cardinal-ai 0.2.16 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/cardinal.css +25 -1
- data/app/controllers/asana_controller.rb +31 -0
- data/app/controllers/cards_controller.rb +46 -1
- data/app/services/asana.rb +85 -0
- data/app/views/asana/new_card.html.erb +59 -0
- data/app/views/cards/_card.html.erb +3 -1
- data/app/views/cards/_detail.html.erb +8 -6
- data/app/views/cards/_summary_panel.html.erb +24 -0
- data/app/views/cards/new.html.erb +3 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260705225451_add_asana_url_to_cards.rb +5 -0
- data/lib/cardinal/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e3382a025a7d0f1ce58e7a8a9f64a6a36705a2e27f2e9e9acd116dfa0c8955e8
|
|
4
|
+
data.tar.gz: 13824a0d4d5680a880151780e33b611e16c2299f9c7465bebbf47af5bf32718b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '098257f74f2a65053e822330315ce564e44fddef11ece7cb830364f8f1f8810a8161ae98a3cf439a35d54918b4bcca4670f97ac5f8ced351ed4e0cd8ae724ae2'
|
|
7
|
+
data.tar.gz: 3051666fee426647550bf6abab8abd8d36f79ff8998ca90e03eb0df85b318044aa31389487270bba5a2340e1b48be846cde8253ef40e49e1cf23d0ba4b79b927
|
|
@@ -127,7 +127,13 @@ a { color: var(--blue); text-decoration: none; }
|
|
|
127
127
|
.archive-meta { display: block; font-size: 11px; color: var(--text-dim); margin-top: 3px; }
|
|
128
128
|
.restore-btn { white-space: nowrap; }
|
|
129
129
|
|
|
130
|
-
.
|
|
130
|
+
.composer-box { flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
|
131
|
+
.composer-box textarea { width: 100%; }
|
|
132
|
+
.note-toggle {
|
|
133
|
+
font-size: 11px; color: var(--text-dim); cursor: pointer; align-self: flex-start;
|
|
134
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
135
|
+
}
|
|
136
|
+
.note-toggle input[type="checkbox"] { margin: 0; }
|
|
131
137
|
.note-toggle input { accent-color: var(--amber); }
|
|
132
138
|
.note-flag { font-size: 10px; color: var(--amber); border: 1px solid var(--amber); border-radius: 8px; padding: 0 6px; margin-right: 4px; }
|
|
133
139
|
|
|
@@ -152,6 +158,24 @@ a { color: var(--blue); text-decoration: none; }
|
|
|
152
158
|
.archive-rails .inline-check { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; cursor: pointer; }
|
|
153
159
|
.archive-rails .hint { display: block; margin-top: 6px; }
|
|
154
160
|
|
|
161
|
+
.asana-entry {
|
|
162
|
+
margin-right: auto; display: inline-flex; align-items: center;
|
|
163
|
+
background: transparent; color: var(--text-dim); border: 1px solid var(--border);
|
|
164
|
+
border-radius: 6px; padding: 6px 12px; font-weight: 600; font-size: inherit;
|
|
165
|
+
}
|
|
166
|
+
.asana-entry:hover { color: var(--text); border-color: var(--text-dim); }
|
|
167
|
+
.asana-steps { font-size: 13px; margin: 8px 0 14px; padding-left: 20px; }
|
|
168
|
+
.asana-steps li { margin: 4px 0; }
|
|
169
|
+
.asana-connected { color: var(--green); }
|
|
170
|
+
|
|
171
|
+
.summary-share { display: flex; align-items: center; margin-top: 8px; flex-shrink: 0; }
|
|
172
|
+
.summary-share button[disabled] { opacity: .4; cursor: not-allowed; pointer-events: none; }
|
|
173
|
+
.share-spacer { flex: 1; text-align: center; }
|
|
174
|
+
.share-status { font-size: 12px; font-weight: 600; color: var(--green);
|
|
175
|
+
animation: share-fade 4s ease forwards; }
|
|
176
|
+
.share-status.share-err { color: var(--red); animation: none; }
|
|
177
|
+
@keyframes share-fade { 0%, 70% { opacity: 1; } 100% { opacity: 0; } }
|
|
178
|
+
|
|
155
179
|
.pull-form { display: inline; }
|
|
156
180
|
#repo-pull-status { font-size: 0.85rem; }
|
|
157
181
|
#repo-pull-status .pull-ok { color: var(--green); }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Asana import (card #7). First visit is a connect wizard (PAT walkthrough);
|
|
2
|
+
# once connected it's a one-field modal: paste the task URL, get a card.
|
|
3
|
+
class AsanaController < ApplicationController
|
|
4
|
+
def new_card
|
|
5
|
+
@board = Board.first!
|
|
6
|
+
redirect_to root_path and return unless turbo_frame_request?
|
|
7
|
+
@connected = Asana.connected?
|
|
8
|
+
@error = params[:error]
|
|
9
|
+
@just_connected = params[:connected]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def connect
|
|
13
|
+
name = Asana.verify!(params.require(:token))
|
|
14
|
+
Asana.save_token!(params[:token])
|
|
15
|
+
redirect_to asana_new_card_path(connected: name)
|
|
16
|
+
rescue Asana::Error => e
|
|
17
|
+
redirect_to asana_new_card_path(error: e.message)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def import
|
|
21
|
+
card = Asana.import!(Board.first!, params.require(:url))
|
|
22
|
+
redirect_to card_path(card)
|
|
23
|
+
rescue Asana::Error => e
|
|
24
|
+
redirect_to asana_new_card_path(error: e.message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def disconnect
|
|
28
|
+
Asana.disconnect!
|
|
29
|
+
redirect_to asana_new_card_path
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
class CardsController < ApplicationController
|
|
2
|
-
before_action :set_card, only: [:show, :update, :move, :approve, :summarize, :compact, :destroy, :archive, :unarchive]
|
|
2
|
+
before_action :set_card, only: [:show, :update, :move, :approve, :summarize, :compact, :destroy, :archive, :unarchive, :share_summary]
|
|
3
3
|
|
|
4
4
|
def new
|
|
5
5
|
@board = Board.first!
|
|
@@ -140,6 +140,37 @@ class CardsController < ApplicationController
|
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
+
# Push the customer summary outward: to the source Asana task as a comment,
|
|
144
|
+
# or to the card's PR. The summary was written for exactly this audience.
|
|
145
|
+
def share_summary
|
|
146
|
+
summary = @card.summary.to_s.strip
|
|
147
|
+
if summary.blank?
|
|
148
|
+
@card.log!("error", text: "Nothing to share — the summary is empty.")
|
|
149
|
+
return redirect_to card_path(@card, zoom: "summary")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
flash_text, flash_error = nil, false
|
|
153
|
+
case params[:to]
|
|
154
|
+
when "asana"
|
|
155
|
+
Asana.comment!(@card.asana_url, "Update from Cardinal:\n\n#{summary}")
|
|
156
|
+
@card.log!("status_change", actor: "user", text: "Summary posted to the Asana task as a comment")
|
|
157
|
+
flash_text = "✓ Posted to Asana"
|
|
158
|
+
when "pr"
|
|
159
|
+
out, status = Open3.capture2e("gh", "pr", "comment", @card.pr_url, "--body", summary)
|
|
160
|
+
if status.success?
|
|
161
|
+
@card.log!("status_change", actor: "user", text: "Summary posted as a PR comment")
|
|
162
|
+
flash_text = "✓ Posted to the PR"
|
|
163
|
+
else
|
|
164
|
+
@card.log!("error", text: "Couldn't comment on the PR: #{out.truncate(160)}")
|
|
165
|
+
flash_text, flash_error = "✗ PR comment failed — see the timeline", true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
respond_with_share_flash(flash_text, flash_error)
|
|
169
|
+
rescue Asana::Error => e
|
|
170
|
+
@card.log!("error", text: "Couldn't post to Asana: #{e.message}")
|
|
171
|
+
respond_with_share_flash("✗ Asana refused — see the timeline", true)
|
|
172
|
+
end
|
|
173
|
+
|
|
143
174
|
# Archive (card #42): off the board, never gone — /board/archive lists,
|
|
144
175
|
# searches, and restores. Running cards can't be archived out from under
|
|
145
176
|
# their agent.
|
|
@@ -162,6 +193,20 @@ class CardsController < ApplicationController
|
|
|
162
193
|
|
|
163
194
|
private
|
|
164
195
|
|
|
196
|
+
# In-place feedback on the Summary tab: replace the panel with a transient
|
|
197
|
+
# ✓/✗ flash next to the share buttons (falls back to a redirect for plain
|
|
198
|
+
# HTML requests).
|
|
199
|
+
def respond_with_share_flash(text, error)
|
|
200
|
+
respond_to do |format|
|
|
201
|
+
format.turbo_stream do
|
|
202
|
+
render turbo_stream: turbo_stream.replace("card_summary",
|
|
203
|
+
partial: "cards/summary_panel",
|
|
204
|
+
locals: { card: @card.reload, share_status: text, share_error: error })
|
|
205
|
+
end
|
|
206
|
+
format.html { redirect_to card_path(@card, zoom: "summary") }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
165
210
|
def set_card
|
|
166
211
|
@card = Board.first!.cards.find_by!(number: params[:id])
|
|
167
212
|
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
|
|
3
|
+
# Asana import (card #7): paste a task URL, get a card. First use walks
|
|
4
|
+
# through connecting a Personal Access Token, which lives as a 0600 file in
|
|
5
|
+
# .cardinal/ (like the Claude token) — never in the database, never in git.
|
|
6
|
+
module Asana
|
|
7
|
+
API = "https://app.asana.com/api/1.0".freeze
|
|
8
|
+
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
def self.token_path
|
|
12
|
+
Pathname(File.expand_path(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("asana-token")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.connected? = !!File.size?(token_path)
|
|
16
|
+
def self.token = File.read(token_path).strip
|
|
17
|
+
|
|
18
|
+
def self.save_token!(value)
|
|
19
|
+
FileUtils.mkdir_p(File.dirname(token_path))
|
|
20
|
+
File.write(token_path, value.strip)
|
|
21
|
+
File.chmod(0o600, token_path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.disconnect! = FileUtils.rm_f(token_path)
|
|
25
|
+
|
|
26
|
+
# Cheapest possible "does this token work" — also gives us a name to show.
|
|
27
|
+
def self.verify!(candidate)
|
|
28
|
+
data = request("/users/me", candidate)
|
|
29
|
+
data["name"].presence || data["email"].presence || "connected"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Task URLs come in several vintages (/0/<project>/<task>, /0/.../f,
|
|
33
|
+
# /1/<ws>/project/<p>/task/<t>) — the task gid is the last long digit run.
|
|
34
|
+
def self.task_gid(url)
|
|
35
|
+
url.to_s.scan(/\d{6,}/).last or raise Error, "That doesn't look like an Asana task URL"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.import!(board, url)
|
|
39
|
+
data = request("/tasks/#{task_gid(url)}?opt_fields=name,notes,permalink_url,tags.name", token)
|
|
40
|
+
permalink = data["permalink_url"].presence || url
|
|
41
|
+
if (existing = board.cards.find_by(asana_url: permalink))
|
|
42
|
+
return existing
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
inbox = board.columns.inbox.order(:position).first
|
|
46
|
+
card = board.cards.create!(
|
|
47
|
+
column: inbox,
|
|
48
|
+
title: data["name"].presence || "Asana task",
|
|
49
|
+
asana_url: permalink,
|
|
50
|
+
tags: Array(data["tags"]).filter_map { |t| t["name"] }.first(5),
|
|
51
|
+
description: "#{data["notes"].presence || "(no description on the Asana task)"}\n\n---\n_Imported from Asana: #{permalink}_"
|
|
52
|
+
)
|
|
53
|
+
card.log!("status_change", actor: "user", text: "Imported from Asana: #{permalink}")
|
|
54
|
+
card
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Post text to the task as a comment (an Asana "story").
|
|
58
|
+
def self.comment!(task_url, text)
|
|
59
|
+
request("/tasks/#{task_gid(task_url)}/stories", token,
|
|
60
|
+
method: :post, body: { data: { text: text } })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.request(path, auth, method: :get, body: nil)
|
|
64
|
+
uri = URI("#{API}#{path}")
|
|
65
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
66
|
+
http.use_ssl = true
|
|
67
|
+
http.open_timeout = 10
|
|
68
|
+
http.read_timeout = 15
|
|
69
|
+
headers = { "Authorization" => "Bearer #{auth}" }
|
|
70
|
+
response =
|
|
71
|
+
if method == :post
|
|
72
|
+
http.post(uri.request_uri, body.to_json, headers.merge("Content-Type" => "application/json"))
|
|
73
|
+
else
|
|
74
|
+
http.get(uri.request_uri, headers)
|
|
75
|
+
end
|
|
76
|
+
unless [200, 201].include?(response.code.to_i)
|
|
77
|
+
raise Error, "Asana said no (HTTP #{response.code}) — check the token, and that it can see this task"
|
|
78
|
+
end
|
|
79
|
+
JSON.parse(response.body)["data"]
|
|
80
|
+
rescue JSON::ParserError
|
|
81
|
+
raise Error, "Asana returned something unreadable"
|
|
82
|
+
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
83
|
+
raise Error, "Couldn't reach Asana (#{e.class.name.demodulize}) — check your connection"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
|
|
2
|
+
<div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
|
|
3
|
+
<div class="modal modal-sm">
|
|
4
|
+
<header class="modal-header">
|
|
5
|
+
<h1>◯ New from Asana</h1>
|
|
6
|
+
<button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
|
|
7
|
+
</header>
|
|
8
|
+
<div class="modal-body">
|
|
9
|
+
<% if @error.present? %><p class="form-error"><%= @error %></p><% end %>
|
|
10
|
+
|
|
11
|
+
<% if @connected %>
|
|
12
|
+
<% if @just_connected.present? %>
|
|
13
|
+
<p class="hint asana-connected">✅ Connected as <strong><%= @just_connected %></strong> — you won't need to do that again.</p>
|
|
14
|
+
<% end %>
|
|
15
|
+
<p class="hint">Paste the Asana task's URL (open the task, copy the address bar or
|
|
16
|
+
use "Copy task link"). Cardinal pulls the title, description, and tags into a new
|
|
17
|
+
Tasks card that links back to Asana.</p>
|
|
18
|
+
|
|
19
|
+
<%= form_with url: asana_import_path, class: "card-edit",
|
|
20
|
+
data: { turbo_frame: "_top" } do |f| %>
|
|
21
|
+
<label>Asana task URL</label>
|
|
22
|
+
<%= f.text_field :url, required: true, autofocus: true,
|
|
23
|
+
placeholder: "https://app.asana.com/0/1200000000000000/1205000000000000" %>
|
|
24
|
+
<div class="card-edit-actions">
|
|
25
|
+
<button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
|
|
26
|
+
<%= f.submit "Import task" %>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
29
|
+
<details class="advanced-rules">
|
|
30
|
+
<summary>Connection</summary>
|
|
31
|
+
<p class="hint">Your Asana token lives in <code>.cardinal/asana-token</code> on this machine only.</p>
|
|
32
|
+
<%= button_to "Unlink Asana", asana_disconnect_path, class: "btn-cancel",
|
|
33
|
+
form: { data: { turbo_frame: "modal" } } %>
|
|
34
|
+
</details>
|
|
35
|
+
|
|
36
|
+
<% else %>
|
|
37
|
+
<p class="hint"><strong>One-time setup:</strong> Cardinal talks to Asana with a
|
|
38
|
+
Personal Access Token that stays on this machine
|
|
39
|
+
(<code>.cardinal/asana-token</code>, never committed, never in the database).</p>
|
|
40
|
+
<ol class="asana-steps">
|
|
41
|
+
<li>Open <a href="https://app.asana.com/0/my-apps" target="_blank" rel="noopener">app.asana.com/0/my-apps</a> (Asana → Settings → Apps → Developer console)</li>
|
|
42
|
+
<li>Click <strong>Create new token</strong>, name it something like <code>cardinal</code></li>
|
|
43
|
+
<li>Copy the token and paste it below</li>
|
|
44
|
+
</ol>
|
|
45
|
+
|
|
46
|
+
<%= form_with url: asana_connect_path, class: "card-edit" do |f| %>
|
|
47
|
+
<label>Personal Access Token</label>
|
|
48
|
+
<%= f.password_field :token, required: true, autofocus: true,
|
|
49
|
+
placeholder: "1/1234567890:abcdef…", autocomplete: "off" %>
|
|
50
|
+
<div class="card-edit-actions">
|
|
51
|
+
<button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
|
|
52
|
+
<%= f.submit "Connect Asana" %>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
<% end %>
|
|
@@ -34,10 +34,12 @@
|
|
|
34
34
|
</div>
|
|
35
35
|
<% end %>
|
|
36
36
|
<% has_cost = card.total_cost.positive? || card.total_output_tokens.positive? %>
|
|
37
|
-
<% if has_cost || card.pr_url.present? %>
|
|
37
|
+
<% if has_cost || card.pr_url.present? || card.asana_url.present? %>
|
|
38
38
|
<div class="card-footer">
|
|
39
39
|
<% if card.pr_url.present? %>
|
|
40
40
|
<a class="footer-pr" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</a>
|
|
41
|
+
<% elsif card.asana_url.present? %>
|
|
42
|
+
<a class="footer-pr" href="<%= card.asana_url %>" target="_blank" rel="noopener" title="Open the source task on Asana">◯ Asana ↗</a>
|
|
41
43
|
<% else %>
|
|
42
44
|
<span class="footer-pr"></span>
|
|
43
45
|
<% end %>
|
|
@@ -73,12 +73,14 @@
|
|
|
73
73
|
data-action="scroll#jump">↓ New messages</button>
|
|
74
74
|
|
|
75
75
|
<%= form_with url: card_messages_path(@card), class: "message-form" do |f| %>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<%= f.
|
|
81
|
-
|
|
76
|
+
<div class="composer-box">
|
|
77
|
+
<label class="note-toggle" title="Record this message without asking any AI to respond. Future agents (the next column's assistant or worker) still read it as context — perfect for a parting instruction right before you drag the card onward.">
|
|
78
|
+
<%= f.check_box "message[note]", {}, "1", "0" %> 📝 note only — no AI reply
|
|
79
|
+
</label>
|
|
80
|
+
<%= f.text_area "message[text]", rows: 2, required: true,
|
|
81
|
+
data: { controller: "composer attach", action: "keydown->composer#keydown paste->attach#paste" },
|
|
82
|
+
placeholder: (@card.column.planning? ? "Discuss this card with the planning assistant…" : "Add a note to this card…") + " (Enter sends, Shift+Enter for a new line)" %>
|
|
83
|
+
</div>
|
|
82
84
|
<% end %>
|
|
83
85
|
<% end %>
|
|
84
86
|
</section>
|
|
@@ -27,4 +27,28 @@
|
|
|
27
27
|
disabled: card.summary_working? %>
|
|
28
28
|
<% end %>
|
|
29
29
|
|
|
30
|
+
<% can_asana = Asana.connected? && card.asana_url.present? %>
|
|
31
|
+
<% can_pr = card.pr_url.present? %>
|
|
32
|
+
<% if can_asana || can_pr %>
|
|
33
|
+
<div class="summary-share">
|
|
34
|
+
<% if can_asana %>
|
|
35
|
+
<%= button_to "◯ Post as comment on Asana task", share_summary_card_path(card, to: "asana"),
|
|
36
|
+
class: "theme-toggle", disabled: card.summary.blank?,
|
|
37
|
+
data: { turbo_submits_with: "Posting…" },
|
|
38
|
+
title: "Post this summary as a comment on #{card.asana_url}" %>
|
|
39
|
+
<% end %>
|
|
40
|
+
<span class="share-spacer">
|
|
41
|
+
<% if local_assigns[:share_status].present? %>
|
|
42
|
+
<span class="share-status<%= " share-err" if local_assigns[:share_error] %>"><%= share_status %></span>
|
|
43
|
+
<% end %>
|
|
44
|
+
</span>
|
|
45
|
+
<% if can_pr %>
|
|
46
|
+
<%= button_to "💬 Post as comment on PR", share_summary_card_path(card, to: "pr"),
|
|
47
|
+
class: "theme-toggle", disabled: card.summary.blank?,
|
|
48
|
+
data: { turbo_submits_with: "Posting…" },
|
|
49
|
+
title: "Post this summary as a comment on #{card.pr_url}" %>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
30
54
|
</div>
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
<%= f.text_field "card[pr_url]", placeholder: "https://github.com/owner/repo/pull/123" %>
|
|
26
26
|
<p class="hint">Leave blank and the agent picks a branch. Set either to point work at an existing branch or PR.</p>
|
|
27
27
|
<div class="card-edit-actions">
|
|
28
|
+
<%= link_to "◯ New from Asana", asana_new_card_path, class: "asana-entry",
|
|
29
|
+
data: { turbo_frame: "modal" },
|
|
30
|
+
title: "Import an Asana task as a card — first use walks you through connecting" %>
|
|
28
31
|
<button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
|
|
29
32
|
<%= f.submit "Save" %>
|
|
30
33
|
</div>
|
data/config/routes.rb
CHANGED
|
@@ -15,11 +15,17 @@ Rails.application.routes.draw do
|
|
|
15
15
|
post :approve
|
|
16
16
|
post :archive
|
|
17
17
|
post :unarchive
|
|
18
|
+
post :share_summary
|
|
18
19
|
post :summarize
|
|
19
20
|
post :compact
|
|
20
21
|
end
|
|
21
22
|
resources :messages, only: [:create]
|
|
22
23
|
end
|
|
24
|
+
get "asana/new" => "asana#new_card", as: :asana_new_card
|
|
25
|
+
post "asana/connect" => "asana#connect", as: :asana_connect
|
|
26
|
+
post "asana/import" => "asana#import", as: :asana_import
|
|
27
|
+
post "asana/disconnect" => "asana#disconnect", as: :asana_disconnect
|
|
28
|
+
|
|
23
29
|
resources :columns, only: [:create, :edit, :update, :destroy]
|
|
24
30
|
resources :runs, only: [] do
|
|
25
31
|
member do
|
data/lib/cardinal/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cardinal-ai
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.18
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jason Ellis
|
|
@@ -163,6 +163,7 @@ files:
|
|
|
163
163
|
- app/assets/stylesheets/application.css
|
|
164
164
|
- app/assets/stylesheets/cardinal.css
|
|
165
165
|
- app/controllers/application_controller.rb
|
|
166
|
+
- app/controllers/asana_controller.rb
|
|
166
167
|
- app/controllers/boards_controller.rb
|
|
167
168
|
- app/controllers/cards_controller.rb
|
|
168
169
|
- app/controllers/columns_controller.rb
|
|
@@ -209,12 +210,14 @@ files:
|
|
|
209
210
|
- app/models/run.rb
|
|
210
211
|
- app/services/agent/runner.rb
|
|
211
212
|
- app/services/agent/workspace.rb
|
|
213
|
+
- app/services/asana.rb
|
|
212
214
|
- app/services/card_transition.rb
|
|
213
215
|
- app/services/claude_cli.rb
|
|
214
216
|
- app/services/github_issues.rb
|
|
215
217
|
- app/services/rules.rb
|
|
216
218
|
- app/services/rules/compiler.rb
|
|
217
219
|
- app/services/run_sweeper.rb
|
|
220
|
+
- app/views/asana/new_card.html.erb
|
|
218
221
|
- app/views/boards/archive.html.erb
|
|
219
222
|
- app/views/boards/brief.html.erb
|
|
220
223
|
- app/views/boards/edit.html.erb
|
|
@@ -276,6 +279,7 @@ files:
|
|
|
276
279
|
- db/migrate/20260705120000_create_ai_calls.rb
|
|
277
280
|
- db/migrate/20260705120001_add_issue_number_to_cards.rb
|
|
278
281
|
- db/migrate/20260705193935_add_settings_to_boards.rb
|
|
282
|
+
- db/migrate/20260705225451_add_asana_url_to_cards.rb
|
|
279
283
|
- db/queue_schema.rb
|
|
280
284
|
- db/seeds.rb
|
|
281
285
|
- docker/agent/Dockerfile
|