agentilda 1.0.3

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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +26 -0
  3. data/Gemfile.lock +261 -0
  4. data/agentilda.gemspec +57 -0
  5. data/agents/hansolo-reviewer.md +29 -0
  6. data/agents/lando-broker.md +74 -0
  7. data/agents/leah-researcher.md +80 -0
  8. data/agents/luke-backend.md +81 -0
  9. data/agents/palpatine-planner.md +40 -0
  10. data/agents/rey-frontend.md +106 -0
  11. data/agents/yoda-writer.md +54 -0
  12. data/bin/create-plan-folder +125 -0
  13. data/bin/plan-number +164 -0
  14. data/exe/agentilda +111 -0
  15. data/exe/tilda +1 -0
  16. data/lib/agentilda/adoption.rb +192 -0
  17. data/lib/agentilda/agent.rb +136 -0
  18. data/lib/agentilda/brief.rb +234 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
  20. data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
  21. data/lib/agentilda/cli/base.rb +88 -0
  22. data/lib/agentilda/cli/create/create.rb +309 -0
  23. data/lib/agentilda/cli/docs/docs.rb +30 -0
  24. data/lib/agentilda/cli/index/index.rb +38 -0
  25. data/lib/agentilda/cli/linear/linear.rb +35 -0
  26. data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
  27. data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
  28. data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
  31. data/lib/agentilda/cli/run/run.rb +289 -0
  32. data/lib/agentilda/cli/states/states.rb +15 -0
  33. data/lib/agentilda/cli/unblock/unblock.rb +227 -0
  34. data/lib/agentilda/cli/version/version.rb +13 -0
  35. data/lib/agentilda/cli.rb +74 -0
  36. data/lib/agentilda/config.rb +44 -0
  37. data/lib/agentilda/control.rb +115 -0
  38. data/lib/agentilda/creator.rb +120 -0
  39. data/lib/agentilda/dev_work.rb +54 -0
  40. data/lib/agentilda/diagram.rb +144 -0
  41. data/lib/agentilda/documentation.rb +429 -0
  42. data/lib/agentilda/executor.rb +539 -0
  43. data/lib/agentilda/feature.rb +253 -0
  44. data/lib/agentilda/frontmatter.rb +36 -0
  45. data/lib/agentilda/github.rb +160 -0
  46. data/lib/agentilda/index.rb +206 -0
  47. data/lib/agentilda/keyboard.rb +88 -0
  48. data/lib/agentilda/linear/api.rb +220 -0
  49. data/lib/agentilda/linear/attribution.rb +185 -0
  50. data/lib/agentilda/linear/fuzzy.rb +68 -0
  51. data/lib/agentilda/linear/import.rb +298 -0
  52. data/lib/agentilda/linear/issue.rb +184 -0
  53. data/lib/agentilda/linear/mapping.rb +115 -0
  54. data/lib/agentilda/linear/push.rb +190 -0
  55. data/lib/agentilda/linear/survey.rb +173 -0
  56. data/lib/agentilda/linear/unit.rb +274 -0
  57. data/lib/agentilda/linear.rb +42 -0
  58. data/lib/agentilda/markdown.rb +56 -0
  59. data/lib/agentilda/ordinal.rb +90 -0
  60. data/lib/agentilda/progress_log.rb +122 -0
  61. data/lib/agentilda/publisher.rb +172 -0
  62. data/lib/agentilda/pull_request.rb +213 -0
  63. data/lib/agentilda/reporter.rb +175 -0
  64. data/lib/agentilda/resync.rb +358 -0
  65. data/lib/agentilda/roster.rb +110 -0
  66. data/lib/agentilda/runner.rb +456 -0
  67. data/lib/agentilda/state_machine.rb +355 -0
  68. data/lib/agentilda/status.rb +280 -0
  69. data/lib/agentilda/tally.rb +169 -0
  70. data/lib/agentilda/transcript.rb +435 -0
  71. data/lib/agentilda/tree.rb +77 -0
  72. data/lib/agentilda/ui.rb +681 -0
  73. data/lib/agentilda/unblocker.rb +207 -0
  74. data/lib/agentilda/version.rb +10 -0
  75. data/lib/agentilda/viewer.rb +60 -0
  76. data/lib/agentilda/worktree.rb +211 -0
  77. data/lib/agentilda.rb +155 -0
  78. data/lib/dry/cli/banner.rb +293 -0
  79. metadata +349 -0
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Agentilda
6
+ module Linear
7
+ # One thing that would happen in Linear.
8
+ #
9
+ # `args` is deliberately shaped as the Linear MCP server's `save_issue`
10
+ # arguments, because that server addresses everything by
11
+ # the names a human already knows — a team by its key, a project by its
12
+ # name, a workflow state by its name, an issue by its identifier. Emitting
13
+ # exactly that shape means the JSON this produces can drive either
14
+ # transport unchanged: {Push} turns it into GraphQL, and the skill hands
15
+ # it to the MCP tools as-is. One contract, so the two cannot drift.
16
+ #
17
+ # @!attribute [r] kind
18
+ # @return [Symbol] `:issue` for a plan, `:subissue` for a unit of one
19
+ # @!attribute [r] op
20
+ # @return [Symbol] `:create`, `:update` or `:skip`
21
+ # @!attribute [r] ordinal
22
+ # @return [String] the plan this belongs to, e.g. "003.00"
23
+ # @!attribute [r] unit
24
+ # @return [String, nil] the {Unit#key}, nil for a plan's own issue
25
+ # @!attribute [r] identifier
26
+ # @return [String, nil] what Linear already calls it, when it exists
27
+ # @!attribute [r] title
28
+ # @return [String] for the human reading the dry run
29
+ # @!attribute [r] digest
30
+ # @return [String] fingerprint of `args`, recorded after a push
31
+ # @!attribute [r] args
32
+ # @return [Hash] the MCP argument shape
33
+ # @!attribute [r] reason
34
+ # @return [String] why this operation and not another
35
+ Action = Data.define(:kind, :op, :ordinal, :unit, :identifier, :title, :digest, :args, :reason) do
36
+ # @return [Boolean] whether this would change anything
37
+ def pending? = op != :skip
38
+
39
+ # @return [Boolean] whether this issue hangs off another
40
+ def child? = kind == :subissue
41
+
42
+ # @return [Hash] for `--format json`
43
+ def to_h_json
44
+ {kind:, op:, plan: ordinal, unit:, identifier:, title:, digest:, args:}.compact
45
+ end
46
+ end
47
+
48
+ # What a plan folder would become in Linear, and what has already become
49
+ # of it. Reads the filesystem; touches nothing else.
50
+ #
51
+ # One folder is one issue, and the units inside its `plan.md` are that
52
+ # issue's children. Projects are never created: a team's project list is
53
+ # something a human curated, and every plan is filed under one the caller
54
+ # named on the command line.
55
+ #
56
+ # Keeping the whole decision offline is what makes `--commit` honest. The
57
+ # dry run is not an approximation of what a push would do — it is the same
58
+ # object the push consumes, so what gets printed and what gets sent cannot
59
+ # disagree. Everything that needs the network — the project, the repository
60
+ # pull request list — is handed in.
61
+ class Import
62
+ # @param tree [Agentilda::Tree]
63
+ # @param team [String] the team key, e.g. "TAX"
64
+ # @param project [Hash] the Linear project, `{"id", "name", "url"}`
65
+ # @param adopted [Hash{String => Array<Agentilda::PullRequest>}]
66
+ # pull requests {Attribution} placed, by folder name
67
+ # @param since [String, nil] skip plans numbered below this
68
+ # @param statuses [Array<Symbol>, nil] only these states
69
+ # @param force [Boolean] update everything, matching digest or not
70
+ def initialize(tree:, team:, project:, adopted: {}, since: nil, statuses: nil, force: false)
71
+ @tree = tree
72
+ @team = team.to_s.strip.upcase
73
+ @project = project
74
+ @adopted = adopted
75
+ @since = since && Ordinal.parse(since)
76
+ @statuses = statuses
77
+ @force = force
78
+ end
79
+
80
+ # @return [String] the team key
81
+ attr_reader :team
82
+
83
+ # @return [Hash] the project everything is filed under
84
+ attr_reader :project
85
+
86
+ # @return [String]
87
+ def project_name = project["name"].to_s
88
+
89
+ # @return [Array<Agentilda::Linear::Action>] each plan's issue,
90
+ # followed by that issue's children
91
+ def actions = @actions ||= subjects.flat_map { |s| actions_for(s) }
92
+
93
+ # @return [Array<Agentilda::Linear::Action>]
94
+ def pending = actions.select(&:pending?)
95
+
96
+ # @return [String] the whole import, for a pipe
97
+ def to_json(*_args)
98
+ JSON.pretty_generate(team:, project: project_name, actions: actions.map(&:to_h_json))
99
+ end
100
+
101
+ # Plans in a state nobody has decided how to file, and why not.
102
+ #
103
+ # @return [Hash{Agentilda::Status => Array<String>}] state => ordinals
104
+ def unplaced
105
+ @unplaced ||= (chosen - subjects).group_by(&:status)
106
+ .transform_values { |group| group.map { |s| s.feature.ordinal.to_s } }
107
+ end
108
+
109
+ private
110
+
111
+ # @return [Agentilda::Tree]
112
+ attr_reader :tree
113
+
114
+ # @return [Hash]
115
+ attr_reader :adopted
116
+
117
+ # @return [Agentilda::Ordinal, nil]
118
+ attr_reader :since
119
+
120
+ # @return [Array<Symbol>, nil]
121
+ attr_reader :statuses
122
+
123
+ # @return [Boolean]
124
+ attr_reader :force
125
+
126
+ # @return [Array<Agentilda::Subject>]
127
+ def subjects = @subjects ||= chosen.select { |s| Agentilda::Linear.placement(s.status) }
128
+
129
+ # @return [Array<Agentilda::Subject>] before the placement question
130
+ def chosen
131
+ @chosen ||= tree.subjects.select { |s|
132
+ (since.nil? || s.feature.ordinal >= since) &&
133
+ (statuses.nil? || statuses.include?(s.status.key))
134
+ }
135
+ end
136
+
137
+ # The units a plan's issue will have children for: the ones its
138
+ # `plan.md` declares, plus any pull request {Attribution} placed here
139
+ # that none of them already claims.
140
+ #
141
+ # @param subject [Agentilda::Subject]
142
+ # @return [Array<Agentilda::Linear::Unit>]
143
+ def units_for(subject)
144
+ (@units ||= {})[subject.feature.path] ||= begin
145
+ declared = Units.new(subject:).all
146
+ claimed = declared.flat_map(&:pull_requests).map(&:number)
147
+ extra = adopted.fetch(subject.feature.dirname, []).reject { |pr| claimed.include?(pr.number) }
148
+ declared + extra.map { |pr| adopted_unit(pr) }
149
+ end
150
+ end
151
+
152
+ # @param pull [Agentilda::PullRequest]
153
+ # @return [Agentilda::Linear::Unit]
154
+ def adopted_unit(pull)
155
+ Unit.new(key: "##{pull.number}", title: Units.clean_title(pull.title),
156
+ body: "", pull_requests: [pull])
157
+ end
158
+
159
+ # @param subject [Agentilda::Subject]
160
+ # @return [Agentilda::Linear::Issues]
161
+ def record_for(subject) = (@records ||= {})[subject.feature.path] ||= Issues.new(dir: subject.feature.path)
162
+
163
+ # @param subject [Agentilda::Subject]
164
+ # @return [Array<Agentilda::Linear::Action>]
165
+ def actions_for(subject)
166
+ [plan_action(subject)] + units_for(subject).map { |unit| unit_action(subject, unit) }
167
+ end
168
+
169
+ # The issue that stands for the whole plan folder.
170
+ #
171
+ # @param subject [Agentilda::Subject]
172
+ # @return [Agentilda::Linear::Action]
173
+ def plan_action(subject)
174
+ placement = Agentilda::Linear.placement(subject.status)
175
+ recorded = record_for(subject).by_unit[Issues::PARENT]
176
+ title = plan_title(subject)
177
+
178
+ args = {team:, project: project_name, title:, description: plan_description(subject),
179
+ state: placement.name, labels: placement.labels}
180
+ digest = Issues.digest(args)
181
+
182
+ op, reason = decide(recorded&.identifier, recorded&.digest, digest)
183
+ args = args.merge(id: recorded.identifier).except(:team, :project) if op == :update && recorded
184
+
185
+ Action.new(kind: :issue, op:, ordinal: subject.feature.ordinal.to_s, unit: Issues::PARENT,
186
+ identifier: recorded&.identifier, title:, digest:, args:, reason:)
187
+ end
188
+
189
+ # @param subject [Agentilda::Subject]
190
+ # @param unit [Agentilda::Linear::Unit]
191
+ # @return [Agentilda::Linear::Action]
192
+ def unit_action(subject, unit)
193
+ placement = Agentilda::Linear.placement_for(subject.status, unit.pull_requests)
194
+ recorded = record_for(subject).by_unit[unit.key]
195
+ parent = record_for(subject).by_unit[Issues::PARENT]
196
+
197
+ args = {team:, project: project_name, title: unit.title,
198
+ description: unit_description(subject, unit),
199
+ state: placement.name, labels: placement.labels, links: links_for(unit)}
200
+ args = args.merge(parentId: parent.identifier) if parent
201
+ digest = Issues.digest(args.except(:parentId))
202
+
203
+ op, reason = decide(recorded&.identifier, recorded&.digest, digest)
204
+ args = args.merge(id: recorded.identifier).except(:team, :project) if op == :update && recorded
205
+
206
+ Action.new(kind: :subissue, op:, ordinal: subject.feature.ordinal.to_s, unit: unit.key,
207
+ identifier: recorded&.identifier, title: unit.title, digest:, args:, reason:)
208
+ end
209
+
210
+ # The three-way decision, in one place so a plan and a unit cannot
211
+ # answer it differently.
212
+ #
213
+ # @param existing [String, nil] what Linear already calls it
214
+ # @param was [String, nil] the digest recorded at the last push
215
+ # @param now [String] the digest of what we would push
216
+ # @return [Array(Symbol, String)] the operation and its reason
217
+ def decide(existing, was, now)
218
+ return [:create, "not recorded in #{Issues::FILENAME}"] if existing.nil?
219
+ return [:update, "--force"] if force
220
+ return [:skip, "unchanged since the last import"] if was == now
221
+
222
+ [:update, "the plan has changed since #{existing} was pushed"]
223
+ end
224
+
225
+ # @param subject [Agentilda::Subject]
226
+ # @return [String]
227
+ def plan_title(subject) = "[#{subject.feature.ordinal}] #{heading(subject)}"
228
+
229
+ # The specification's own H1 when it has one, because an agent writing
230
+ # `spec.md` gives it a real sentence — "Tenancy: users, households,
231
+ # memberships" — while the folder slug can only carry kebab-case.
232
+ #
233
+ # @param subject [Agentilda::Subject]
234
+ # @return [String]
235
+ def heading(subject)
236
+ line = subject.read("spec.md").to_s[/^[ \t]{0,3}#[ \t]+(.+)$/, 1]
237
+ cleaned = line.to_s.sub(/\A(?:spec(?:ification)?\s*)?\d+(?:\.\d+)?\s*[—–:.-]\s*/i, "").strip
238
+ cleaned.empty? ? subject.feature.title : cleaned
239
+ end
240
+
241
+ # @param subject [Agentilda::Subject]
242
+ # @return [String]
243
+ def plan_description(subject)
244
+ [
245
+ subject.goal.join("\n\n"),
246
+ "**State**: #{subject.status} — #{subject.status.note}",
247
+ "**Folder**: `#{Agentilda::PLANS_DIR}/#{subject.feature.dirname}`",
248
+ provenance
249
+ ].compact.reject(&:empty?).join("\n\n")
250
+ end
251
+
252
+ # @param subject [Agentilda::Subject]
253
+ # @param unit [Agentilda::Linear::Unit]
254
+ # @return [String]
255
+ def unit_description(subject, unit)
256
+ [
257
+ truncate(unit.body),
258
+ "**Plan**: `#{Agentilda::PLANS_DIR}/#{subject.feature.dirname}` · unit `#{unit.key}`",
259
+ provenance
260
+ ].compact.reject(&:empty?).join("\n\n")
261
+ end
262
+
263
+ # A pull request is attached to its issue, not listed in its body.
264
+ #
265
+ # Linear has a first-class relationship for this and renders it as what
266
+ # it is — an artifact implementing the work, carrying its own state. A
267
+ # markdown bullet is a claim about a relationship; an attachment is the
268
+ # relationship. Links are keyed on the URL, so re-sending one updates
269
+ # rather than duplicates.
270
+ #
271
+ # @param unit [Agentilda::Linear::Unit]
272
+ # @return [Array<Hash>]
273
+ def links_for(unit)
274
+ unit.pull_requests.select(&:url).map { |pr| {url: pr.url, title: pr.label} }
275
+ end
276
+
277
+ # A plan section can run to several hundred lines of design notes and
278
+ # DDL. Linear is where the work is tracked, not where it is specified,
279
+ # and the folder is one click away.
280
+ #
281
+ # @param body [String]
282
+ # @param limit [Integer]
283
+ # @return [String]
284
+ def truncate(body, limit = 6_000)
285
+ text = body.to_s.strip
286
+ return text if text.length <= limit
287
+
288
+ "#{text[0, limit].rpartition("\n").first.rstrip}\n\n_…truncated; the plan folder has the rest._"
289
+ end
290
+
291
+ # @return [String]
292
+ def provenance
293
+ "_Imported by `agentilda linear import`. The plan folder is the source of truth; " \
294
+ "edits made here do not travel back._"
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Agentilda
6
+ module Linear
7
+ # One row of a plan's `linear.md`: an issue this tool has already pushed.
8
+ #
9
+ # @!attribute [r] unit
10
+ # @return [String] the {Unit#key} it was made from
11
+ # @!attribute [r] identifier
12
+ # @return [String] "TAX-41" — Linear resolves this as an id
13
+ # @!attribute [r] url
14
+ # @return [String, nil]
15
+ # @!attribute [r] title
16
+ # @return [String] as pushed
17
+ # @!attribute [r] state
18
+ # @return [String] as pushed
19
+ # @!attribute [r] digest
20
+ # @return [String] fingerprint of the payload as pushed
21
+ Issue = Data.define(:unit, :identifier, :url, :title, :state, :digest)
22
+
23
+ # A plan's record of what it has put into Linear.
24
+ #
25
+ # Without it, a second `linear import` cannot tell an issue it created
26
+ # last week from one it has never created, and the only two outcomes are
27
+ # duplicating everything or searching Linear by title on every run. The
28
+ # file is the memory, and it is a committed artifact rather than a dotfile
29
+ # so the record travels with the plan and shows up in review.
30
+ #
31
+ # The `Synced` column is what makes an update cheap to decide: it is a
32
+ # digest of the payload last pushed, so a run can tell — with no network
33
+ # at all — whether anything about this issue has changed since. A digest
34
+ # that no longer matches is the whole trigger for an update.
35
+ class Issues
36
+ # The file this reads and writes.
37
+ FILENAME = "linear.md"
38
+
39
+ # The unit key given to the issue that stands for the whole folder. Its
40
+ # children carry their own unit keys; it carries this, so one table can
41
+ # hold a plan and its parts without a second shape to parse.
42
+ PARENT = "PLAN"
43
+
44
+ # Fingerprint of a payload, short enough to read in a table.
45
+ #
46
+ # @param payload [Object] anything with a stable #inspect ordering
47
+ # @return [String] eight hex characters
48
+ def self.digest(payload) = ::Digest::SHA256.hexdigest(JSON.generate(payload))[0, 8]
49
+
50
+ # @param team [String] the team key, e.g. "TAX"
51
+ # @param project [Hash, nil] `{name:, url:}`
52
+ # @param issues [Array<Agentilda::Linear::Issue>]
53
+ # @return [String]
54
+ def self.render(team:, project:, issues:)
55
+ rows = order(issues).map { |i|
56
+ "| #{i.unit} | #{link(i.identifier, i.url)} | #{escape(i.title)} | #{i.state} | #{i.digest} |"
57
+ }
58
+
59
+ <<~MARKDOWN
60
+ # Linear
61
+
62
+ #{heading(team, project)}
63
+
64
+ | Unit | Issue | Title | State | Synced |
65
+ | :--- | :---- | :---- | ----: | :----- |
66
+ #{rows.join("\n")}
67
+
68
+ <!-- Written by `agentilda linear import --commit`. Do not hand-edit the
69
+ Synced column: it fingerprints what was last pushed, and a row whose
70
+ fingerprint no longer matches the plan is what triggers an update. -->
71
+ MARKDOWN
72
+ end
73
+
74
+ # The folder's own issue first, then its children. A table that opens
75
+ # with a child reads as a list of unrelated issues.
76
+ #
77
+ # @param issues [Array<Agentilda::Linear::Issue>]
78
+ # @return [Array<Agentilda::Linear::Issue>]
79
+ def self.order(issues)
80
+ parent, children = issues.partition { |i| i.unit == PARENT }
81
+ parent + children
82
+ end
83
+
84
+ # @param team [String]
85
+ # @param project [Hash, nil]
86
+ # @return [String]
87
+ def self.heading(team, project)
88
+ return "Team **#{team}**." unless project
89
+
90
+ "Team **#{team}** · project #{link(project[:name], project[:url])}"
91
+ end
92
+
93
+ # @param text [String]
94
+ # @param url [String, nil]
95
+ # @return [String]
96
+ def self.link(text, url) = url ? "[#{escape(text)}](#{url})" : escape(text)
97
+
98
+ # @param text [String]
99
+ # @return [String]
100
+ def self.escape(text) = text.to_s.gsub("|", "\\|").gsub(/\s+/, " ").strip
101
+
102
+ # @param dir [String] absolute path to the plan folder
103
+ def initialize(dir:)
104
+ @dir = dir
105
+ end
106
+
107
+ # @return [Array<Agentilda::Linear::Issue>] possibly empty
108
+ def all = @all ||= parse
109
+
110
+ # @return [Hash{String => Agentilda::Linear::Issue}] keyed by unit
111
+ def by_unit = @by_unit ||= all.to_h { |issue| [issue.unit, issue] }
112
+
113
+ # The project this plan was last filed under, if any.
114
+ #
115
+ # @return [Hash, nil] `{name:, url:}`
116
+ def project = (@project ||= [parse_project])[0]
117
+
118
+ # The issue standing for the folder itself.
119
+ #
120
+ # @return [Agentilda::Linear::Issue, nil]
121
+ def parent = by_unit[PARENT]
122
+
123
+ # @return [String] absolute path to `linear.md`
124
+ def path = File.join(dir, FILENAME)
125
+
126
+ # @return [Boolean]
127
+ def exist? = File.file?(path)
128
+
129
+ # @param team [String]
130
+ # @param project [Hash, nil]
131
+ # @param issues [Array<Agentilda::Linear::Issue>]
132
+ # @return [String] the path written
133
+ def write(team:, project:, issues:)
134
+ File.write(path, self.class.render(team:, project:, issues:))
135
+ @all = @by_unit = @project = nil
136
+ path
137
+ end
138
+
139
+ private
140
+
141
+ # @return [String]
142
+ attr_reader :dir
143
+
144
+ # @return [String]
145
+ def text = @text ||= exist? ? File.read(path, encoding: "UTF-8") : ""
146
+
147
+ # @return [Array<Agentilda::Linear::Issue>]
148
+ def parse
149
+ table = Markdown.tables(text).find { |t|
150
+ t[:header].any? { |h| h.match?(/\Aissue\z/i) } && t[:header].any? { |h| h.match?(/\Aunit\z/i) }
151
+ }
152
+ return [] unless table
153
+
154
+ index = table[:header].each_with_index.to_h { |h, i| [h.downcase.strip, i] }
155
+ table[:rows].filter_map { |cells| row_to_issue(cells, index) }
156
+ end
157
+
158
+ # @param cells [Array<String>]
159
+ # @param index [Hash{String => Integer}]
160
+ # @return [Agentilda::Linear::Issue, nil]
161
+ def row_to_issue(cells, index)
162
+ cell = ->(name) { cells[index[name]].to_s }
163
+ link = cell["issue"].match(/\[([^\]]+)\]\((\S+?)\)/)
164
+ identifier = (link ? link[1] : cell["issue"]).gsub(/[*_`]/, "").strip
165
+ return nil if identifier.empty?
166
+
167
+ Issue.new(unit: cell["unit"].strip, identifier:, url: link && link[2],
168
+ title: cell["title"].gsub(/\\([|\\])/, '\1').strip,
169
+ state: cell["state"].strip, digest: cell["synced"].gsub(/[`\s]/, ""))
170
+ end
171
+
172
+ # @return [Hash, nil]
173
+ def parse_project
174
+ line = text.lines.find { |l| l.match?(/project\s+\[/i) }
175
+ return nil unless line
176
+
177
+ link = line.match(/project\s+\[([^\]]+)\]\((\S+?)\)/i)
178
+ return nil unless link
179
+
180
+ {name: link[1].strip.gsub(/\\([|\\])/, '\1'), url: link[2]}
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Linear
5
+ # What a folder's state means to Linear.
6
+ #
7
+ # Linear gives every workflow state a *type* — one of five, fixed across
8
+ # every workspace — and a *name*, which each team chooses for itself. A
9
+ # team may call its started state "In Progress", "Doing" or "🚧 WIP", so
10
+ # matching on the name alone works right up until it meets somebody else's
11
+ # workspace. Each row here therefore carries both: the type, which is the
12
+ # contract, and the name we would prefer if the team happens to have one.
13
+ # {Push} resolves the name first and falls back to the type.
14
+ #
15
+ # Several states have no Linear equivalent at all — ⭕️ Technical Block is
16
+ # a reason, not a position in a workflow — so they map to the nearest
17
+ # position and carry a label saying which reason.
18
+ #
19
+ # Two states are left out entirely; see {UNPLACED}.
20
+ #
21
+ # @!attribute [r] type
22
+ # @return [String] Linear's canonical type: backlog, unstarted, started,
23
+ # completed or canceled
24
+ # @!attribute [r] name
25
+ # @return [String] the state name to prefer when the team has one
26
+ # @!attribute [r] labels
27
+ # @return [Array<String>] labels that carry what the type cannot
28
+ Placement = Data.define(:type, :name, :labels)
29
+
30
+ # Linear's five workflow state types, in lifecycle order.
31
+ TYPES = %w[backlog unstarted started completed canceled].freeze
32
+
33
+ # Every plan state this tool is willing to place, keyed by
34
+ # {Agentilda::Status#key}.
35
+ #
36
+ # Between this and {UNPLACED} every entry in {Agentilda::STATUSES} is
37
+ # named exactly once, and a spec asserts it. That is the guard against the
38
+ # failure this file is most prone to: a sixteenth state gets added,
39
+ # nothing here changes, and its plans quietly import as Backlog with no
40
+ # indication anything was missed.
41
+ PLACEMENTS = {
42
+ new: Placement.new(type: "backlog", name: "Backlog", labels: []),
43
+ researched: Placement.new(type: "backlog", name: "Backlog", labels: %w[researched]),
44
+ planned: Placement.new(type: "unstarted", name: "Todo", labels: []),
45
+ building: Placement.new(type: "started", name: "In Progress", labels: []),
46
+ # Both halves of building are one column on a board. A reader there
47
+ # wants to know work is under way; which half is under way is this
48
+ # tool's business, and the label carries it for anyone who does care.
49
+ building_ui: Placement.new(type: "started", name: "In Progress", labels: %w[frontend]),
50
+ ready_for_review: Placement.new(type: "started", name: "In Review", labels: []),
51
+ in_review: Placement.new(type: "started", name: "In Review", labels: []),
52
+ rejected: Placement.new(type: "started", name: "In Review", labels: %w[changes-requested]),
53
+ approved: Placement.new(type: "completed", name: "Done", labels: []),
54
+ deployed: Placement.new(type: "completed", name: "Done", labels: %w[deployed]),
55
+ blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked]),
56
+ product_blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked-on-product]),
57
+ deferred: Placement.new(type: "backlog", name: "Backlog", labels: %w[deferred]),
58
+ retroactive: Placement.new(type: "completed", name: "Done", labels: %w[retroactive]),
59
+ discarded: Placement.new(type: "canceled", name: "Canceled", labels: [])
60
+ }.freeze
61
+
62
+ # States deliberately left out, and why.
63
+ #
64
+ # Both are real positions in this tool's lifecycle and neither is
65
+ # obviously any position on a Linear board. Where they belong is a
66
+ # statement about how a particular team works, and this tool does not know
67
+ # that. Guessing would be worse than not knowing: an issue filed in the
68
+ # wrong column reads exactly like an issue filed in the right one, and
69
+ # nobody goes looking for a mistake that renders correctly.
70
+ #
71
+ # A plan in one of these states is reported and skipped. To import them,
72
+ # decide where they belong and move the entry into {PLACEMENTS}.
73
+ UNPLACED = {
74
+ shit: "the plan survives and its pull requests do not; whether that is work still to do " \
75
+ "or work abandoned depends on what the team does next",
76
+ rolled_back: "it shipped and was pulled; whether that reopens this work or opens new work " \
77
+ "depends on what broke"
78
+ }.freeze
79
+
80
+ # Where one unit of work belongs, which is not always where its plan does.
81
+ #
82
+ # A plan in 🟡 Building has some units merged and some not started. Giving
83
+ # every one of its issues the plan's own state says they are all in
84
+ # progress, which is false about most of them and useless on a board. A
85
+ # unit's pull requests are the better evidence, so they are used when
86
+ # there are any.
87
+ #
88
+ # @param status [Agentilda::Status] the plan's state
89
+ # @param pulls [Array<Agentilda::PullRequest>] the unit's
90
+ # @return [Agentilda::Linear::Placement, nil]
91
+ def self.placement_for(status, pulls)
92
+ return placement(status) if pulls.empty?
93
+ return PLACEMENTS[:building].with(name: "In Review") if pulls.any?(&:open?)
94
+ return PLACEMENTS[:approved] if pulls.all?(&:merged?)
95
+
96
+ placement(status)
97
+ end
98
+
99
+ # Where a plan in this state belongs on a Linear board.
100
+ #
101
+ # @param status [Agentilda::Status]
102
+ # @return [Agentilda::Linear::Placement, nil] nil when nobody has decided
103
+ def self.placement(status) = PLACEMENTS[status.key]
104
+
105
+ # Why a state is not imported, for the report that says so.
106
+ #
107
+ # @param status [Agentilda::Status]
108
+ # @return [String] the reason, or the louder one for a state nobody has considered at all
109
+ def self.reason_unplaced(status)
110
+ UNPLACED.fetch(status.key) do
111
+ "no Linear placement has ever been decided for it — add one to Linear::PLACEMENTS"
112
+ end
113
+ end
114
+ end
115
+ end