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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Linear
5
+ # What one {Action} actually did.
6
+ #
7
+ # @!attribute [r] action
8
+ # @return [Agentilda::Linear::Action]
9
+ # @!attribute [r] identifier
10
+ # @return [String, nil] "TAX-41"
11
+ # @!attribute [r] url
12
+ # @return [String, nil]
13
+ # @!attribute [r] error
14
+ # @return [String, nil] why it did not happen
15
+ Result = Data.define(:action, :identifier, :url, :error) do
16
+ # @return [Boolean]
17
+ def ok? = error.nil?
18
+ end
19
+
20
+ # Applies an {Import} through the {API}, then writes each plan's
21
+ # `linear.md` so the next run knows what this one did.
22
+ #
23
+ # Plans are pushed one at a time and in order. There is no concurrency
24
+ # here on purpose: within a plan the folder's own issue must exist before
25
+ # its children can name it as their parent, and across plans the gain
26
+ # would be a few seconds against the risk of two threads racing to create
27
+ # the same label on a team that does not have it yet.
28
+ class Push
29
+ # @param import [Agentilda::Linear::Import]
30
+ # @param api [Agentilda::Linear::API]
31
+ # @param tree [Agentilda::Tree]
32
+ def initialize(import:, api:, tree:)
33
+ @import = import
34
+ @api = api
35
+ @tree = tree
36
+ end
37
+
38
+ # @return [Array<Agentilda::Linear::Result>] every action attempted,
39
+ # skips included, in plan order
40
+ def call
41
+ import.actions.group_by(&:ordinal).flat_map { |ordinal, actions| push_plan(ordinal, actions) }
42
+ end
43
+
44
+ private
45
+
46
+ # @return [Agentilda::Linear::Import]
47
+ attr_reader :import
48
+
49
+ # @return [Agentilda::Linear::API]
50
+ attr_reader :api
51
+
52
+ # @return [Agentilda::Tree]
53
+ attr_reader :tree
54
+
55
+ # @return [Hash] the team, its states and its labels
56
+ def team = @team ||= api.team(import.team)
57
+
58
+ # @return [String] the project everything is filed under
59
+ def project_id = import.project["id"]
60
+
61
+ # @param ordinal [String]
62
+ # @param actions [Array<Agentilda::Linear::Action>]
63
+ # @return [Array<Agentilda::Linear::Result>]
64
+ def push_plan(ordinal, actions)
65
+ subject = tree.find(ordinal) or return []
66
+ parents, children = actions.partition { |a| a.unit == Issues::PARENT }
67
+ return [] if parents.empty?
68
+
69
+ parent = apply(parents.first, subject, nil)
70
+ results = [parent] + children.map { |action| apply(action, subject, parent) }
71
+ record(subject, results)
72
+ results
73
+ end
74
+
75
+ # @param action [Agentilda::Linear::Action]
76
+ # @param subject [Agentilda::Subject]
77
+ # @param parent [Agentilda::Linear::Result, nil]
78
+ # @return [Agentilda::Linear::Result]
79
+ def apply(action, subject, parent)
80
+ if parent && !parent.ok?
81
+ return Result.new(action:, identifier: action.identifier, url: nil,
82
+ error: "the issue for its plan could not be created")
83
+ end
84
+
85
+ attempt(action) do
86
+ node = case action.op
87
+ when :skip then recorded(action, subject)
88
+ when :update then attach(api.update_issue(action.args[:id], input(action, parent)), action)
89
+ else attach(api.create_issue(input(action, parent).merge(teamId: team[:id], projectId: project_id)), action)
90
+ end
91
+
92
+ [node["identifier"], node["url"]]
93
+ end
94
+ end
95
+
96
+ # A child names its parent by the identifier Linear just handed back,
97
+ # which is why the folder's own issue is pushed first and alone.
98
+ #
99
+ # @param action [Agentilda::Linear::Action]
100
+ # @param parent [Agentilda::Linear::Result, nil]
101
+ # @return [Hash]
102
+ 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
+ return base unless parent&.identifier
106
+
107
+ base.merge(parentId: parent.identifier)
108
+ end
109
+
110
+ # Attaching the pull requests is part of writing the issue, not a
111
+ # decoration on top of it. Linear keys an attachment on its URL, so
112
+ # sending the same one again updates it rather than piling up a second
113
+ # copy — which is what makes this safe on an update.
114
+ #
115
+ # @param node [Hash]
116
+ # @param action [Agentilda::Linear::Action]
117
+ # @return [Hash]
118
+ def attach(node, action)
119
+ Array(action.args[:links]).each { |l| api.link(issue_id: node["id"], url: l[:url], title: l[:title]) }
120
+ node
121
+ end
122
+
123
+ # @param action [Agentilda::Linear::Action]
124
+ # @param subject [Agentilda::Subject]
125
+ # @return [Hash]
126
+ def recorded(action, subject)
127
+ was = Issues.new(dir: subject.feature.path).by_unit[action.unit]
128
+ {"identifier" => was&.identifier || action.identifier, "url" => was&.url}
129
+ end
130
+
131
+ # A team names its own workflow states, so the name we would prefer is a
132
+ # preference and the type is the contract. Falling back to the type is
133
+ # what lets this run against a workspace whose "In Progress" is called
134
+ # something else entirely.
135
+ #
136
+ # @param name [String]
137
+ # @return [String, nil]
138
+ def state_id(name)
139
+ placement = PLACEMENTS.values.find { |p| p.name == name }
140
+ states = team[:states]
141
+
142
+ found = states.find { |s| s["name"].to_s.casecmp?(name.to_s) }
143
+ found ||= states.select { |s| s["type"] == placement&.type }.min_by { |s| s["position"].to_f }
144
+ found && found["id"]
145
+ end
146
+
147
+ # @param names [Array<String>]
148
+ # @return [Array<String>]
149
+ def label_ids(names)
150
+ Array(names).map do |name|
151
+ found = team[:labels].find { |l| l["name"].to_s.casecmp?(name) }
152
+ found ||= api.create_label(name, team[:id]).tap { |made| team[:labels] << made }
153
+ found["id"]
154
+ end
155
+ end
156
+
157
+ # Every Linear call is wrapped, so one plan that fails — a state the
158
+ # team does not have, a title Linear refuses — does not abandon the
159
+ # twenty plans queued behind it.
160
+ #
161
+ # @param action [Agentilda::Linear::Action]
162
+ # @yieldreturn [Array(String, String)] identifier and url
163
+ # @return [Agentilda::Linear::Result]
164
+ def attempt(action)
165
+ identifier, url = yield
166
+ Result.new(action:, identifier:, url:, error: nil)
167
+ rescue Error => e
168
+ Result.new(action:, identifier: action.identifier, url: nil, error: e.message)
169
+ end
170
+
171
+ # `linear.md` is rewritten from what actually happened, including the
172
+ # skips — a row dropped because its action was a no-op is a row the next
173
+ # run would recreate from scratch.
174
+ #
175
+ # @param subject [Agentilda::Subject]
176
+ # @param results [Array<Agentilda::Linear::Result>]
177
+ # @return [void]
178
+ def record(subject, results)
179
+ issues = results.select { |r| r.ok? && r.identifier }.map { |r|
180
+ Issue.new(unit: r.action.unit, identifier: r.identifier, url: r.url,
181
+ title: r.action.title, state: r.action.args[:state] || "", digest: r.action.digest)
182
+ }
183
+ return if issues.empty?
184
+
185
+ Issues.new(dir: subject.feature.path).write(team: import.team, issues:,
186
+ project: {name: import.project_name, url: import.project["url"]})
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Linear
5
+ # What a team already has in Linear, set against what `.plans` holds.
6
+ #
7
+ # This exists because the import cannot see Linear at all — that is the
8
+ # property that makes its dry run trustworthy — and the cost of that
9
+ # property is a blind spot: a plan whose project someone already made by
10
+ # hand looks, from disk, exactly like a plan that has none. Proposing to
11
+ # create it would quietly produce a second project beside the first.
12
+ #
13
+ # So the reconciliation is its own read-only command rather than something
14
+ # smuggled into the import. It answers one question — *is anything here
15
+ # already in Linear under a different name?* — and answers it by showing
16
+ # both lists rather than by guessing.
17
+ class Survey
18
+ # How a project and a plan were found to be the same thing.
19
+ #
20
+ # Both rules are exact after normalising; neither is fuzzy. A near-match
21
+ # is reported as a near-match, because a project silently adopted by the
22
+ # wrong plan is worse than one nobody adopted at all.
23
+ Match = Data.define(:project, :subject, :rule) do
24
+ # @return [String]
25
+ def why = {ordinal: "its name carries the plan number", slug: "its name is the plan's slug"}.fetch(rule)
26
+ end
27
+
28
+ # @param tree [Agentilda::Tree]
29
+ # @param projects [Array<Hash>] `{"id", "name", "url", "status"}` from {API#projects}
30
+ def initialize(tree:, projects:)
31
+ @tree = tree
32
+ @projects = projects
33
+ end
34
+
35
+ # @return [Array<Hash>] every project, as Linear reports it
36
+ attr_reader :projects
37
+
38
+ # @return [Array<Agentilda::Linear::Survey::Match>]
39
+ def matches = @matches ||= projects.filter_map { |project| match_for(project) }
40
+
41
+ # Projects that correspond to no plan. Usually the team's real,
42
+ # hand-curated projects — the ones an import has no business touching.
43
+ #
44
+ # @return [Array<Hash>]
45
+ def unmatched = projects - matches.map(&:project)
46
+
47
+ # Plans with no project. An import would create one for each.
48
+ #
49
+ # @return [Array<Agentilda::Subject>]
50
+ def uncovered = tree.subjects - matches.map(&:subject)
51
+
52
+ # Projects whose name shares the plan's words without matching either
53
+ # rule, best first.
54
+ #
55
+ # @param subject [Agentilda::Subject]
56
+ # @return [Array<Hash>]
57
+ def near(subject) = scored(subject).map(&:first)
58
+
59
+ # The best guess at which project a plan belongs under, for
60
+ # `--auto-assign`, with the number of words it agreed on.
61
+ #
62
+ # One shared word is not evidence — half these plans mention "tax" — so
63
+ # a guess needs two. Even then it is reported as a guess wherever it
64
+ # appears, because `plaid-integration` matching `Plaid Connectivity` is
65
+ # obvious to a person and, to this, indistinguishable from
66
+ # `tax-engine-consolidation` matching `Tax Law Engine`, which is wrong.
67
+ #
68
+ # @param subject [Agentilda::Subject]
69
+ # @return [Array(Hash, Integer), nil] the project and its score
70
+ def guess(subject)
71
+ best, score = scored(subject).first
72
+ return nil unless best && score >= 2
73
+
74
+ [best, score]
75
+ end
76
+
77
+ # Projects with nothing written about them.
78
+ #
79
+ # A name is three or four words and half of them are the company's. What
80
+ # makes a plan recognisably one project's business rather than another's
81
+ # is the sentence saying what the project is for, and where that sentence
82
+ # is missing there is nothing to match a specification against.
83
+ #
84
+ # @return [Array<Hash>]
85
+ def undescribed = projects.reject { |p| described?(p) }
86
+
87
+ # @param project [Hash]
88
+ # @return [Boolean]
89
+ def described?(project) = !prose(project).strip.empty?
90
+
91
+ # Find one project by whatever the user had to hand: its URL, its name,
92
+ # or its id. A URL is the thing you can actually copy out of Linear.
93
+ #
94
+ # @param reference [String]
95
+ # @return [Hash]
96
+ # @raise [Agentilda::Error] when nothing matches it
97
+ def project(reference)
98
+ wanted = reference.to_s.strip
99
+ found = projects.find { |p|
100
+ p["url"].to_s.casecmp?(wanted) || p["name"].to_s.casecmp?(wanted) ||
101
+ p["id"] == wanted || p["url"].to_s.end_with?("/#{slugify(wanted)}")
102
+ }
103
+ return found if found
104
+
105
+ raise Error, "no project matches #{reference.inspect}. This team has:\n" +
106
+ projects.map { |p| " #{p["name"]}\n #{p["url"]}" }.join("\n")
107
+ end
108
+
109
+ private
110
+
111
+ # @return [Agentilda::Tree]
112
+ attr_reader :tree
113
+
114
+ # @param project [Hash]
115
+ # @return [Agentilda::Linear::Survey::Match, nil]
116
+ def match_for(project)
117
+ name = project["name"].to_s
118
+
119
+ by_ordinal = tree.subjects.find { |s| name.include?(s.feature.ordinal.to_s) }
120
+ return Match.new(project:, subject: by_ordinal, rule: :ordinal) if by_ordinal
121
+
122
+ by_slug = tree.subjects.find { |s| slugify(name) == s.feature.slug }
123
+ Match.new(project:, subject: by_slug, rule: :slug) if by_slug
124
+ end
125
+
126
+ # Every project that shares a word with this plan, best first.
127
+ #
128
+ # @param subject [Agentilda::Subject]
129
+ # @return [Array<Array(Hash, Integer)>]
130
+ def scored(subject)
131
+ wanted = plan_words(subject)
132
+ return [] if wanted.empty?
133
+
134
+ projects.filter_map { |p|
135
+ overlap = (project_words(p) & wanted).size
136
+ [p, overlap] if overlap.positive?
137
+ }.sort_by { |_, overlap| -overlap }
138
+ end
139
+
140
+ # @param subject [Agentilda::Subject]
141
+ # @return [Array<String>]
142
+ def plan_words(subject)
143
+ words(subject.feature.slug) | words(slugify(subject.feature.title)) |
144
+ words(slugify(subject.goal.join(" ")))
145
+ end
146
+
147
+ # @param project [Hash]
148
+ # @return [Array<String>]
149
+ def project_words(project)
150
+ (@project_words ||= {})[project["id"]] ||=
151
+ words(slugify(project["name"])) | words(slugify(prose(project)))
152
+ end
153
+
154
+ # Whatever the project says about itself. Linear keeps a short line and a
155
+ # long document and a team uses whichever it uses.
156
+ #
157
+ # @param project [Hash]
158
+ # @return [String]
159
+ def prose(project) = "#{project["description"]} #{project["content"]} #{project["summary"]}"
160
+
161
+ # @param text [String]
162
+ # @return [String]
163
+ def slugify(text) = text.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
164
+
165
+ # Words too common to carry a match on their own.
166
+ NOISE = %w[the a an and or of to for app web spec plan].freeze
167
+
168
+ # @param slug [String]
169
+ # @return [Array<String>]
170
+ def words(slug) = slug.to_s.split("-").reject { |w| w.length < 3 || NOISE.include?(w) }
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Linear
5
+ # One unit of work inside a plan — the thing that becomes a Linear issue.
6
+ #
7
+ # @!attribute [r] key
8
+ # @return [String] "PR-1" from a plan.md heading, or "#38" from a pull request
9
+ # @!attribute [r] title
10
+ # @return [String] the heading's words, without the "PR-1 —" part
11
+ # @!attribute [r] body
12
+ # @return [String] everything under the heading, as written
13
+ # @!attribute [r] pull_requests
14
+ # @return [Array<Agentilda::PullRequest>] the ones this unit claims
15
+ Unit = Data.define(:key, :title, :body, :pull_requests)
16
+
17
+ # Reads the work units out of a plan folder.
18
+ #
19
+ # `plan.md` is written by an agent for humans, not as a data file, so this
20
+ # reads the one structure the format has always had: a heading per unit,
21
+ # naming the pull request it will become. Everything else is prose about
22
+ # those units.
23
+ #
24
+ # Two things about real plans make that harder than it sounds, and both
25
+ # were found by running this over thirty-eight of them:
26
+ #
27
+ # * The unit headings are all at one level, but *which* level varies by
28
+ # document — `## PR-1 — …` in one plan, `### PR 020.01 — …` in the
29
+ # next. Deeper headings underneath them (`### PR-1 tests`) name the
30
+ # same unit again. Matching every heading that mentions a pull request
31
+ # turned one plan's three units into nine.
32
+ # * A plan numbers its units either from one (`PR-1`) or from its own
33
+ # spec number (`PR 020.01`). The second form is the first form with
34
+ # the plan's number glued on, so it is normalised back.
35
+ #
36
+ # A plan with no such headings is not an error. Retroactive plans have no
37
+ # `plan.md` at all, and a small plan is often one pull request with no
38
+ # internal divisions — both import as a single issue standing for the
39
+ # whole plan, which is the honest reading of a plan that never divided
40
+ # itself.
41
+ class Units
42
+ # `## 2. PR-1 — …`, `### PR 020.01 — …`, and the several other ways the
43
+ # same heading gets typed. The hashes are escaped because an unescaped
44
+ # `#{` in a regexp literal is interpolation.
45
+ HEADING = /\A(\#{2,4})[ \t]+(?:\d+[.)][ \t]*)?PR[\s_-]?(\d+(?:\.\d+)?)\b[ \t]*(.*)\z/i
46
+
47
+ # A fenced block can hold anything, including lines that read as
48
+ # headings — `plan.md` files are full of shell and SQL whose comments
49
+ # start with `#`. Scanning without stripping fences invents units.
50
+ FENCE = /^[ \t]{0,3}(?:```|~~~)/
51
+
52
+ # `(✔ #38)`, `(▶︎ in-flight)`, `(⬜ blocked)` — a status glyph in a
53
+ # trailing parenthetical. It is the state of the unit, which Linear
54
+ # tracks itself, and leaving it in the title means every title churns
55
+ # the moment the work moves.
56
+ GLYPH_NOTE = /\s*\((?:[^\w\s(][^)]*)\)\s*\z/
57
+
58
+ # The same thing without the brackets: `… over the law plane ☢️ deferred`.
59
+ TRAILING_GLYPH = /\s*[\u{2190}-\u{2BFF}\u{1F000}-\u{1FAFF}][^\n]*\z/
60
+
61
+ # A plan number the author typed into the heading as well. The issue for
62
+ # the plan already carries it.
63
+ OWN_NUMBER = /\A\[\d{1,3}(?:\.\d{2})?\]\s*/
64
+
65
+ # Pull request numbers named in the heading: "(✔ #38)".
66
+ NUMBERED = /#(\d+)\b/
67
+
68
+ # @param subject [Agentilda::Subject]
69
+ def initialize(subject:)
70
+ @subject = subject
71
+ end
72
+
73
+ # The units of work inside a plan, which become its sub-issues.
74
+ #
75
+ # A plan that declares its units in `plan.md` gets those. A plan that
76
+ # does not — a retroactive one, or one written before the convention —
77
+ # gets one per pull request instead, because a pull request that shipped
78
+ # is a unit of work whether or not anybody wrote it down first.
79
+ #
80
+ # A plan with neither is legitimately empty: it has been specified and
81
+ # nothing has been divided or built yet. Its issue stands alone until
82
+ # somebody plans it.
83
+ #
84
+ # @return [Array<Agentilda::Linear::Unit>] possibly empty
85
+ def all = @all ||= divided.empty? ? from_pull_requests : attach(divided)
86
+
87
+ # A pull request title carries the plan number this tool put there, and
88
+ # often the unit it implements. Neither belongs in an issue title: the
89
+ # issue for the plan already says which plan, and the attachment already
90
+ # says which pull request.
91
+ #
92
+ # @param title [String]
93
+ # @return [String]
94
+ def self.clean_title(title)
95
+ title.to_s
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
+ end
101
+
102
+ private
103
+
104
+ # @return [Agentilda::Subject]
105
+ attr_reader :subject
106
+
107
+ # @return [Array<Agentilda::Linear::Unit>] possibly empty
108
+ def divided = @divided ||= merge(sections).map { |key, title, body| Unit.new(key:, title:, body:, pull_requests: []) }
109
+
110
+ # One unit per pull request, for a plan that declared none itself.
111
+ #
112
+ # Keyed by pull request number rather than by position, so the key means
113
+ # something on its own and cannot collide with the `PR-1` keys a
114
+ # `plan.md` would introduce later.
115
+ #
116
+ # @return [Array<Agentilda::Linear::Unit>]
117
+ def from_pull_requests
118
+ subject.pull_requests.map do |pr|
119
+ Unit.new(key: "##{pr.number}", title: clean_pr(pr.title), body: "", pull_requests: [pr])
120
+ end
121
+ end
122
+
123
+ # @param title [String]
124
+ # @return [String]
125
+ def clean_pr(title) = self.class.clean_title(title)
126
+
127
+ # @return [Array<Array(String, String, String)>] key, title, body
128
+ def sections
129
+ lines = readable
130
+ level = headings(lines).map(&:first).min
131
+ return [] unless level
132
+
133
+ found = []
134
+ lines.each do |line|
135
+ match = HEADING.match(line)
136
+ if match && match[1].length == level
137
+ found << [key_for(match[2]), clean(match[3]), +""]
138
+ elsif found.any?
139
+ found.last[2] << line << "\n"
140
+ end
141
+ end
142
+ found.reject { |_, title, _| title.empty? }
143
+ end
144
+
145
+ # A heading at the shallowest level that names a pull request starts a
146
+ # unit; a deeper one is that unit's contents.
147
+ #
148
+ # @param lines [Array<String>]
149
+ # @return [Array<Array(Integer, String)>] level and key
150
+ def headings(lines)
151
+ lines.filter_map { |line|
152
+ match = HEADING.match(line)
153
+ [match[1].length, key_for(match[2])] if match
154
+ }
155
+ end
156
+
157
+ # `PR 020.01` inside plan 020.00 is this plan's first unit, written the
158
+ # long way. Reduced, so its key does not depend on which convention the
159
+ # author reached for.
160
+ #
161
+ # @param number [String] "1" or "020.01"
162
+ # @return [String]
163
+ def key_for(number)
164
+ major, minor = number.split(".")
165
+ return "PR-#{number}" unless minor
166
+ return "PR-#{number}" unless major.to_i == subject.feature.ordinal.major
167
+
168
+ "PR-#{minor.to_i}"
169
+ end
170
+
171
+ # One unit, headed twice. Bodies join; the first title wins, because a
172
+ # later heading for the same unit is a continuation and titles itself
173
+ # like one — "PR-1 tests", "PR-1 parameters".
174
+ #
175
+ # @param sections [Array<Array(String, String, String)>]
176
+ # @return [Array<Array(String, String, String)>]
177
+ def merge(sections)
178
+ sections.each_with_object({}) { |(key, title, body), by_key|
179
+ if by_key.key?(key)
180
+ by_key[key][2] = "#{by_key[key][2]}\n\n#{body}"
181
+ else
182
+ by_key[key] = [key, title, body]
183
+ end
184
+ }.values.map { |key, title, body| [key, title, body.strip] }
185
+ end
186
+
187
+ # Which pull requests each unit claims, by the three joins that exist
188
+ # between `plan.md` and `pull-requests.md`, most explicit first.
189
+ #
190
+ # @param units [Array<Agentilda::Linear::Unit>]
191
+ # @return [Array<Agentilda::Linear::Unit>]
192
+ def attach(units)
193
+ claimed = []
194
+ found = units.map { |unit|
195
+ prs = (numbered(unit) + named(unit)).uniq - claimed
196
+ claimed.concat(prs)
197
+ unit.with(pull_requests: prs)
198
+ }
199
+
200
+ return found unless found.one?
201
+
202
+ # With one unit there is nowhere else for a pull request to go. That
203
+ # is arithmetic rather than a guess, so it is done rather than warned
204
+ # about — and it is the common case for the small plans.
205
+ [found.first.with(pull_requests: subject.pull_requests)]
206
+ end
207
+
208
+ # The heading said so: `### PR 020.03 — Clerk webhooks (✔ #44)`.
209
+ #
210
+ # @param unit [Agentilda::Linear::Unit]
211
+ # @return [Array<Agentilda::PullRequest>]
212
+ def numbered(unit)
213
+ wanted = heading_for(unit.key).to_s.scan(NUMBERED).flatten
214
+ subject.pull_requests.select { |pr| wanted.include?(pr.number.to_s) }
215
+ end
216
+
217
+ # The pull request title said so: "Spec 010 PR-2: MAGI registry".
218
+ #
219
+ # @param unit [Agentilda::Linear::Unit]
220
+ # @return [Array<Agentilda::PullRequest>]
221
+ def named(unit)
222
+ pattern = /\bPR[\s_-]?#{unit.key[/\d+\z/]}\b/i
223
+ subject.pull_requests.select { |pr| pr.title.match?(pattern) }
224
+ end
225
+
226
+ # Every heading line that names a unit, grouped by its key — the deeper
227
+ # continuation headings included, since any of them may be the one
228
+ # carrying the pull request number.
229
+ #
230
+ # @return [Hash{String => Array<String>}]
231
+ def headlines
232
+ @headlines ||= readable.each_with_object(Hash.new { |h, k| h[k] = [] }) do |line, found|
233
+ match = HEADING.match(line)
234
+ found[key_for(match[2])] << line if match
235
+ end
236
+ end
237
+
238
+ # @param key [String]
239
+ # @return [String] every heading this unit was written under
240
+ def heading_for(key) = headlines[key].join("\n")
241
+
242
+ # Every line of `plan.md` that is not inside a fence, fence lines
243
+ # themselves included in what gets dropped.
244
+ #
245
+ # @return [Array<String>]
246
+ def readable
247
+ @readable ||= begin
248
+ fenced = false
249
+ subject.read("plan.md").to_s.each_line(chomp: true).each_with_object([]) do |line, kept|
250
+ if line.match?(FENCE)
251
+ fenced = !fenced
252
+ elsif !fenced
253
+ kept << line
254
+ end
255
+ end
256
+ end
257
+ end
258
+
259
+ # @param title [String]
260
+ # @return [String]
261
+ def clean(title)
262
+ title.to_s
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
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # `.plans` as Linear projects and issues.
5
+ #
6
+ # The folders already are a tracker: a number, a state, a set of pull
7
+ # requests and a document saying why. What they are not is visible to anyone
8
+ # who does not have the repository checked out, which is most of the people
9
+ # who want to know how a piece of work is going. This exports to Linear so
10
+ # that audience can see it, and it exports *one way* — the folder is the
11
+ # truth, Linear is the window.
12
+ #
13
+ # Four pieces, in the order a run uses them:
14
+ #
15
+ # {Units} reads `plan.md` and finds the work units inside a plan
16
+ # {Import} decides, from disk alone, what would be created or updated
17
+ # {Push} applies that through {API} and records what it did
18
+ # {Issues} is the record, `linear.md`, that makes the next run idempotent
19
+ #
20
+ # {Import} touches nothing but the filesystem, which is what makes the dry
21
+ # run trustworthy: it is not a description of what a push would do, it is
22
+ # the object the push consumes.
23
+ module Linear
24
+ # Team keys are what Linear puts in front of every issue number.
25
+ KEY = /\A[A-Z][A-Z0-9]{1,9}\z/
26
+
27
+ # @param prefix [String, nil]
28
+ # @return [String] the normalised team key
29
+ # @raise [Agentilda::Error] when it could never be one
30
+ def self.key!(prefix)
31
+ key = prefix.to_s.strip.upcase
32
+ return key if key.match?(KEY)
33
+
34
+ raise Error, "#{prefix.inspect} is not a Linear team key. A key is 2–10 characters, " \
35
+ "letters and digits, as in TAX or ENG — the part before the dash in TAX-41."
36
+ end
37
+ end
38
+ 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