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.
- checksums.yaml +7 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +261 -0
- data/agentilda.gemspec +57 -0
- data/agents/hansolo-reviewer.md +29 -0
- data/agents/lando-broker.md +74 -0
- data/agents/leah-researcher.md +80 -0
- data/agents/luke-backend.md +81 -0
- data/agents/palpatine-planner.md +40 -0
- data/agents/rey-frontend.md +106 -0
- data/agents/yoda-writer.md +54 -0
- data/bin/create-plan-folder +125 -0
- data/bin/plan-number +164 -0
- data/exe/agentilda +111 -0
- data/exe/tilda +1 -0
- data/lib/agentilda/adoption.rb +192 -0
- data/lib/agentilda/agent.rb +136 -0
- data/lib/agentilda/brief.rb +234 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
- data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
- data/lib/agentilda/cli/base.rb +88 -0
- data/lib/agentilda/cli/create/create.rb +309 -0
- data/lib/agentilda/cli/docs/docs.rb +30 -0
- data/lib/agentilda/cli/index/index.rb +38 -0
- data/lib/agentilda/cli/linear/linear.rb +35 -0
- data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
- data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
- data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
- data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
- data/lib/agentilda/cli/run/run.rb +289 -0
- data/lib/agentilda/cli/states/states.rb +15 -0
- data/lib/agentilda/cli/unblock/unblock.rb +227 -0
- data/lib/agentilda/cli/version/version.rb +13 -0
- data/lib/agentilda/cli.rb +74 -0
- data/lib/agentilda/config.rb +44 -0
- data/lib/agentilda/control.rb +115 -0
- data/lib/agentilda/creator.rb +120 -0
- data/lib/agentilda/dev_work.rb +54 -0
- data/lib/agentilda/diagram.rb +144 -0
- data/lib/agentilda/documentation.rb +429 -0
- data/lib/agentilda/executor.rb +539 -0
- data/lib/agentilda/feature.rb +253 -0
- data/lib/agentilda/frontmatter.rb +36 -0
- data/lib/agentilda/github.rb +160 -0
- data/lib/agentilda/index.rb +206 -0
- data/lib/agentilda/keyboard.rb +88 -0
- data/lib/agentilda/linear/api.rb +220 -0
- data/lib/agentilda/linear/attribution.rb +185 -0
- data/lib/agentilda/linear/fuzzy.rb +68 -0
- data/lib/agentilda/linear/import.rb +298 -0
- data/lib/agentilda/linear/issue.rb +184 -0
- data/lib/agentilda/linear/mapping.rb +115 -0
- data/lib/agentilda/linear/push.rb +190 -0
- data/lib/agentilda/linear/survey.rb +173 -0
- data/lib/agentilda/linear/unit.rb +274 -0
- data/lib/agentilda/linear.rb +42 -0
- data/lib/agentilda/markdown.rb +56 -0
- data/lib/agentilda/ordinal.rb +90 -0
- data/lib/agentilda/progress_log.rb +122 -0
- data/lib/agentilda/publisher.rb +172 -0
- data/lib/agentilda/pull_request.rb +213 -0
- data/lib/agentilda/reporter.rb +175 -0
- data/lib/agentilda/resync.rb +358 -0
- data/lib/agentilda/roster.rb +110 -0
- data/lib/agentilda/runner.rb +456 -0
- data/lib/agentilda/state_machine.rb +355 -0
- data/lib/agentilda/status.rb +280 -0
- data/lib/agentilda/tally.rb +169 -0
- data/lib/agentilda/transcript.rb +435 -0
- data/lib/agentilda/tree.rb +77 -0
- data/lib/agentilda/ui.rb +681 -0
- data/lib/agentilda/unblocker.rb +207 -0
- data/lib/agentilda/version.rb +10 -0
- data/lib/agentilda/viewer.rb +60 -0
- data/lib/agentilda/worktree.rb +211 -0
- data/lib/agentilda.rb +155 -0
- data/lib/dry/cli/banner.rb +293 -0
- 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
|