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,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
|