Class: CardsController

Inherits:
ApplicationController show all
Defined in:
app/controllers/cards_controller.rb

Instance Method Summary collapse

Instance Method Details

#approveObject

Review verdicts (§3, §14.2). Approve is reversible — the merge happens as Done's entry rule when the human drags the card there.



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'app/controllers/cards_controller.rb', line 80

def approve
  if @card.in_review?
    @card.update!(status: "approved")
    @card.log!("status_change", actor: "user", text: "Work approved — drag to Done to ship")
  end

  respond_to do |format|
    # Flash the verdict in place, then let the dismiss controller minimize the
    # modal. Patch the board face too — Turbo suppresses this tab's own refresh
    # broadcast, so without this the card keeps its stale status once we close.
    format.turbo_stream do
      render turbo_stream: [
        turbo_stream.replace(helpers.dom_id(@card), partial: "cards/card", locals: { card: @card }),
        turbo_stream.replace("review-callout", partial: "cards/dismiss_flash",
                                                locals: { message: "Approved — closing…" })
      ]
    end
    format.html { redirect_to card_path(@card) }
  end
end

#archiveObject

Archive (card #42): off the board, never gone — /board/archive lists, searches, and restores. Running cards can't be archived out from under their agent.



177
178
179
180
181
182
183
184
185
# File 'app/controllers/cards_controller.rb', line 177

def archive
  if @card.running?
    redirect_to card_path(@card), alert: "Card is running — cancel or finish the run first."
  else
    @card.update!(status: "archived")
    @card.log!("status_change", actor: "user", text: "Archived")
    redirect_to root_path
  end
end

#compactObject

Generate a technical "compact" journal on demand (card #34). The AI-readable mirror of #summarize: flip the card into its "working" state, morph the Compact panel so the button reflects it, and let CompactJob do the one-shot synthesis in the background. Skipped when one is already running.



118
119
120
121
122
123
124
125
# File 'app/controllers/cards_controller.rb', line 118

def compact
  unless @card.compact_working?
    @card.update!(compact_status: "working")
    CompactJob.perform_later(@card)
  end
  render turbo_stream: turbo_stream.replace("card_compact",
           partial: "cards/compact_panel", locals: { card: @card })
end

#createObject



9
10
11
12
13
14
15
16
17
# File 'app/controllers/cards_controller.rb', line 9

def create
  board = Board.first!
  column = board.columns.inbox.order(:position).first || board.columns.first
  parent = board.cards.find_by(id: params.dig(:card, :parent_id))
  card = board.cards.create!(column:, parent:, **card_params)
  card.log!("status_change", actor: "user", text: parent ? "Card created as a child of ##{parent.number} #{parent.title}" : "Card created")
  parent&.log!("status_change", actor: "user", text: "Child card added: #{card.title}")
  redirect_to root_path
end

#destroyObject

Rarely needed, deliberately buried in the card modal. A working card must be cancelled first — no killing live agents by deleting their card.



21
22
23
24
25
26
27
28
29
30
# File 'app/controllers/cards_controller.rb', line 21

def destroy
  if @card.working?
    redirect_to card_path(@card)
    return
  end
  workspace_path = Agent::Workspace::Local.new(@card).path
  @card.destroy!
  FileUtils.rm_rf(workspace_path)
  redirect_to root_path
end

#moveObject



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'app/controllers/cards_controller.rb', line 127

def move
  from_column = @card.column
  to_column = @card.board.columns.find(params[:column_id])
  result = CardTransition.new(@card, to_column: to_column, position: params[:position]&.to_i).call
  if result.success?
    # Fresh markup for the affected columns: Turbo suppresses this tab's own
    # refresh broadcasts, so without this the dragged card keeps its stale
    # face (no queued ghosting, no ticker bump) until a job-thread broadcast.
    render turbo_stream: [from_column, to_column].uniq.map { |col|
      turbo_stream.replace(helpers.dom_id(col), partial: "columns/column", locals: { column: col.reload })
    }
  else
    render json: { error: result.error }, status: :unprocessable_entity
  end
end

#newObject



4
5
6
7
# File 'app/controllers/cards_controller.rb', line 4

def new
  @board = Board.first!
  @parent = @board.cards.find_by(id: params[:parent_id])
end

#share_summaryObject

Push the customer summary outward: to the source Asana task as a comment, or to the card's PR. The summary was written for exactly this audience.



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'app/controllers/cards_controller.rb', line 145

def share_summary
  summary = @card.summary.to_s.strip
  if summary.blank?
    @card.log!("error", text: "Nothing to share — the summary is empty.")
    return redirect_to card_path(@card, zoom: "summary")
  end

  flash_text, flash_error = nil, false
  case params[:to]
  when "asana"
    Asana.comment!(@card.asana_url, "Update from Cardinal:\n\n#{summary}")
    @card.log!("status_change", actor: "user", text: "Summary posted to the Asana task as a comment")
    flash_text = "✓ Posted to Asana"
  when "pr"
    out, status = Open3.capture2e("gh", "pr", "comment", @card.pr_url, "--body", summary)
    if status.success?
      @card.log!("status_change", actor: "user", text: "Summary posted as a PR comment")
      flash_text = "✓ Posted to the PR"
    else
      @card.log!("error", text: "Couldn't comment on the PR: #{out.truncate(160)}")
      flash_text, flash_error = "✗ PR comment failed — see the timeline", true
    end
  end
  respond_with_share_flash(flash_text, flash_error)
rescue Asana::Error => e
  @card.log!("error", text: "Couldn't post to Asana: #{e.message}")
  respond_with_share_flash("✗ Asana refused — see the timeline", true)
end

#showObject



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'app/controllers/cards_controller.rb', line 32

def show
  @zoom = params[:zoom].presence_in(%w[conversation activity debug summary compact]) || "conversation"
  @events = case @zoom
            when "conversation" then @card.events.conversation
            when "activity" then @card.events.activity
            when "summary", "compact" then Event.none # these tabs show a card panel, not events
            else @card.events
            end

  # A frame navigation (opening the modal from the board) only needs the modal
  # frame. A direct visit — bookmark, reload, or history restore of /cards/:id —
  # must render the whole board with the modal already open behind it.
  unless turbo_frame_request?
    @board = @card.board
    render "boards/show"
  end
end

#summarizeObject

Generate a customer-friendly summary on demand (card #35). Non-blocking, mirroring the board's deep dive: flip the card into its "working" state, morph the Summary panel so the button reflects it, and let SummaryJob do the one-shot synthesis in the background. Skipped when one is already running.



105
106
107
108
109
110
111
112
# File 'app/controllers/cards_controller.rb', line 105

def summarize
  unless @card.summary_working?
    @card.update!(summary_status: "working")
    SummaryJob.perform_later(@card)
  end
  render turbo_stream: turbo_stream.replace("card_summary",
           partial: "cards/summary_panel", locals: { card: @card })
end

#unarchiveObject



187
188
189
190
191
192
# File 'app/controllers/cards_controller.rb', line 187

def unarchive
  restore = Card::ARCHIVE_RESTORE.fetch(@card.column.archetype, "draft")
  @card.update!(status: restore, position: (@card.column.cards.active.maximum(:position) || -1) + 1)
  @card.log!("status_change", actor: "user", text: "Restored from the archive to #{@card.column.name} (#{restore.humanize.downcase})")
  redirect_to archive_board_path
end

#updateObject



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'app/controllers/cards_controller.rb', line 50

def update
  attrs = card_params
  attrs.delete(:title) if params[:autosave] && attrs[:title].blank? # mid-edit blank, not a delete
  # branch_name and pr_url lock once set (by the user or the agent) — never
  # let a later edit clobber a value that work may already depend on.
  attrs.delete(:branch_name) if @card.branch_name.present?
  attrs.delete(:pr_url) if @card.pr_url.present?
  @card.update!(attrs)
  log_changelog!
  log_config_change!

  respond_to do |format|
    # Explicitly patch the board face in this tab too — Turbo suppresses a
    # tab's own refresh broadcasts, so the morph won't cover the originator.
    # Autosave must NOT replace the modal (it would steal focus mid-typing).
    format.turbo_stream do
      streams = [turbo_stream.replace(helpers.dom_id(@card), partial: "cards/card", locals: { card: @card })]
      unless params[:autosave]
        @zoom = "conversation"
        @events = @card.events.conversation
        streams << turbo_stream.replace("modal", template: "cards/show", formats: [:html])
      end
      render turbo_stream: streams
    end
    format.html { redirect_to card_path(@card) }
  end
end