agentilda 1.0.3 → 2.0.1
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/agentilda.gemspec +10 -6
- data/agents/hansolo-reviewer.md +42 -13
- data/agents/lando-broker.md +40 -46
- data/agents/leah-researcher.md +36 -58
- data/agents/luke-backend.md +49 -61
- data/agents/palpatine-planner.md +39 -21
- data/agents/rey-frontend.md +44 -86
- data/agents/yoda-writer.md +38 -36
- data/bin/setup +26 -0
- data/exe/agentilda +13 -92
- data/exe/tilda-rs +0 -0
- data/lib/agentilda/adoption.rb +7 -2
- data/lib/agentilda/agent.rb +40 -99
- data/lib/agentilda/agents.rb +122 -0
- data/lib/agentilda/board.rb +91 -0
- data/lib/agentilda/brief.rb +54 -5
- data/lib/agentilda/child.rb +92 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
- data/lib/agentilda/cli/base.rb +46 -6
- data/lib/agentilda/cli/create/create.rb +46 -19
- data/lib/agentilda/cli/docs/docs.rb +6 -2
- data/lib/agentilda/cli/index/index.rb +6 -4
- data/lib/agentilda/cli/linear/linear.rb +3 -2
- data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
- data/lib/agentilda/cli/mail/mail.rb +31 -0
- data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
- data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
- data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
- data/lib/agentilda/cli/run/run.rb +207 -99
- data/lib/agentilda/cli/unblock/unblock.rb +24 -13
- data/lib/agentilda/cli/worktree/worktree.rb +85 -0
- data/lib/agentilda/cli.rb +43 -0
- data/lib/agentilda/clock.rb +114 -0
- data/lib/agentilda/console.rb +157 -0
- data/lib/agentilda/control.rb +63 -0
- data/lib/agentilda/creator.rb +7 -2
- data/lib/agentilda/dashboard.rb +219 -0
- data/lib/agentilda/dev_work.rb +2 -0
- data/lib/agentilda/diagram.rb +12 -7
- data/lib/agentilda/dispatcher.rb +646 -0
- data/lib/agentilda/documentation.rb +14 -6
- data/lib/agentilda/executor.rb +362 -204
- data/lib/agentilda/feature.rb +14 -135
- data/lib/agentilda/frontmatter.rb +4 -2
- data/lib/agentilda/github.rb +19 -11
- data/lib/agentilda/index.rb +11 -11
- data/lib/agentilda/keyboard.rb +76 -8
- data/lib/agentilda/launcher.rb +69 -0
- data/lib/agentilda/ledger.rb +233 -0
- data/lib/agentilda/linear/api.rb +14 -10
- data/lib/agentilda/linear/attribution.rb +5 -5
- data/lib/agentilda/linear/import.rb +30 -14
- data/lib/agentilda/linear/issue.rb +6 -3
- data/lib/agentilda/linear/mapping.rb +18 -17
- data/lib/agentilda/linear/push.rb +19 -12
- data/lib/agentilda/linear/survey.rb +3 -2
- data/lib/agentilda/linear/unit.rb +12 -12
- data/lib/agentilda/linear.rb +5 -6
- data/lib/agentilda/mailbox.rb +141 -0
- data/lib/agentilda/markdown.rb +1 -1
- data/lib/agentilda/progress_log.rb +5 -1
- data/lib/agentilda/publisher.rb +6 -3
- data/lib/agentilda/pull_request.rb +0 -186
- data/lib/agentilda/pull_requests.rb +230 -0
- data/lib/agentilda/reporter.rb +9 -3
- data/lib/agentilda/resync.rb +120 -52
- data/lib/agentilda/roster.rb +31 -48
- data/lib/agentilda/runner.rb +72 -389
- data/lib/agentilda/screen/ratatui/bar.rb +57 -0
- data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
- data/lib/agentilda/screen/ratatui.rb +318 -0
- data/lib/agentilda/state_file.rb +174 -0
- data/lib/agentilda/state_machine.rb +60 -32
- data/lib/agentilda/status.rb +120 -37
- data/lib/agentilda/subject.rb +133 -0
- data/lib/agentilda/tally.rb +17 -11
- data/lib/agentilda/transcript.rb +47 -16
- data/lib/agentilda/tree.rb +10 -6
- data/lib/agentilda/ui.rb +204 -190
- data/lib/agentilda/unblocker.rb +25 -11
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda/worktree.rb +14 -18
- data/lib/agentilda.rb +57 -70
- metadata +65 -13
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -261
- data/bin/create-plan-folder +0 -125
- data/lib/dry/cli/banner.rb +0 -293
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
# The dated notes an agent leaves in the document it owns.
|
|
7
|
+
#
|
|
8
|
+
# This is the only place in the codebase that knows the syntax. The
|
|
9
|
+
# dispatcher reads it to learn whether an agent finished and who it named
|
|
10
|
+
# next; the state file records it; the screen paints it. An agent writes
|
|
11
|
+
# one entry when it starts and one when it stops, in a GitHub NOTE alert,
|
|
12
|
+
# one entry per line:
|
|
13
|
+
#
|
|
14
|
+
# > [!NOTE]
|
|
15
|
+
# >
|
|
16
|
+
# > [2026-09-04 11:29:20 AM PDT] [ agent: leah-researcher status: Started, round 1 ]
|
|
17
|
+
# > [2026-09-04 11:44:03 AM PDT] [ agent: leah-researcher status: Completed, round 1 ]
|
|
18
|
+
# > [2026-09-04 11:44:04 AM PDT] [ next: yoda-writer ]
|
|
19
|
+
#
|
|
20
|
+
# The verbs are a closed set. A line that looks like an entry but does not
|
|
21
|
+
# parse is reported as a {Problem}, never dropped, because an agent that
|
|
22
|
+
# wrote "Done" has said nothing the harness can act on and silence would
|
|
23
|
+
# read as a stall.
|
|
24
|
+
module Ledger
|
|
25
|
+
# Everything an agent may claim. `Completed` is the only verb a `next:`
|
|
26
|
+
# line may follow.
|
|
27
|
+
STATUSES = ["Started", "Completed", "Almost completed", "Interrupted", "Blocked"].freeze
|
|
28
|
+
|
|
29
|
+
# Local time with the zone spelled out, so an entry written on one
|
|
30
|
+
# machine reads correctly on another.
|
|
31
|
+
TIME_FORMAT = "%Y-%m-%d %I:%M:%S %p %Z"
|
|
32
|
+
|
|
33
|
+
# An entry. `**` around the status is how the harness marks the one line
|
|
34
|
+
# it writes itself, the Interrupted that precedes a signature.
|
|
35
|
+
ENTRY = /\A>\s*\[(?<at>[^\]]+)\]\s*\[\s*agent:\s*(?<agent>[\w-]+)\s+status:\s*(?<bold>\*\*)?
|
|
36
|
+
(?<status>#{STATUSES.map { |s| Regexp.escape(s) }.join("|")}),\s*round\s*(?<round>\d+)
|
|
37
|
+
(?:\s*\((?<note>[^)]*)\))?\s*(?:\*\*)?\s*\]\s*\z/x
|
|
38
|
+
|
|
39
|
+
# The handoff line.
|
|
40
|
+
NEXT = /\A>\s*\[(?<at>[^\]]+)\]\s*\[\s*next:\s*(?<next>[\w-]+)\s*\]\s*\z/
|
|
41
|
+
|
|
42
|
+
# A line that is trying to be one of the two above. Anything matching
|
|
43
|
+
# this and neither of those is a {Problem}.
|
|
44
|
+
ATTEMPT = /\A>\s*\[[^\]]+\]\s*\[/
|
|
45
|
+
|
|
46
|
+
# The whole alert block, for {.stripped}.
|
|
47
|
+
BLOCK = /^[ \t]*>[ \t]*\[!NOTE\][ \t]*\n(?:[ \t]*>.*\n?)*/
|
|
48
|
+
|
|
49
|
+
# @!attribute [r] at
|
|
50
|
+
# @return [Time]
|
|
51
|
+
# @!attribute [r] agent
|
|
52
|
+
# @return [String] the definition's name, `leah-researcher`
|
|
53
|
+
# @!attribute [r] status
|
|
54
|
+
# @return [String] one of {STATUSES}
|
|
55
|
+
# @!attribute [r] round
|
|
56
|
+
# @return [Integer]
|
|
57
|
+
# @!attribute [r] note
|
|
58
|
+
# @return [String, nil] the parenthesised tail, `rejected 1/2`
|
|
59
|
+
# @!attribute [r] file
|
|
60
|
+
# @return [String] the document it was read from
|
|
61
|
+
# @!attribute [r] line
|
|
62
|
+
# @return [Integer] 1-based
|
|
63
|
+
# @!attribute [r] bold
|
|
64
|
+
# @return [Boolean] written `**bold**`, which only the harness does
|
|
65
|
+
Entry = Data.define(:at, :agent, :status, :round, :note, :file, :line, :bold) do
|
|
66
|
+
def initialize(note: nil, bold: false, **rest) = super
|
|
67
|
+
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def completed? = status == "Completed"
|
|
70
|
+
|
|
71
|
+
# @return [Boolean] whether the agent gets another round
|
|
72
|
+
def retry? = ["Almost completed", "Interrupted"].include?(status)
|
|
73
|
+
|
|
74
|
+
# @return [Boolean]
|
|
75
|
+
def blocked? = status == "Blocked"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @!attribute [r] next
|
|
79
|
+
# @return [String] the agent named
|
|
80
|
+
Handoff = Data.define(:at, :next, :file, :line)
|
|
81
|
+
|
|
82
|
+
# @!attribute [r] text
|
|
83
|
+
# @return [String] the line as written
|
|
84
|
+
Problem = Data.define(:file, :line, :text)
|
|
85
|
+
|
|
86
|
+
# What one read of a plan folder yielded.
|
|
87
|
+
Reading = Data.define(:entries, :handoffs, :problems) do
|
|
88
|
+
# @return [Agentilda::Ledger::Reading]
|
|
89
|
+
def self.empty = new(entries: [], handoffs: [], problems: [])
|
|
90
|
+
|
|
91
|
+
# @param other [Agentilda::Ledger::Reading]
|
|
92
|
+
# @return [Agentilda::Ledger::Reading]
|
|
93
|
+
def +(other)
|
|
94
|
+
self.class.new(entries: entries + other.entries,
|
|
95
|
+
handoffs: handoffs + other.handoffs,
|
|
96
|
+
problems: problems + other.problems)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class << self
|
|
101
|
+
# @param text [String] a whole document
|
|
102
|
+
# @param file [String] its name, carried on every entry
|
|
103
|
+
# @return [Agentilda::Ledger::Reading]
|
|
104
|
+
def parse(text, file:)
|
|
105
|
+
entries = []
|
|
106
|
+
handoffs = []
|
|
107
|
+
problems = []
|
|
108
|
+
text.to_s.each_line.with_index(1) do |raw, line|
|
|
109
|
+
stripped = raw.chomp
|
|
110
|
+
if (m = ENTRY.match(stripped))
|
|
111
|
+
entries << Entry.new(at: parse_time(m[:at]),
|
|
112
|
+
agent: m[:agent],
|
|
113
|
+
status: m[:status],
|
|
114
|
+
round: m[:round].to_i,
|
|
115
|
+
note: m[:note]&.strip,
|
|
116
|
+
file:,
|
|
117
|
+
line:,
|
|
118
|
+
bold: !m[:bold].nil?)
|
|
119
|
+
elsif (m = NEXT.match(stripped))
|
|
120
|
+
handoffs << Handoff.new(at: parse_time(m[:at]), next: m[:next], file:, line:)
|
|
121
|
+
elsif ATTEMPT.match?(stripped)
|
|
122
|
+
problems << Problem.new(file:, line:, text: stripped.strip)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
Reading.new(entries:, handoffs:, problems:)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Every document an agent may sign, in the order its definition lists
|
|
129
|
+
# them. A missing file is not a problem; an agent that has not reached
|
|
130
|
+
# `pull-requests.md` yet has not written it.
|
|
131
|
+
#
|
|
132
|
+
# @param dir [String] the plan folder
|
|
133
|
+
# @param files [Array<String>]
|
|
134
|
+
# @return [Agentilda::Ledger::Reading]
|
|
135
|
+
def read(dir, files)
|
|
136
|
+
files.each_with_index.reduce(Reading.empty) do |reading, (name, _index)|
|
|
137
|
+
path = File.join(dir, name)
|
|
138
|
+
next reading unless File.file?(path)
|
|
139
|
+
|
|
140
|
+
found = parse(File.read(path, encoding: "UTF-8"), file: name)
|
|
141
|
+
reading + Reading.new(entries: found.entries.map { |e| e.with(file: name) },
|
|
142
|
+
handoffs: found.handoffs,
|
|
143
|
+
problems: found.problems)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @param time [Time]
|
|
148
|
+
# @return [String]
|
|
149
|
+
def timestamp(time = Time.now) = time.strftime(TIME_FORMAT)
|
|
150
|
+
|
|
151
|
+
# Agents write what `date` prints, and `Time.strptime` cannot read a
|
|
152
|
+
# zone abbreviation back into an offset. The abbreviation is dropped
|
|
153
|
+
# and the time read as local, which is right on the machine that wrote
|
|
154
|
+
# it and off by a zone anywhere else, which the tick tolerates: entries
|
|
155
|
+
# are compared with each other, never with the wall clock.
|
|
156
|
+
#
|
|
157
|
+
# @param text [String]
|
|
158
|
+
# @return [Time]
|
|
159
|
+
def parse_time(text)
|
|
160
|
+
Time.strptime(text.to_s.strip.sub(/\s+[A-Z]{2,5}\z/, ""), "%Y-%m-%d %I:%M:%S %p")
|
|
161
|
+
rescue ArgumentError
|
|
162
|
+
Time.at(0)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @param entry [Agentilda::Ledger::Entry]
|
|
166
|
+
# @return [String] one quoted line, no newline
|
|
167
|
+
def render(entry)
|
|
168
|
+
tail = entry.note ? " (#{entry.note})" : ""
|
|
169
|
+
body = "#{entry.status}, round #{entry.round}#{tail}"
|
|
170
|
+
body = "**#{body}**" if entry.bold
|
|
171
|
+
"> [#{timestamp(entry.at)}] [ agent: #{entry.agent} status: #{body} ]"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @param handoff [Agentilda::Ledger::Handoff]
|
|
175
|
+
# @return [String]
|
|
176
|
+
def render_handoff(handoff) = "> [#{timestamp(handoff.at)}] [ next: #{handoff.next} ]"
|
|
177
|
+
|
|
178
|
+
# @param lines [Array<String>] already quoted
|
|
179
|
+
# @return [String] a NOTE alert, newline-terminated
|
|
180
|
+
def block(*lines) = (["> [!NOTE]", ">"] + lines.flatten).join("\n") + "\n"
|
|
181
|
+
|
|
182
|
+
# Append one block. Separated from what is there by a blank line so the
|
|
183
|
+
# alert renders as its own block rather than merging into a paragraph.
|
|
184
|
+
#
|
|
185
|
+
# @param path [String]
|
|
186
|
+
# @param lines [Array<String>]
|
|
187
|
+
# @return [void]
|
|
188
|
+
def append(path, *lines)
|
|
189
|
+
existing = File.file?(path) ? File.read(path, encoding: "UTF-8") : ""
|
|
190
|
+
glue = if existing.empty?
|
|
191
|
+
""
|
|
192
|
+
elsif existing.end_with?("\n\n")
|
|
193
|
+
""
|
|
194
|
+
elsif existing.end_with?("\n")
|
|
195
|
+
"\n"
|
|
196
|
+
else
|
|
197
|
+
"\n\n"
|
|
198
|
+
end
|
|
199
|
+
File.write(path, existing + glue + block(*lines))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The entry that tells the harness where this agent stands: the latest
|
|
203
|
+
# by clock, then by the order its documents are listed, then by line.
|
|
204
|
+
#
|
|
205
|
+
# @param reading [Agentilda::Ledger::Reading]
|
|
206
|
+
# @param agent [String]
|
|
207
|
+
# @return [Agentilda::Ledger::Entry, nil]
|
|
208
|
+
def last_for(reading, agent)
|
|
209
|
+
files = reading.entries.map(&:file).uniq
|
|
210
|
+
reading.entries.select { |e| e.agent == agent }
|
|
211
|
+
.max_by { |e| [e.at, files.index(e.file), e.line] }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The `next:` that belongs to an entry: same file, a later line, and no
|
|
215
|
+
# other entry between the two. A handoff written under somebody else's
|
|
216
|
+
# entry is not this agent's handoff.
|
|
217
|
+
#
|
|
218
|
+
# @param reading [Agentilda::Ledger::Reading]
|
|
219
|
+
# @param entry [Agentilda::Ledger::Entry]
|
|
220
|
+
# @return [Agentilda::Ledger::Handoff, nil]
|
|
221
|
+
def handoff_after(reading, entry)
|
|
222
|
+
candidates = reading.handoffs.select { |h| h.file == entry.file && h.line > entry.line }
|
|
223
|
+
handoff = candidates.min_by(&:line) or return nil
|
|
224
|
+
between = reading.entries.any? { |e| e.file == entry.file && e.line > entry.line && e.line < handoff.line }
|
|
225
|
+
between ? nil : handoff
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# @param text [String]
|
|
229
|
+
# @return [String] the document without its ledger blocks
|
|
230
|
+
def stripped(text) = text.to_s.gsub(BLOCK, "")
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
data/lib/agentilda/linear/api.rb
CHANGED
|
@@ -32,8 +32,9 @@ module Agentilda
|
|
|
32
32
|
@endpoint = endpoint
|
|
33
33
|
return if transport || (token && !token.empty?)
|
|
34
34
|
|
|
35
|
-
raise Error,
|
|
36
|
-
|
|
35
|
+
raise Error,
|
|
36
|
+
"no Linear token. Set #{TOKEN_VARIABLE}, or use the MCP transport:\n " \
|
|
37
|
+
"agentilda linear import <TEAM> -p <PROJECT> --format json"
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
# The team, its workflow states and its labels, in one round trip.
|
|
@@ -45,8 +46,8 @@ module Agentilda
|
|
|
45
46
|
node = query(TEAM, key: key.to_s.upcase).dig("teams", "nodes")&.first
|
|
46
47
|
raise Error, "no Linear team has the key #{key.to_s.upcase}" unless node
|
|
47
48
|
|
|
48
|
-
{id: node["id"], name: node["name"], key: node["key"],
|
|
49
|
-
states: node.dig("states", "nodes").to_a, labels: node.dig("labels", "nodes").to_a}
|
|
49
|
+
{ id: node["id"], name: node["name"], key: node["key"],
|
|
50
|
+
states: node.dig("states", "nodes").to_a, labels: node.dig("labels", "nodes").to_a }
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
# @param team_id [String]
|
|
@@ -75,7 +76,7 @@ module Agentilda
|
|
|
75
76
|
# @param team_id [String]
|
|
76
77
|
# @return [Hash] `{id:, name:}`
|
|
77
78
|
def create_label(name, team_id)
|
|
78
|
-
unwrap(query(LABEL_CREATE, input: {name:, teamId: team_id}), "issueLabelCreate", "issueLabel")
|
|
79
|
+
unwrap(query(LABEL_CREATE, input: { name:, teamId: team_id }), "issueLabelCreate", "issueLabel")
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
# @param issue_id [String]
|
|
@@ -83,7 +84,7 @@ module Agentilda
|
|
|
83
84
|
# @param title [String]
|
|
84
85
|
# @return [void]
|
|
85
86
|
def link(issue_id:, url:, title:)
|
|
86
|
-
query(ATTACHMENT_CREATE, input: {issueId: issue_id, url:, title:})
|
|
87
|
+
query(ATTACHMENT_CREATE, input: { issueId: issue_id, url:, title: })
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
# @param query [String] a GraphQL document
|
|
@@ -139,8 +140,11 @@ module Agentilda
|
|
|
139
140
|
request["Authorization"] = token
|
|
140
141
|
request.body = JSON.generate(query: document, variables:)
|
|
141
142
|
|
|
142
|
-
response = Net::HTTP.start(uri.hostname,
|
|
143
|
-
|
|
143
|
+
response = Net::HTTP.start(uri.hostname,
|
|
144
|
+
uri.port,
|
|
145
|
+
use_ssl: uri.scheme == "https",
|
|
146
|
+
open_timeout: 10,
|
|
147
|
+
read_timeout: 30) { |http| http.request(request) }
|
|
144
148
|
|
|
145
149
|
parse(response)
|
|
146
150
|
rescue JSON::ParserError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
@@ -153,8 +157,8 @@ module Agentilda
|
|
|
153
157
|
return JSON.parse(response.body.to_s) if response.is_a?(Net::HTTPSuccess)
|
|
154
158
|
|
|
155
159
|
hint = if ["400", "401"].include?(response.code)
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
"\n\nCheck #{TOKEN_VARIABLE}. A personal API key is sent verbatim, without a `Bearer` prefix."
|
|
161
|
+
end
|
|
158
162
|
raise Error, "Linear returned HTTP #{response.code}#{hint}"
|
|
159
163
|
end
|
|
160
164
|
|
|
@@ -75,7 +75,7 @@ module Agentilda
|
|
|
75
75
|
MINIMUM_WORDS = 3
|
|
76
76
|
|
|
77
77
|
NOISE = %w[the and for with from into that this add adds added fix fixes
|
|
78
|
-
|
|
78
|
+
update updates use uses spec plan pull request pr tax app web api].freeze
|
|
79
79
|
|
|
80
80
|
# @param tree [Agentilda::Tree]
|
|
81
81
|
def initialize(tree:)
|
|
@@ -113,9 +113,9 @@ module Agentilda
|
|
|
113
113
|
# @return [Array<String>]
|
|
114
114
|
def opening_words(body)
|
|
115
115
|
text = body.to_s
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
.gsub(/<!--.*?-->/m, "")
|
|
117
|
+
.gsub(/```.*?```/m, "")
|
|
118
|
+
.gsub(/^\s*[-*|#>]+/, " ")
|
|
119
119
|
words(text.split(/\s+/).first(OPENING_WORDS * 3).join(" ")).first(OPENING_WORDS)
|
|
120
120
|
end
|
|
121
121
|
|
|
@@ -161,7 +161,7 @@ module Agentilda
|
|
|
161
161
|
# @return [Array<String>]
|
|
162
162
|
def words(text)
|
|
163
163
|
text.to_s.downcase.gsub(/[^a-z0-9]+/, " ").split
|
|
164
|
-
|
|
164
|
+
.reject { |w| w.length < 4 || NOISE.include?(w) }.uniq
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
# How much of a folder's name a pull request title actually said.
|
|
@@ -41,7 +41,7 @@ module Agentilda
|
|
|
41
41
|
|
|
42
42
|
# @return [Hash] for `--format json`
|
|
43
43
|
def to_h_json
|
|
44
|
-
{kind:, op:, plan: ordinal, unit:, identifier:, title:, digest:, args:}.compact
|
|
44
|
+
{ kind:, op:, plan: ordinal, unit:, identifier:, title:, digest:, args: }.compact
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
@@ -103,7 +103,7 @@ module Agentilda
|
|
|
103
103
|
# @return [Hash{Agentilda::Status => Array<String>}] state => ordinals
|
|
104
104
|
def unplaced
|
|
105
105
|
@unplaced ||= (chosen - subjects).group_by(&:status)
|
|
106
|
-
|
|
106
|
+
.transform_values { |group| group.map { |s| s.feature.ordinal.to_s } }
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
private
|
|
@@ -152,8 +152,10 @@ module Agentilda
|
|
|
152
152
|
# @param pull [Agentilda::PullRequest]
|
|
153
153
|
# @return [Agentilda::Linear::Unit]
|
|
154
154
|
def adopted_unit(pull)
|
|
155
|
-
Unit.new(key: "##{pull.number}",
|
|
156
|
-
|
|
155
|
+
Unit.new(key: "##{pull.number}",
|
|
156
|
+
title: Units.clean_title(pull.title),
|
|
157
|
+
body: "",
|
|
158
|
+
pull_requests: [pull])
|
|
157
159
|
end
|
|
158
160
|
|
|
159
161
|
# @param subject [Agentilda::Subject]
|
|
@@ -175,15 +177,22 @@ module Agentilda
|
|
|
175
177
|
recorded = record_for(subject).by_unit[Issues::PARENT]
|
|
176
178
|
title = plan_title(subject)
|
|
177
179
|
|
|
178
|
-
args = {team:, project: project_name, title:, description: plan_description(subject),
|
|
179
|
-
state: placement.name, labels: placement.labels}
|
|
180
|
+
args = { team:, project: project_name, title:, description: plan_description(subject),
|
|
181
|
+
state: placement.name, labels: placement.labels }
|
|
180
182
|
digest = Issues.digest(args)
|
|
181
183
|
|
|
182
184
|
op, reason = decide(recorded&.identifier, recorded&.digest, digest)
|
|
183
185
|
args = args.merge(id: recorded.identifier).except(:team, :project) if op == :update && recorded
|
|
184
186
|
|
|
185
|
-
Action.new(kind: :issue,
|
|
186
|
-
|
|
187
|
+
Action.new(kind: :issue,
|
|
188
|
+
op:,
|
|
189
|
+
ordinal: subject.feature.ordinal.to_s,
|
|
190
|
+
unit: Issues::PARENT,
|
|
191
|
+
identifier: recorded&.identifier,
|
|
192
|
+
title:,
|
|
193
|
+
digest:,
|
|
194
|
+
args:,
|
|
195
|
+
reason:)
|
|
187
196
|
end
|
|
188
197
|
|
|
189
198
|
# @param subject [Agentilda::Subject]
|
|
@@ -194,17 +203,24 @@ module Agentilda
|
|
|
194
203
|
recorded = record_for(subject).by_unit[unit.key]
|
|
195
204
|
parent = record_for(subject).by_unit[Issues::PARENT]
|
|
196
205
|
|
|
197
|
-
args = {team:, project: project_name, title: unit.title,
|
|
206
|
+
args = { team:, project: project_name, title: unit.title,
|
|
198
207
|
description: unit_description(subject, unit),
|
|
199
|
-
state: placement.name, labels: placement.labels, links: links_for(unit)}
|
|
208
|
+
state: placement.name, labels: placement.labels, links: links_for(unit) }
|
|
200
209
|
args = args.merge(parentId: parent.identifier) if parent
|
|
201
210
|
digest = Issues.digest(args.except(:parentId))
|
|
202
211
|
|
|
203
212
|
op, reason = decide(recorded&.identifier, recorded&.digest, digest)
|
|
204
213
|
args = args.merge(id: recorded.identifier).except(:team, :project) if op == :update && recorded
|
|
205
214
|
|
|
206
|
-
Action.new(kind: :subissue,
|
|
207
|
-
|
|
215
|
+
Action.new(kind: :subissue,
|
|
216
|
+
op:,
|
|
217
|
+
ordinal: subject.feature.ordinal.to_s,
|
|
218
|
+
unit: unit.key,
|
|
219
|
+
identifier: recorded&.identifier,
|
|
220
|
+
title: unit.title,
|
|
221
|
+
digest:,
|
|
222
|
+
args:,
|
|
223
|
+
reason:)
|
|
208
224
|
end
|
|
209
225
|
|
|
210
226
|
# The three-way decision, in one place so a plan and a unit cannot
|
|
@@ -271,7 +287,7 @@ module Agentilda
|
|
|
271
287
|
# @param unit [Agentilda::Linear::Unit]
|
|
272
288
|
# @return [Array<Hash>]
|
|
273
289
|
def links_for(unit)
|
|
274
|
-
unit.pull_requests.select(&:url).map { |pr| {url: pr.url, title: pr.label} }
|
|
290
|
+
unit.pull_requests.select(&:url).map { |pr| { url: pr.url, title: pr.label } }
|
|
275
291
|
end
|
|
276
292
|
|
|
277
293
|
# A plan section can run to several hundred lines of design notes and
|
|
@@ -291,7 +307,7 @@ module Agentilda
|
|
|
291
307
|
# @return [String]
|
|
292
308
|
def provenance
|
|
293
309
|
"_Imported by `agentilda linear import`. The plan folder is the source of truth; " \
|
|
294
|
-
|
|
310
|
+
"edits made here do not travel back._"
|
|
295
311
|
end
|
|
296
312
|
end
|
|
297
313
|
end
|
|
@@ -164,9 +164,12 @@ module Agentilda
|
|
|
164
164
|
identifier = (link ? link[1] : cell["issue"]).gsub(/[*_`]/, "").strip
|
|
165
165
|
return nil if identifier.empty?
|
|
166
166
|
|
|
167
|
-
Issue.new(unit: cell["unit"].strip,
|
|
167
|
+
Issue.new(unit: cell["unit"].strip,
|
|
168
|
+
identifier:,
|
|
169
|
+
url: link && link[2],
|
|
168
170
|
title: cell["title"].gsub(/\\([|\\])/, '\1').strip,
|
|
169
|
-
state: cell["state"].strip,
|
|
171
|
+
state: cell["state"].strip,
|
|
172
|
+
digest: cell["synced"].gsub(/[`\s]/, ""))
|
|
170
173
|
end
|
|
171
174
|
|
|
172
175
|
# @return [Hash, nil]
|
|
@@ -177,7 +180,7 @@ module Agentilda
|
|
|
177
180
|
link = line.match(/project\s+\[([^\]]+)\]\((\S+?)\)/i)
|
|
178
181
|
return nil unless link
|
|
179
182
|
|
|
180
|
-
{name: link[1].strip.gsub(/\\([|\\])/, '\1'), url: link[2]}
|
|
183
|
+
{ name: link[1].strip.gsub(/\\([|\\])/, '\1'), url: link[2] }
|
|
181
184
|
end
|
|
182
185
|
end
|
|
183
186
|
end
|
|
@@ -39,24 +39,25 @@ module Agentilda
|
|
|
39
39
|
# nothing here changes, and its plans quietly import as Backlog with no
|
|
40
40
|
# indication anything was missed.
|
|
41
41
|
PLACEMENTS = {
|
|
42
|
-
new:
|
|
43
|
-
researched:
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
new: Placement.new(type: "backlog", name: "Backlog", labels: []),
|
|
43
|
+
researched: Placement.new(type: "backlog", name: "Backlog", labels: %w[researched]),
|
|
44
|
+
ready_for_planning: Placement.new(type: "backlog", name: "Backlog", labels: %w[spec-ready]),
|
|
45
|
+
planned: Placement.new(type: "unstarted", name: "Todo", labels: []),
|
|
46
|
+
building: Placement.new(type: "started", name: "In Progress", labels: []),
|
|
46
47
|
# Both halves of building are one column on a board. A reader there
|
|
47
48
|
# wants to know work is under way; which half is under way is this
|
|
48
49
|
# tool's business, and the label carries it for anyone who does care.
|
|
49
|
-
building_ui:
|
|
50
|
-
ready_for_review:
|
|
51
|
-
in_review:
|
|
52
|
-
rejected:
|
|
53
|
-
approved:
|
|
54
|
-
deployed:
|
|
55
|
-
blocked:
|
|
56
|
-
product_blocked:
|
|
57
|
-
deferred:
|
|
58
|
-
retroactive:
|
|
59
|
-
discarded:
|
|
50
|
+
building_ui: Placement.new(type: "started", name: "In Progress", labels: %w[frontend]),
|
|
51
|
+
ready_for_review: Placement.new(type: "started", name: "In Review", labels: []),
|
|
52
|
+
in_review: Placement.new(type: "started", name: "In Review", labels: []),
|
|
53
|
+
rejected: Placement.new(type: "started", name: "In Review", labels: %w[changes-requested]),
|
|
54
|
+
approved: Placement.new(type: "completed", name: "Done", labels: []),
|
|
55
|
+
deployed: Placement.new(type: "completed", name: "Done", labels: %w[deployed]),
|
|
56
|
+
blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked]),
|
|
57
|
+
product_blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked-on-product]),
|
|
58
|
+
deferred: Placement.new(type: "backlog", name: "Backlog", labels: %w[deferred]),
|
|
59
|
+
retroactive: Placement.new(type: "completed", name: "Done", labels: %w[retroactive]),
|
|
60
|
+
discarded: Placement.new(type: "canceled", name: "Canceled", labels: [])
|
|
60
61
|
}.freeze
|
|
61
62
|
|
|
62
63
|
# States deliberately left out, and why.
|
|
@@ -71,8 +72,8 @@ module Agentilda
|
|
|
71
72
|
# A plan in one of these states is reported and skipped. To import them,
|
|
72
73
|
# decide where they belong and move the entry into {PLACEMENTS}.
|
|
73
74
|
UNPLACED = {
|
|
74
|
-
shit:
|
|
75
|
-
|
|
75
|
+
shit: "the plan survives and its pull requests do not; whether that is work still to do " \
|
|
76
|
+
"or work abandoned depends on what the team does next",
|
|
76
77
|
rolled_back: "it shipped and was pulled; whether that reopens this work or opens new work " \
|
|
77
78
|
"depends on what broke"
|
|
78
79
|
}.freeze
|
|
@@ -78,16 +78,18 @@ module Agentilda
|
|
|
78
78
|
# @return [Agentilda::Linear::Result]
|
|
79
79
|
def apply(action, subject, parent)
|
|
80
80
|
if parent && !parent.ok?
|
|
81
|
-
return Result.new(action:,
|
|
81
|
+
return Result.new(action:,
|
|
82
|
+
identifier: action.identifier,
|
|
83
|
+
url: nil,
|
|
82
84
|
error: "the issue for its plan could not be created")
|
|
83
85
|
end
|
|
84
86
|
|
|
85
87
|
attempt(action) do
|
|
86
88
|
node = case action.op
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
when :skip then recorded(action, subject)
|
|
90
|
+
when :update then attach(api.update_issue(action.args[:id], input(action, parent)), action)
|
|
91
|
+
else attach(api.create_issue(input(action, parent).merge(teamId: team[:id], projectId: project_id)), action)
|
|
92
|
+
end
|
|
91
93
|
|
|
92
94
|
[node["identifier"], node["url"]]
|
|
93
95
|
end
|
|
@@ -100,8 +102,8 @@ module Agentilda
|
|
|
100
102
|
# @param parent [Agentilda::Linear::Result, nil]
|
|
101
103
|
# @return [Hash]
|
|
102
104
|
def input(action, parent)
|
|
103
|
-
base = {title: action.args[:title], description: action.args[:description],
|
|
104
|
-
stateId: state_id(action.args[:state]), labelIds: label_ids(action.args[:labels])}.compact
|
|
105
|
+
base = { title: action.args[:title], description: action.args[:description],
|
|
106
|
+
stateId: state_id(action.args[:state]), labelIds: label_ids(action.args[:labels]) }.compact
|
|
105
107
|
return base unless parent&.identifier
|
|
106
108
|
|
|
107
109
|
base.merge(parentId: parent.identifier)
|
|
@@ -125,7 +127,7 @@ module Agentilda
|
|
|
125
127
|
# @return [Hash]
|
|
126
128
|
def recorded(action, subject)
|
|
127
129
|
was = Issues.new(dir: subject.feature.path).by_unit[action.unit]
|
|
128
|
-
{"identifier" => was&.identifier || action.identifier, "url" => was&.url}
|
|
130
|
+
{ "identifier" => was&.identifier || action.identifier, "url" => was&.url }
|
|
129
131
|
end
|
|
130
132
|
|
|
131
133
|
# A team names its own workflow states, so the name we would prefer is a
|
|
@@ -177,13 +179,18 @@ module Agentilda
|
|
|
177
179
|
# @return [void]
|
|
178
180
|
def record(subject, results)
|
|
179
181
|
issues = results.select { |r| r.ok? && r.identifier }.map { |r|
|
|
180
|
-
Issue.new(unit: r.action.unit,
|
|
181
|
-
|
|
182
|
+
Issue.new(unit: r.action.unit,
|
|
183
|
+
identifier: r.identifier,
|
|
184
|
+
url: r.url,
|
|
185
|
+
title: r.action.title,
|
|
186
|
+
state: r.action.args[:state] || "",
|
|
187
|
+
digest: r.action.digest)
|
|
182
188
|
}
|
|
183
189
|
return if issues.empty?
|
|
184
190
|
|
|
185
|
-
Issues.new(dir: subject.feature.path).write(team: import.team,
|
|
186
|
-
|
|
191
|
+
Issues.new(dir: subject.feature.path).write(team: import.team,
|
|
192
|
+
issues:,
|
|
193
|
+
project: { name: import.project_name, url: import.project["url"] })
|
|
187
194
|
end
|
|
188
195
|
end
|
|
189
196
|
end
|
|
@@ -22,7 +22,7 @@ module Agentilda
|
|
|
22
22
|
# wrong plan is worse than one nobody adopted at all.
|
|
23
23
|
Match = Data.define(:project, :subject, :rule) do
|
|
24
24
|
# @return [String]
|
|
25
|
-
def why = {ordinal: "its name carries the plan number", slug: "its name is the plan's slug"}.fetch(rule)
|
|
25
|
+
def why = { ordinal: "its name carries the plan number", slug: "its name is the plan's slug" }.fetch(rule)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# @param tree [Agentilda::Tree]
|
|
@@ -102,7 +102,8 @@ module Agentilda
|
|
|
102
102
|
}
|
|
103
103
|
return found if found
|
|
104
104
|
|
|
105
|
-
raise Error,
|
|
105
|
+
raise Error,
|
|
106
|
+
"no project matches #{reference.inspect}. This team has:\n" +
|
|
106
107
|
projects.map { |p| " #{p["name"]}\n #{p["url"]}" }.join("\n")
|
|
107
108
|
end
|
|
108
109
|
|
|
@@ -93,10 +93,10 @@ module Agentilda
|
|
|
93
93
|
# @return [String]
|
|
94
94
|
def self.clean_title(title)
|
|
95
95
|
title.to_s
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
.sub(/\A\[[^\]]*\]\s*/, "")
|
|
97
|
+
.sub(/\A(?:spec\s*)?\d{1,3}(?:\.\d{2})?\s*[—–:.-]?\s*/i, "")
|
|
98
|
+
.sub(/\APR[\s_-]?\d+(?:\.\d+)?\s*[—–:.-]?\s*/i, "")
|
|
99
|
+
.strip
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
private
|
|
@@ -260,14 +260,14 @@ module Agentilda
|
|
|
260
260
|
# @return [String]
|
|
261
261
|
def clean(title)
|
|
262
262
|
title.to_s
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
263
|
+
.sub(/\A\([^)]*\)\s*/, "") # "(010a) — Per-person taxes" — a nickname, not a title
|
|
264
|
+
.sub(/\A[—–:.-]+\s*/, "")
|
|
265
|
+
.sub(GLYPH_NOTE, "")
|
|
266
|
+
.sub(TRAILING_GLYPH, "")
|
|
267
|
+
.gsub(/[*`]/, "") # before OWN_NUMBER: the number is often inside backticks
|
|
268
|
+
.sub(/\A_+/, "").sub(/_+\z/, "") # emphasis, but never `signed_off`
|
|
269
|
+
.sub(OWN_NUMBER, "")
|
|
270
|
+
.strip
|
|
271
271
|
end
|
|
272
272
|
end
|
|
273
273
|
end
|
data/lib/agentilda/linear.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "agentilda/linear/mapping"
|
|
4
|
+
|
|
3
5
|
module Agentilda
|
|
4
6
|
# `.plans` as Linear projects and issues.
|
|
5
7
|
#
|
|
@@ -31,12 +33,9 @@ module Agentilda
|
|
|
31
33
|
key = prefix.to_s.strip.upcase
|
|
32
34
|
return key if key.match?(KEY)
|
|
33
35
|
|
|
34
|
-
raise Error,
|
|
35
|
-
|
|
36
|
+
raise Error,
|
|
37
|
+
"#{prefix.inspect} is not a Linear team key. A key is 2–10 characters, " \
|
|
38
|
+
"letters and digits, as in TAX or ENG — the part before the dash in TAX-41."
|
|
36
39
|
end
|
|
37
40
|
end
|
|
38
41
|
end
|
|
39
|
-
|
|
40
|
-
%w[mapping fuzzy unit issue survey attribution import api push].each do |part|
|
|
41
|
-
require File.join(__dir__, "linear", "#{part}.rb")
|
|
42
|
-
end
|