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,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# `/spec-status` — every plan, its state in icon and words, and its pull
|
|
5
|
+
# requests as clickable links.
|
|
6
|
+
#
|
|
7
|
+
# {#render} returns a string and prints nothing. Whoever calls it decides
|
|
8
|
+
# where it goes, which keeps the table pipeable.
|
|
9
|
+
class Reporter
|
|
10
|
+
# Columns, so the header and the rows cannot drift apart.
|
|
11
|
+
HEADINGS = ["Plan", "⬇", "Status", "Feature", "Pull Requests"].freeze
|
|
12
|
+
|
|
13
|
+
# Cells given to the plan number: "018.01" with room to grow.
|
|
14
|
+
ORDINAL_WIDTH = 7
|
|
15
|
+
|
|
16
|
+
# Cells given to a status icon. Emoji vary in both character count and
|
|
17
|
+
# display width, so the cell is padded to this by {UI.fit} rather than
|
|
18
|
+
# trusted to be any particular size — see the note there.
|
|
19
|
+
EMOJI_WIDTH = 2
|
|
20
|
+
|
|
21
|
+
# Cells given to a pull request's state, so the URLs line up beneath each
|
|
22
|
+
# other whichever states the tree happens to contain.
|
|
23
|
+
STATE_WIDTH = PullRequests::STATES.map { |_, label| UI.display_width(label) }.max
|
|
24
|
+
|
|
25
|
+
# @param tree [Agentilda::Tree]
|
|
26
|
+
def initialize(tree:)
|
|
27
|
+
@tree = tree
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Agentilda::Tree]
|
|
31
|
+
attr_reader :tree
|
|
32
|
+
|
|
33
|
+
# @return [String] the whole table, newline-terminated
|
|
34
|
+
def render
|
|
35
|
+
return "No plans found in #{tree.dir}\n" if tree.subjects.empty?
|
|
36
|
+
|
|
37
|
+
[header, *tree.subjects.flat_map { |s| rows_for(s) }, "", footer].join("\n") + "\n"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash{Symbol => Integer}] how many plans sit in each state
|
|
41
|
+
def totals
|
|
42
|
+
tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status.key] += 1 }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Plans whose folder name their contents do not justify.
|
|
46
|
+
#
|
|
47
|
+
# @return [Array<Agentilda::Subject>]
|
|
48
|
+
def inconsistent = tree.subjects.reject(&:consistent?)
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# @return [Integer] widest status label, so the column lines up
|
|
53
|
+
def label_width = @label_width ||= tree.subjects.map { |s| UI.display_width(s.status.label) }.max.to_i + 4
|
|
54
|
+
|
|
55
|
+
# @return [Integer] widest feature name
|
|
56
|
+
def title_width = @title_width ||= tree.subjects.map { |s| UI.display_width(s.feature.title) }.max.to_i + 2
|
|
57
|
+
|
|
58
|
+
# The one place the table's shape is written down. The heading, the plan
|
|
59
|
+
# rows, the continuation lines and the violations all come through here, so
|
|
60
|
+
# a column cannot widen in one of them and stay put in the others.
|
|
61
|
+
#
|
|
62
|
+
# Cells arrive already fitted and already painted, in that order, because
|
|
63
|
+
# the order matters: escape codes count toward a string's length, so
|
|
64
|
+
# padding a coloured string pads it to a width of which several characters
|
|
65
|
+
# are invisible, and the column collapses by exactly that much.
|
|
66
|
+
#
|
|
67
|
+
# @return [String]
|
|
68
|
+
def row(flag, ordinal, emoji, label, title, tail)
|
|
69
|
+
format("%s %s %s %s %s %s", flag, ordinal, emoji, label, title, tail)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Cell at which the pull requests start, so a plan's second and third pull
|
|
73
|
+
# requests sit under its first. Measured by running blanks through {#row}
|
|
74
|
+
# rather than counted by hand, so the indent cannot drift when the layout
|
|
75
|
+
# changes.
|
|
76
|
+
#
|
|
77
|
+
# @return [Integer]
|
|
78
|
+
def pr_column
|
|
79
|
+
@pr_column ||= row(" ", " " * ORDINAL_WIDTH, " " * EMOJI_WIDTH,
|
|
80
|
+
" " * label_width, " " * title_width, "").length
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Integer] cell at which the feature name starts
|
|
84
|
+
def title_column = pr_column - title_width - 1
|
|
85
|
+
|
|
86
|
+
# @return [String]
|
|
87
|
+
def header
|
|
88
|
+
text = row(" ",
|
|
89
|
+
UI.fit(HEADINGS[0], ORDINAL_WIDTH),
|
|
90
|
+
UI.fit(HEADINGS[1], EMOJI_WIDTH),
|
|
91
|
+
UI.fit(HEADINGS[2], label_width),
|
|
92
|
+
UI.fit(HEADINGS[3], title_width),
|
|
93
|
+
HEADINGS[4])
|
|
94
|
+
|
|
95
|
+
rule = " " + ("─" * (UI.display_width(text) - 2))
|
|
96
|
+
|
|
97
|
+
[UI.paint(text, :bold), UI.paint(rule, :bright_yellow)].join("\n")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One line per plan, plus a continuation line for each extra pull request
|
|
101
|
+
# so that every URL is visible and clickable rather than truncated.
|
|
102
|
+
#
|
|
103
|
+
# @param subject [Agentilda::Subject]
|
|
104
|
+
# @return [Array<String>]
|
|
105
|
+
def rows_for(subject)
|
|
106
|
+
feature = subject.feature
|
|
107
|
+
prs = subject.pull_requests
|
|
108
|
+
|
|
109
|
+
first = row(
|
|
110
|
+
subject.consistent? ? " " : UI.paint("!", :red),
|
|
111
|
+
UI.paint(UI.fit(feature.ordinal.to_s, ORDINAL_WIDTH), :bold),
|
|
112
|
+
UI.fit(feature.status.emoji, EMOJI_WIDTH),
|
|
113
|
+
UI.paint(UI.fit(feature.status.label, label_width), :yellow),
|
|
114
|
+
UI.paint(UI.fit(feature.title, title_width), :blue, :bold),
|
|
115
|
+
prs.empty? ? UI.paint("[none yet]", :bright_red) : link(prs.first)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
rest = prs.drop(1).map { |pr| (" " * pr_column) + link(pr) }
|
|
119
|
+
|
|
120
|
+
[first, *rest, *violation_line(subject)]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The URL is left bare rather than wrapped in an OSC 8 hyperlink: the table
|
|
124
|
+
# is as likely to be piped into a file as read in a terminal, and every
|
|
125
|
+
# terminal worth the name linkifies a bare URL anyway.
|
|
126
|
+
#
|
|
127
|
+
# @param pr [Agentilda::PullRequest]
|
|
128
|
+
# @return [String]
|
|
129
|
+
def link(pr)
|
|
130
|
+
colour = state_colour(pr)
|
|
131
|
+
|
|
132
|
+
format("#%4s %s %s",
|
|
133
|
+
pr.number,
|
|
134
|
+
UI.paint(UI.fit(pr.state, STATE_WIDTH), colour),
|
|
135
|
+
UI.paint(pr.url.to_s, colour))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param pr [Agentilda::PullRequest]
|
|
139
|
+
# @return [Symbol] a pastel style name
|
|
140
|
+
def state_colour(pr)
|
|
141
|
+
if pr.merged?
|
|
142
|
+
:magenta
|
|
143
|
+
elsif pr.open?
|
|
144
|
+
:yellow
|
|
145
|
+
else
|
|
146
|
+
:blue
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @param subject [Agentilda::Subject]
|
|
151
|
+
# @return [Array<String>]
|
|
152
|
+
def violation_line(subject)
|
|
153
|
+
return [] if subject.consistent?
|
|
154
|
+
|
|
155
|
+
[(" " * title_column) + UI.paint("↳ #{subject.violation}", :red)]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [String]
|
|
159
|
+
def footer
|
|
160
|
+
counts = totals.map { |key, n| "#{STATUS_BY_KEY.fetch(key).emoji} #{n}" }.join(" ")
|
|
161
|
+
lines = [" #{tree.subjects.size} #{(tree.subjects.size == 1) ? "plan" : "plans"} #{counts}"]
|
|
162
|
+
|
|
163
|
+
unless inconsistent.empty?
|
|
164
|
+
lines << " #{UI.paint("#{inconsistent.size} with a status their contents do not justify", :red)}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
tree.duplicates.each do |ordinal, dirnames|
|
|
168
|
+
lines << " #{UI.paint("#{ordinal} is claimed by #{dirnames.size} folders — a number is an identity:", :red)}"
|
|
169
|
+
dirnames.each { |d| lines << " #{UI.paint(d, :bright_black)}" }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
lines.join("\n")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# Reconciling what is recorded with what is true.
|
|
5
|
+
#
|
|
6
|
+
# Both resyncs are dry-run by default and both refuse to act where they are
|
|
7
|
+
# not certain, because both write to things other people join on: folder
|
|
8
|
+
# names, and pull request titles.
|
|
9
|
+
module Resync
|
|
10
|
+
# `resync dirs` — makes every folder's name say what the folder is.
|
|
11
|
+
#
|
|
12
|
+
# Two things can be wrong with a name, and both are repaired here:
|
|
13
|
+
#
|
|
14
|
+
# - **The emoji is wrong** — the contents justify a different state. A
|
|
15
|
+
# folder whose status already holds is left alone, which is what stops
|
|
16
|
+
# ⭕️ Blocked and 🅱️ Product Blocked — deliberately identical invariants,
|
|
17
|
+
# distinguished only by the name — from collapsing into one.
|
|
18
|
+
# - **The number is not in `NNN.MM` form** — a folder written `018-⚪️--foo`
|
|
19
|
+
# before the padding rule, or `18.1-⚪️--foo` by hand. These read fine and
|
|
20
|
+
# sort wrong, which is the whole reason the rule exists: `018.09` <
|
|
21
|
+
# `018.1` < `018.10` puts a single mixed-width folder in the middle of
|
|
22
|
+
# the range. Both are renamed to `018.00-⚪️--foo` and `018.01-⚪️--foo`.
|
|
23
|
+
#
|
|
24
|
+
# The two cases collapse into one rule — **rename any folder that is not
|
|
25
|
+
# already named what it should be named** — which is why the emoji fix and
|
|
26
|
+
# the renumber cannot disagree about the target.
|
|
27
|
+
class Dirs
|
|
28
|
+
# A proposed rename.
|
|
29
|
+
#
|
|
30
|
+
# @!attribute [r] dirname
|
|
31
|
+
# @return [String] the folder as it stands
|
|
32
|
+
# @!attribute [r] from
|
|
33
|
+
# @return [Symbol] the state it claims
|
|
34
|
+
# @!attribute [r] to
|
|
35
|
+
# @return [Symbol] the state its contents justify
|
|
36
|
+
# @!attribute [r] source
|
|
37
|
+
# @return [String] absolute path now
|
|
38
|
+
# @!attribute [r] target
|
|
39
|
+
# @return [String] absolute path after
|
|
40
|
+
# @!attribute [r] reason
|
|
41
|
+
# @return [String] why the current name is wrong
|
|
42
|
+
Change = Data.define(:dirname, :from, :to, :source, :target, :reason) do
|
|
43
|
+
# @return [String] a single auditable line
|
|
44
|
+
def to_s = "#{dirname} → #{File.basename(target)} (#{reason})"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param tree [Agentilda::Tree]
|
|
48
|
+
def initialize(tree:)
|
|
49
|
+
@tree = tree
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Agentilda::Tree]
|
|
53
|
+
attr_reader :tree
|
|
54
|
+
|
|
55
|
+
# What would change, without changing anything.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Agentilda::Resync::Dirs::Change>]
|
|
58
|
+
def plan = tree.subjects.filter_map { |subject| change_for(subject) }
|
|
59
|
+
|
|
60
|
+
# @param commit [Boolean] actually rename
|
|
61
|
+
# @return [Array<Agentilda::Resync::Dirs::Change>] what was proposed
|
|
62
|
+
def call(commit: false)
|
|
63
|
+
changes = plan
|
|
64
|
+
return changes unless commit
|
|
65
|
+
|
|
66
|
+
UI.stepping(changes, "Renaming") { |change| rename(change) }
|
|
67
|
+
tree.reload
|
|
68
|
+
changes
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# A folder moves when the name it has is not the name it should have.
|
|
74
|
+
#
|
|
75
|
+
# `best_fit` answers "which state do these contents justify", and falls
|
|
76
|
+
# back to the state already claimed when nothing fits — so a folder with
|
|
77
|
+
# an unreadable set of contents still gets its number padded rather than
|
|
78
|
+
# being skipped for a reason that has nothing to do with its number.
|
|
79
|
+
#
|
|
80
|
+
# Invariants are minimum requirements, so a ⚪️ folder that has grown a
|
|
81
|
+
# `plan.md` still satisfies ⚪️ and is ⭐️ anyway.
|
|
82
|
+
#
|
|
83
|
+
# @param subject [Agentilda::Subject]
|
|
84
|
+
# @return [Agentilda::Resync::Dirs::Change, nil]
|
|
85
|
+
def change_for(subject)
|
|
86
|
+
feature = subject.feature
|
|
87
|
+
fit = subject.best_fit || subject.status
|
|
88
|
+
dirname = feature.dirname_as(fit)
|
|
89
|
+
return nil if dirname == feature.dirname
|
|
90
|
+
|
|
91
|
+
Change.new(
|
|
92
|
+
dirname: feature.dirname,
|
|
93
|
+
from: subject.status.key,
|
|
94
|
+
to: fit.key,
|
|
95
|
+
source: feature.path,
|
|
96
|
+
target: File.join(File.dirname(feature.path), dirname),
|
|
97
|
+
reason: reason_for(subject, fit)
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param subject [Agentilda::Subject]
|
|
102
|
+
# @param fit [Agentilda::Status] the state the folder is moving to
|
|
103
|
+
# @return [String] why the current name is wrong
|
|
104
|
+
def reason_for(subject, fit)
|
|
105
|
+
feature = subject.feature
|
|
106
|
+
return subject.violation || "contents now justify #{fit.label}" unless fit.key == subject.status.key
|
|
107
|
+
return "#{feature.dirname_ordinal} is not padded to #{feature.ordinal}" unless feature.padded?
|
|
108
|
+
|
|
109
|
+
"name is not in canonical NNN.MM-<emoji>--<slug> form"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A rename that finds its target occupied has not happened, and saying
|
|
113
|
+
# nothing about it would leave the folder misnamed with a report
|
|
114
|
+
# claiming otherwise. Two folders can want the same canonical name —
|
|
115
|
+
# `018-⚪️--foo` and `018.00-⚪️--foo` are the same plan written twice.
|
|
116
|
+
#
|
|
117
|
+
# @param change [Agentilda::Resync::Dirs::Change]
|
|
118
|
+
# @return [void]
|
|
119
|
+
# @raise [Agentilda::Error] when the target name is already taken
|
|
120
|
+
def rename(change)
|
|
121
|
+
return if Agentilda.move_directory(change.source, change.target)
|
|
122
|
+
|
|
123
|
+
raise Error, "cannot rename #{change.dirname} — #{File.basename(change.target)} already exists"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# `resync prs` — puts an `[NNN.MM]` prefix on every pull request title that
|
|
128
|
+
# lacks one, and `[DEV.00]` on the ones that implement no plan.
|
|
129
|
+
#
|
|
130
|
+
# The resolution order is the branch name first, then the diff, and only
|
|
131
|
+
# when the diff touches exactly one plan. Anything else is reported for a
|
|
132
|
+
# human and never edited: `pull-requests.md` is generated from these
|
|
133
|
+
# titles, and a wrong number files work under a plan that did not do it,
|
|
134
|
+
# leaving the plan that did looking untouched.
|
|
135
|
+
class Prs
|
|
136
|
+
# The marker this tool used to write, now replaced. A title wearing it is
|
|
137
|
+
# rewritten in place: the assertion it makes — "this belongs to no
|
|
138
|
+
# specification" — is still true and still somebody's deliberate call,
|
|
139
|
+
# so it is restamped rather than re-resolved.
|
|
140
|
+
STALE = /\A\[#{Regexp.escape(Agentilda::STALE_NO_PLAN_PREFIX)}\]\s*/
|
|
141
|
+
|
|
142
|
+
# Titles that already carry a prefix — a plan number, the no-plan marker,
|
|
143
|
+
# or the legacy `[XXX]`.
|
|
144
|
+
PREFIXED = /\A\[(?:\d{3}(?:\.\d{2})?|#{Agentilda::NO_PLAN_PREFIX}|XXX)\](?:\([A-Z]\))?\s/
|
|
145
|
+
|
|
146
|
+
# Finds a plan number in a branch name: `kig/018.01-verify`, `002-slug`.
|
|
147
|
+
BRANCH_PATTERN = %r{(?:\A|[/\-_])(\d{3}(?:\.\d{2})?)(?:\z|[-_])}
|
|
148
|
+
|
|
149
|
+
# A proposed retitle.
|
|
150
|
+
#
|
|
151
|
+
# @!attribute [r] number
|
|
152
|
+
# @return [Integer] the pull request
|
|
153
|
+
# @!attribute [r] title
|
|
154
|
+
# @return [String] as it stands
|
|
155
|
+
# @!attribute [r] new_title
|
|
156
|
+
# @return [String, nil] nil when nothing may safely be done
|
|
157
|
+
# @!attribute [r] ordinal
|
|
158
|
+
# @return [Agentilda::Ordinal, nil]
|
|
159
|
+
# @!attribute [r] reason
|
|
160
|
+
# @return [String] how it was resolved, or why it was not
|
|
161
|
+
# @!attribute [r] ambiguous
|
|
162
|
+
# @return [Boolean] a human must decide; never edited
|
|
163
|
+
# @!attribute [r] assumed
|
|
164
|
+
# @return [Boolean] "no plan" was inferred, not asserted by the author
|
|
165
|
+
Change = Data.define(:number, :title, :new_title, :ordinal, :reason, :ambiguous, :assumed, :adopted) do
|
|
166
|
+
# @return [Boolean]
|
|
167
|
+
def ambiguous? = ambiguous
|
|
168
|
+
|
|
169
|
+
# @return [Boolean]
|
|
170
|
+
def assumed? = assumed
|
|
171
|
+
|
|
172
|
+
# @return [Boolean] whether a plan folder was minted for this one
|
|
173
|
+
def adopted? = adopted
|
|
174
|
+
|
|
175
|
+
# @return [Boolean] safe to apply without a human looking
|
|
176
|
+
def applicable? = !ambiguous && !new_title.nil?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @param tree [Agentilda::Tree]
|
|
180
|
+
# @param github [Agentilda::GitHub]
|
|
181
|
+
# @param adopt [Boolean] give a plan folder to every pull request that
|
|
182
|
+
# resolves to none, rather than flagging it and moving on
|
|
183
|
+
# @param root [String, nil] repository root, for reading branches
|
|
184
|
+
def initialize(tree:, github: GitHub.new, adopt: true, root: nil)
|
|
185
|
+
@tree = tree
|
|
186
|
+
@github = github
|
|
187
|
+
@adopt = adopt
|
|
188
|
+
@root = root
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @return [Boolean]
|
|
192
|
+
def adopt? = @adopt
|
|
193
|
+
|
|
194
|
+
# @return [Agentilda::Tree]
|
|
195
|
+
attr_reader :tree
|
|
196
|
+
|
|
197
|
+
# @return [Agentilda::GitHub]
|
|
198
|
+
attr_reader :github
|
|
199
|
+
|
|
200
|
+
# What would change, without changing anything.
|
|
201
|
+
#
|
|
202
|
+
# @return [Array<Agentilda::Resync::Prs::Change>]
|
|
203
|
+
def plan
|
|
204
|
+
restamps + resolve(candidates).then { |changes| adopt? ? with_adoptions(changes) : changes }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @return [Array<Agentilda::Resync::Prs::Change>]
|
|
208
|
+
def restamps = stale.map { |pull| restamped(pull) }
|
|
209
|
+
|
|
210
|
+
# @param commit [Boolean] actually retitle, and mint the folders
|
|
211
|
+
# @return [Array<Agentilda::Resync::Prs::Change>] what was proposed
|
|
212
|
+
def call(commit: false)
|
|
213
|
+
changes = resolve(candidates)
|
|
214
|
+
return restamps + (adopt? ? with_adoptions(changes) : changes) unless commit
|
|
215
|
+
|
|
216
|
+
changes = with_adoptions(changes, create: true) if adopt?
|
|
217
|
+
changes = restamps + changes
|
|
218
|
+
applicable = changes.select(&:applicable?)
|
|
219
|
+
UI.stepping(applicable, "Retitling") { |c| github.retitle(number: c.number, title: c.new_title) }
|
|
220
|
+
changes
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @return [Array<Agentilda::Adoption>] the adopter, memoized
|
|
224
|
+
def adoption = @adoption ||= Adoption.new(tree:, github:, root: @root)
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# @return [Array<Hash>] pull requests with no prefix yet
|
|
229
|
+
def candidates
|
|
230
|
+
github.pulls.reject { |pr| pr[:title].to_s.match?(PREFIXED) || pr[:title].to_s.match?(STALE) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# @return [Array<Hash>] pull requests still wearing the old marker
|
|
234
|
+
def stale = github.pulls.select { |pr| pr[:title].to_s.match?(STALE) }
|
|
235
|
+
|
|
236
|
+
# @param pull [Hash]
|
|
237
|
+
# @return [Agentilda::Resync::Prs::Change]
|
|
238
|
+
def restamped(pull)
|
|
239
|
+
Change.new(number: pull[:number], title: pull[:title], ordinal: nil, ambiguous: false,
|
|
240
|
+
new_title: pull[:title].sub(STALE, "[#{Agentilda::NO_PLAN_PREFIX}] "),
|
|
241
|
+
reason: "the no-plan marker is now [#{Agentilda::NO_PLAN_PREFIX}]",
|
|
242
|
+
assumed: false, adopted: false)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @param pulls [Array<Hash>]
|
|
246
|
+
# @return [Array<Agentilda::Resync::Prs::Change>]
|
|
247
|
+
def resolve(pulls) = pulls.map { |pr| change_for(pr) }
|
|
248
|
+
|
|
249
|
+
# Replace every flagged change with one that points at a plan folder the
|
|
250
|
+
# pull request now owns. The unresolvable ones were unresolvable because
|
|
251
|
+
# no plan described them — so the answer is a plan, not a shrug.
|
|
252
|
+
#
|
|
253
|
+
# @param changes [Array<Agentilda::Resync::Prs::Change>]
|
|
254
|
+
# @param create [Boolean] mint the folders, rather than only saying so
|
|
255
|
+
# @return [Array<Agentilda::Resync::Prs::Change>]
|
|
256
|
+
def with_adoptions(changes, create: false)
|
|
257
|
+
orphans = changes.select(&:ambiguous?)
|
|
258
|
+
return changes if orphans.empty?
|
|
259
|
+
|
|
260
|
+
pulls = orphans.map { |c| pull_by_number.fetch(c.number) }
|
|
261
|
+
adoptees = create ? adoption.call(pulls) : adoption.plan(pulls)
|
|
262
|
+
by_number = adoptees.to_h { |a| [a.pull[:number], a] }
|
|
263
|
+
|
|
264
|
+
changes.map { |c| (c.ambiguous? && by_number[c.number]) ? adopted(c, by_number[c.number]) : c }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# @return [Hash{Integer => Hash}]
|
|
268
|
+
def pull_by_number = @pull_by_number ||= candidates.to_h { |pr| [pr[:number], pr] }
|
|
269
|
+
|
|
270
|
+
# @param change [Agentilda::Resync::Prs::Change]
|
|
271
|
+
# @param adoptee [Agentilda::Adoption::Adoptee]
|
|
272
|
+
# @return [Agentilda::Resync::Prs::Change]
|
|
273
|
+
def adopted(change, adoptee)
|
|
274
|
+
change.with(
|
|
275
|
+
ordinal: adoptee.ordinal,
|
|
276
|
+
new_title: "#{adoptee.ordinal.to_prefix} #{change.title}",
|
|
277
|
+
reason: "#{change.reason} — adopted into #{adoptee.dirname}",
|
|
278
|
+
ambiguous: false,
|
|
279
|
+
adopted: true
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @param pull [Hash]
|
|
284
|
+
# @return [Agentilda::Resync::Prs::Change]
|
|
285
|
+
def change_for(pull)
|
|
286
|
+
from_branch(pull) || from_files(pull) || no_plan(pull)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# 1. The branch name — the one moment the author certainly knew.
|
|
290
|
+
#
|
|
291
|
+
# @param pull [Hash]
|
|
292
|
+
# @return [Agentilda::Resync::Prs::Change, nil]
|
|
293
|
+
def from_branch(pull)
|
|
294
|
+
match = BRANCH_PATTERN.match(pull[:branch].to_s) or return nil
|
|
295
|
+
ordinal = Ordinal.parse(match[1])
|
|
296
|
+
|
|
297
|
+
unless tree.include?(ordinal)
|
|
298
|
+
return flag(pull, "branch names #{ordinal}, which has no folder in #{File.basename(tree.dir)}")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
resolved(pull, ordinal, "branch #{pull[:branch]}")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# 2. The diff, and only when it touches exactly one plan.
|
|
305
|
+
#
|
|
306
|
+
# @param pull [Hash]
|
|
307
|
+
# @return [Agentilda::Resync::Prs::Change, nil]
|
|
308
|
+
def from_files(pull)
|
|
309
|
+
touched = Array(pull[:files]).filter_map { |path| ordinal_for_path(path) }.uniq
|
|
310
|
+
case touched.size
|
|
311
|
+
when 1 then resolved(pull, touched.first, "diff touches only #{touched.first}")
|
|
312
|
+
when 0 then nil
|
|
313
|
+
else flag(pull, "diff touches #{touched.join(", ")} and none is obviously primary")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# @param path [String] a path from the diff
|
|
318
|
+
# @return [Agentilda::Ordinal, nil]
|
|
319
|
+
def ordinal_for_path(path)
|
|
320
|
+
parts = path.to_s.split("/")
|
|
321
|
+
index = parts.index(Agentilda::PLANS_DIR) or return nil
|
|
322
|
+
folder = parts[index + 1] or return nil
|
|
323
|
+
return nil if parts.length <= index + 2 # a file directly under .plans belongs to no plan
|
|
324
|
+
|
|
325
|
+
ordinal = Ordinal.from_dirname(folder)
|
|
326
|
+
ordinal if ordinal && tree.include?(ordinal)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# @param pull [Hash]
|
|
330
|
+
# @param ordinal [Agentilda::Ordinal]
|
|
331
|
+
# @param why [String]
|
|
332
|
+
# @return [Agentilda::Resync::Prs::Change]
|
|
333
|
+
def resolved(pull, ordinal, why)
|
|
334
|
+
Change.new(number: pull[:number], title: pull[:title], ordinal:, reason: why,
|
|
335
|
+
new_title: "#{ordinal.to_prefix} #{pull[:title]}", ambiguous: false, assumed: false, adopted: false)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Nothing resolved, so this is developer work — but that is an assertion
|
|
339
|
+
# about intent, so it is marked as assumed rather than stated as fact.
|
|
340
|
+
#
|
|
341
|
+
# @param pull [Hash]
|
|
342
|
+
# @return [Agentilda::Resync::Prs::Change]
|
|
343
|
+
def no_plan(pull)
|
|
344
|
+
Change.new(number: pull[:number], title: pull[:title], ordinal: nil,
|
|
345
|
+
new_title: "[#{Agentilda::NO_PLAN_PREFIX}] #{pull[:title]}",
|
|
346
|
+
reason: "no plan resolved from the branch or the diff", ambiguous: false, assumed: true, adopted: false)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# @param pull [Hash]
|
|
350
|
+
# @param why [String]
|
|
351
|
+
# @return [Agentilda::Resync::Prs::Change]
|
|
352
|
+
def flag(pull, why)
|
|
353
|
+
Change.new(number: pull[:number], title: pull[:title], new_title: nil, ordinal: nil,
|
|
354
|
+
reason: why, ambiguous: true, assumed: false, adopted: false)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# `agentilda agents` — who the specialists are and what each is offered
|
|
5
|
+
# work from. Reading one in full is {Viewer}'s job: the definition file is
|
|
6
|
+
# already the whole truth, so `agents describe` opens it rather than
|
|
7
|
+
# restating it.
|
|
8
|
+
#
|
|
9
|
+
# {#list} returns a string and prints nothing, the same bargain {Reporter}
|
|
10
|
+
# makes, so the caller decides where the text goes and the table stays
|
|
11
|
+
# pipeable.
|
|
12
|
+
#
|
|
13
|
+
# Everything here is read from the definition files. Nothing about a
|
|
14
|
+
# specialty is restated in Ruby, because a second copy of "who does what" is
|
|
15
|
+
# a copy that drifts from the frontmatter the loop actually routes on.
|
|
16
|
+
class Roster
|
|
17
|
+
# Columns, so the header and the rows cannot drift apart.
|
|
18
|
+
HEADINGS = ["Agent", "Handles", "Advances to", "Model"].freeze
|
|
19
|
+
|
|
20
|
+
# What an agent with no `advances_to` is, in the one word that explains why
|
|
21
|
+
# `run` never offers it work.
|
|
22
|
+
READ_ONLY = "read-only"
|
|
23
|
+
|
|
24
|
+
# @param agents [Agentilda::Agents]
|
|
25
|
+
def initialize(agents: Agents.new)
|
|
26
|
+
@agents = agents
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Agentilda::Agents]
|
|
30
|
+
attr_reader :agents
|
|
31
|
+
|
|
32
|
+
# One line per agent, in name order.
|
|
33
|
+
#
|
|
34
|
+
# @return [String] newline-terminated
|
|
35
|
+
def list
|
|
36
|
+
all = agents.all
|
|
37
|
+
return "No agent definitions in #{agents.dir}\n" if all.empty?
|
|
38
|
+
|
|
39
|
+
[header(all), *all.map { |agent| row(agent, all) }].join("\n") + "\n"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# A state as the folder names write it, so what an agent handles reads the
|
|
45
|
+
# same here as it does in `status` and on disk.
|
|
46
|
+
#
|
|
47
|
+
# @param key [Symbol]
|
|
48
|
+
# @return [String]
|
|
49
|
+
def state(key)
|
|
50
|
+
status = STATUS_BY_KEY[key] or return key.to_s
|
|
51
|
+
"#{status.emoji} #{status.label}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param agent [Agentilda::Agent]
|
|
55
|
+
# @return [String]
|
|
56
|
+
def handles(agent) = agent.handles.map { |key| state(key) }.join(", ")
|
|
57
|
+
|
|
58
|
+
# @param agent [Agentilda::Agent]
|
|
59
|
+
# @return [String]
|
|
60
|
+
def advances(agent) = agent.advances_to ? state(agent.advances_to) : READ_ONLY
|
|
61
|
+
|
|
62
|
+
# @param all [Array<Agentilda::Agent>]
|
|
63
|
+
# @return [Integer]
|
|
64
|
+
def name_width(all) = @name_width ||= all.map { |a| UI.display_width(a.name) }.max.to_i + 2
|
|
65
|
+
|
|
66
|
+
# @param all [Array<Agentilda::Agent>]
|
|
67
|
+
# @return [Integer]
|
|
68
|
+
def handles_width(all)
|
|
69
|
+
@handles_width ||= (all.map { |a| UI.display_width(handles(a)) } + [UI.display_width(HEADINGS[1])]).max.to_i + 2
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param all [Array<Agentilda::Agent>]
|
|
73
|
+
# @return [Integer]
|
|
74
|
+
def advances_width(all)
|
|
75
|
+
@advances_width ||= (all.map { |a| UI.display_width(advances(a)) } + [UI.display_width(HEADINGS[2])]).max.to_i + 2
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Cells arrive fitted and then painted, in that order: escape codes count
|
|
79
|
+
# toward a string's length, so padding a coloured string pads it to a width
|
|
80
|
+
# of which several characters are invisible. See {Reporter#row}.
|
|
81
|
+
#
|
|
82
|
+
# @return [String]
|
|
83
|
+
def line(name, handled, advances, model)
|
|
84
|
+
format(" %s %s %s %s", name, handled, advances, model).rstrip
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param all [Array<Agentilda::Agent>]
|
|
88
|
+
# @return [String]
|
|
89
|
+
def header(all)
|
|
90
|
+
text = line(UI.fit(HEADINGS[0], name_width(all)),
|
|
91
|
+
UI.fit(HEADINGS[1], handles_width(all)),
|
|
92
|
+
UI.fit(HEADINGS[2], advances_width(all)),
|
|
93
|
+
HEADINGS[3])
|
|
94
|
+
|
|
95
|
+
rule = " " + ("─" * (UI.display_width(text) - 2))
|
|
96
|
+
|
|
97
|
+
[UI.paint(text, :bold), UI.paint(rule, :bright_yellow)].join("\n")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param agent [Agentilda::Agent]
|
|
101
|
+
# @param all [Array<Agentilda::Agent>]
|
|
102
|
+
# @return [String]
|
|
103
|
+
def row(agent, all)
|
|
104
|
+
line(UI.paint(UI.fit(agent.name, name_width(all)), :bright_cyan),
|
|
105
|
+
UI.fit(handles(agent), handles_width(all)),
|
|
106
|
+
UI.paint(UI.fit(advances(agent), advances_width(all)), agent.read_only? ? :bright_black : :green),
|
|
107
|
+
UI.paint(agent.model.to_s, :bright_black))
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|