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,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# The smallest Markdown reader that covers what plan folders actually hold.
|
|
5
|
+
# No CommonMark ambitions: it needs to find a GFM table and split its rows.
|
|
6
|
+
module Markdown
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Split a table row into stripped cells.
|
|
10
|
+
#
|
|
11
|
+
# @param row [String] a line beginning, and usually ending, with `|`
|
|
12
|
+
# @return [Array<String>]
|
|
13
|
+
def cells(row)
|
|
14
|
+
row.strip.sub(/\A\|/, "").sub(/\|\z/, "").split(/(?<!\\)\|/).map(&:strip)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param row [String]
|
|
18
|
+
# @return [Boolean] whether this is a `|---|:--:|` alignment row
|
|
19
|
+
def delimiter_row?(row)
|
|
20
|
+
parsed = cells(row)
|
|
21
|
+
!parsed.empty? && parsed.all? { |c| c.match?(/\A:?-+:?\z/) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every GFM table in the document, in order.
|
|
25
|
+
#
|
|
26
|
+
# @param text [String]
|
|
27
|
+
# @return [Array<Hash{Symbol => Array}>] `{header:, rows:}`
|
|
28
|
+
def tables(text)
|
|
29
|
+
lines = text.to_s.lines(chomp: true)
|
|
30
|
+
found = []
|
|
31
|
+
index = 0
|
|
32
|
+
|
|
33
|
+
while index < lines.length
|
|
34
|
+
head = lines[index]
|
|
35
|
+
rule = lines[index + 1]
|
|
36
|
+
|
|
37
|
+
unless head&.strip&.start_with?("|") && rule&.strip&.start_with?("|") && delimiter_row?(rule)
|
|
38
|
+
index += 1
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
body = []
|
|
43
|
+
cursor = index + 2
|
|
44
|
+
while cursor < lines.length && lines[cursor].strip.start_with?("|")
|
|
45
|
+
body << cells(lines[cursor])
|
|
46
|
+
cursor += 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
found << {header: cells(head), rows: body}
|
|
50
|
+
index = cursor
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
found
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# A plan's number, and therefore its identity. Set once when the folder is
|
|
5
|
+
# created and never changed: branch names, pull request titles and every
|
|
6
|
+
# `pull-requests.md` join on it, and renumbering breaks all of them silently.
|
|
7
|
+
#
|
|
8
|
+
# Always rendered `NNN.MM`. `002.00` is an ordinary plan, specified before it
|
|
9
|
+
# was built. `002.01` is retroactive — work that shipped between 002 and 003
|
|
10
|
+
# and was documented afterwards. The decimal marks a **sibling**, not
|
|
11
|
+
# containment: 002.01 is not part of 002.
|
|
12
|
+
#
|
|
13
|
+
# `000` is where the sequence starts, so the first plan of a project is
|
|
14
|
+
# `000.00`. It also absorbs the older meaning — work that predates the plan
|
|
15
|
+
# discipline — since either way it sorts first, which is where it belongs.
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [r] major
|
|
18
|
+
# @return [Integer] 0..999
|
|
19
|
+
# @!attribute [r] minor
|
|
20
|
+
# @return [Integer] 0..99; zero for an ordinary plan
|
|
21
|
+
class Ordinal < Data.define(:major, :minor)
|
|
22
|
+
include Comparable
|
|
23
|
+
|
|
24
|
+
# The canonical form, plus the bare `NNN` that trees written before this
|
|
25
|
+
# rule still use and must remain readable.
|
|
26
|
+
PATTERN = /\A(\d{1,3})(?:\.(\d{1,2}))?\z/
|
|
27
|
+
|
|
28
|
+
# The most retroactive slots a single gap can hold.
|
|
29
|
+
MAX_MINOR = 99
|
|
30
|
+
|
|
31
|
+
# @param text [String, nil] e.g. "3", "003", "003.00", "018.01"
|
|
32
|
+
# @return [Agentilda::Ordinal, nil] nil when it is not a plan number
|
|
33
|
+
def self.parse(text)
|
|
34
|
+
m = PATTERN.match(text.to_s.strip)
|
|
35
|
+
m && new(major: m[1].to_i, minor: m[2].to_i)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Pull the number off the front of a folder name.
|
|
39
|
+
#
|
|
40
|
+
# @param dirname [String] e.g. "018.01-✅--verify-against-filed-returns"
|
|
41
|
+
# @return [Agentilda::Ordinal, nil]
|
|
42
|
+
def self.from_dirname(dirname) = parse(dirname.to_s[/\A[\d.]+/])
|
|
43
|
+
|
|
44
|
+
# The next ordinary plan after everything in +existing+.
|
|
45
|
+
#
|
|
46
|
+
# The first plan in an empty tree is `000.00`, not `001.00`: the sequence
|
|
47
|
+
# counts from zero so that the very first specification — usually the
|
|
48
|
+
# project's own — sorts above everything and needs no gap reserved for it.
|
|
49
|
+
#
|
|
50
|
+
# @param existing [Array<Agentilda::Ordinal>]
|
|
51
|
+
# @return [Agentilda::Ordinal]
|
|
52
|
+
def self.next_major(existing)
|
|
53
|
+
return new(major: 0, minor: 0) if existing.empty?
|
|
54
|
+
|
|
55
|
+
new(major: existing.map(&:major).max + 1, minor: 0)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The next retroactive slot in the gap after +major+.
|
|
59
|
+
#
|
|
60
|
+
# @param existing [Array<Agentilda::Ordinal>]
|
|
61
|
+
# @param major [Integer] the plan the work landed after
|
|
62
|
+
# @return [Agentilda::Ordinal]
|
|
63
|
+
# @raise [Agentilda::Error] when the gap is full
|
|
64
|
+
def self.next_minor(existing, major:)
|
|
65
|
+
taken = existing.select { |o| o.major == major }.map(&:minor).max || 0
|
|
66
|
+
if taken >= MAX_MINOR
|
|
67
|
+
raise Error, "all #{MAX_MINOR} retroactive slots after #{format("%03d", major)} are taken"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
new(major:, minor: taken + 1)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean] whether this plan was documented after the fact
|
|
74
|
+
def retroactive? = minor.positive?
|
|
75
|
+
|
|
76
|
+
# @return [String] the canonical `NNN.MM`
|
|
77
|
+
def to_s = format("%03d.%02d", major, minor)
|
|
78
|
+
|
|
79
|
+
# @return [String] what a pull request title carries
|
|
80
|
+
def to_prefix = "[#{self}]"
|
|
81
|
+
|
|
82
|
+
# @param other [Object]
|
|
83
|
+
# @return [Integer, nil]
|
|
84
|
+
def <=>(other)
|
|
85
|
+
return nil unless other.is_a?(self.class)
|
|
86
|
+
|
|
87
|
+
[major, minor] <=> [other.major, other.minor]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# One line of `run --log FILE`, laid out as fixed-width columns.
|
|
5
|
+
#
|
|
6
|
+
# The log file is the only record of a headless run, and it is shared: two
|
|
7
|
+
# `agentilda run` invocations pointed at the same `--log` both append to
|
|
8
|
+
# it, interleaved, and so does every round within one run. That is what the
|
|
9
|
+
# process id column is for. Without it a reader cannot tell one run's agents
|
|
10
|
+
# from another's, and "started 003.00" twice looks like a bug rather than two
|
|
11
|
+
# machines doing the same work.
|
|
12
|
+
#
|
|
13
|
+
# Fixed columns are what make the file readable by eye and by `awk` alike. A
|
|
14
|
+
# value wider than its column is cut rather than allowed to push the columns
|
|
15
|
+
# out, and a value that is missing still occupies its width, so a round header
|
|
16
|
+
# with no plan or agent of its own lines up with the lines underneath it.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# ProgressLog.render("editing spec.md", plan: "003.00", status: "⭐️ Planned",
|
|
20
|
+
# agent: "yoda-writer", seconds: 42, round: "01", pid: 91_234)
|
|
21
|
+
# #=> "[16:22:14 | 003.00 | ⭐️ Planned | yoda-writer | 01 | 91234 | 42s] editing spec.md"
|
|
22
|
+
module ProgressLog
|
|
23
|
+
# Wall clock only. A log line answers "when", and the date is the file's.
|
|
24
|
+
TIME_FORMAT = "%H:%M:%S"
|
|
25
|
+
|
|
26
|
+
# Width of {TIME_FORMAT}'s output.
|
|
27
|
+
TIME_WIDTH = 8
|
|
28
|
+
|
|
29
|
+
# "003.00".
|
|
30
|
+
PLAN_WIDTH = 6
|
|
31
|
+
|
|
32
|
+
# Derived from the widest state as {Status#to_s} renders it, emoji
|
|
33
|
+
# included, so a state added to {STATUSES} with a longer label cannot
|
|
34
|
+
# silently knock the columns after it out of line — then trimmed by five
|
|
35
|
+
# cells: the emoji already names the state, so the longest labels can
|
|
36
|
+
# afford to lose their tails to keep the line short.
|
|
37
|
+
STATUS_WIDTH = STATUSES.map { |status| UI.display_width(status.to_s) }.max - 5
|
|
38
|
+
|
|
39
|
+
# The longest specialist name in `agents/` is `palpatine-planner` at 17,
|
|
40
|
+
# and the names are hyphenated words rather than a bounded vocabulary, so
|
|
41
|
+
# this leaves room for one more without a reflow.
|
|
42
|
+
AGENT_WIDTH = 20
|
|
43
|
+
|
|
44
|
+
# Enough for a 32-bit process id, right justified.
|
|
45
|
+
PID_WIDTH = 7
|
|
46
|
+
|
|
47
|
+
# "01". Rounds cap at two digits; a run that reaches a third has bigger
|
|
48
|
+
# problems than this column.
|
|
49
|
+
ROUND_WIDTH = 2
|
|
50
|
+
|
|
51
|
+
# " 907s". Anything longer than four digits of seconds is an agent nobody
|
|
52
|
+
# is still waiting on.
|
|
53
|
+
SECONDS_WIDTH = 6
|
|
54
|
+
|
|
55
|
+
# Between columns, inside the brackets.
|
|
56
|
+
SEPARATOR = " | "
|
|
57
|
+
|
|
58
|
+
# Cells from the opening bracket to the closing one, separators included.
|
|
59
|
+
# The message starts one space after this on every line, whatever fields
|
|
60
|
+
# that line happens to be missing, so a reader indenting a wrapped message
|
|
61
|
+
# has a number to indent by.
|
|
62
|
+
COLUMN_WIDTHS = [TIME_WIDTH, PLAN_WIDTH, STATUS_WIDTH, AGENT_WIDTH, ROUND_WIDTH, PID_WIDTH,
|
|
63
|
+
SECONDS_WIDTH].freeze
|
|
64
|
+
|
|
65
|
+
# @see COLUMN_WIDTHS
|
|
66
|
+
LINE_WIDTH = COLUMN_WIDTHS.sum + (SEPARATOR.length * (COLUMN_WIDTHS.size - 1)) + 2
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# Render one log line: bracketed fixed-width columns, then the message.
|
|
70
|
+
#
|
|
71
|
+
# No colour. The line goes to a file, where an escape sequence is neither
|
|
72
|
+
# readable nor the width it claims to be.
|
|
73
|
+
#
|
|
74
|
+
# @param message [String] the free-form tail, the only variable-width part
|
|
75
|
+
# @param plan [String, nil] the plan's ordinal, e.g. "003.00"
|
|
76
|
+
# @param status [String, nil] emoji and label, e.g. "⭐️ Planned"
|
|
77
|
+
# @param agent [String, nil] the specialist's name, e.g. "yoda-writer"
|
|
78
|
+
# @param seconds [Numeric, nil] how long this agent has been alive
|
|
79
|
+
# @param round [String, nil] which pass over the tree, e.g. "01"
|
|
80
|
+
# @param pid [Integer, nil] which run wrote the line
|
|
81
|
+
# @param at [Time] injectable, so a caller can render a fixed clock
|
|
82
|
+
# @return [String] one line, with no trailing newline
|
|
83
|
+
def render(message, plan: nil, status: nil, agent: nil, seconds: nil,
|
|
84
|
+
round: nil, pid: Process.pid, at: Time.now)
|
|
85
|
+
columns = [
|
|
86
|
+
left(at.strftime(TIME_FORMAT), TIME_WIDTH),
|
|
87
|
+
left(plan, PLAN_WIDTH),
|
|
88
|
+
left(status, STATUS_WIDTH),
|
|
89
|
+
left(agent, AGENT_WIDTH),
|
|
90
|
+
right(round, ROUND_WIDTH),
|
|
91
|
+
right(pid, PID_WIDTH),
|
|
92
|
+
right(duration(seconds), SECONDS_WIDTH)
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
text = message.to_s
|
|
96
|
+
prefix = "[#{columns.join(SEPARATOR)}]"
|
|
97
|
+
text.empty? ? prefix : "#{prefix} #{text}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param seconds [Numeric, nil]
|
|
101
|
+
# @return [String] whole seconds with a trailing "s", or blank for nil
|
|
102
|
+
def duration(seconds) = seconds.nil? ? "" : "#{seconds.round}s"
|
|
103
|
+
|
|
104
|
+
# @param value [Object, nil]
|
|
105
|
+
# @param width [Integer] terminal cells
|
|
106
|
+
# @return [String] padded on the right to exactly +width+ cells
|
|
107
|
+
def left(value, width) = UI.fit(value, width)
|
|
108
|
+
|
|
109
|
+
# {UI.fit} pads on the right, which is the wrong end for a number. Fit
|
|
110
|
+
# first so an over-long value is still truncated to the column, then move
|
|
111
|
+
# the padding to the front.
|
|
112
|
+
#
|
|
113
|
+
# @param value [Object, nil]
|
|
114
|
+
# @param width [Integer] terminal cells
|
|
115
|
+
# @return [String] padded on the left to exactly +width+ cells
|
|
116
|
+
def right(value, width)
|
|
117
|
+
text = UI.fit(value, width).rstrip
|
|
118
|
+
UI.fit("", width - UI.display_width(text)) + text
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# Turns a finished worktree into a pushed branch and a pull request.
|
|
5
|
+
#
|
|
6
|
+
# This is the one place the harness reaches off the machine, so it requires
|
|
7
|
+
# `--commit`, is skipped with `--dont-push-anything`, and refuses anything
|
|
8
|
+
# it is not certain about. Everything it does is reported before it does it.
|
|
9
|
+
#
|
|
10
|
+
# Titles follow the convention the whole system joins on:
|
|
11
|
+
#
|
|
12
|
+
# [002.00](A) Tenancy Households — a plan's first pull request
|
|
13
|
+
# [002.00](B) Tenancy Households — its second
|
|
14
|
+
#
|
|
15
|
+
# The slug is title-cased from the folder name, so the title reads as a
|
|
16
|
+
# sentence rather than as a path fragment.
|
|
17
|
+
class Publisher
|
|
18
|
+
# A published, or publishable, pull request.
|
|
19
|
+
#
|
|
20
|
+
# @!attribute [r] ordinal
|
|
21
|
+
# @return [Agentilda::Ordinal]
|
|
22
|
+
# @!attribute [r] branch
|
|
23
|
+
# @return [String]
|
|
24
|
+
# @!attribute [r] title
|
|
25
|
+
# @return [String]
|
|
26
|
+
# @!attribute [r] url
|
|
27
|
+
# @return [String, nil] nil on a dry run or a refusal
|
|
28
|
+
# @!attribute [r] refusal
|
|
29
|
+
# @return [String, nil]
|
|
30
|
+
Publication = Data.define(:ordinal, :branch, :title, :url, :refusal) do
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def published? = !url.nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A..Z, which is more parts than any feature should be split into.
|
|
36
|
+
LETTERS = ("A".."Z").to_a.freeze
|
|
37
|
+
|
|
38
|
+
# @param root [String] repository the worktree belongs to
|
|
39
|
+
# @param command [TTY::Command]
|
|
40
|
+
# @param base [String] the branch pull requests target
|
|
41
|
+
# @param dry_run [Boolean]
|
|
42
|
+
def initialize(root:, command: TTY::Command.new(printer: :null), base: "main", dry_run: true)
|
|
43
|
+
@root = root
|
|
44
|
+
@command = command
|
|
45
|
+
@base = base
|
|
46
|
+
@dry_run = dry_run
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Push one plan's branch and open its pull request.
|
|
50
|
+
#
|
|
51
|
+
# @param checkout [Agentilda::Worktree::Checkout]
|
|
52
|
+
# @param subject [Agentilda::Subject]
|
|
53
|
+
# @param letter [String, nil] force a part letter; nil auto-assigns
|
|
54
|
+
# @return [Agentilda::Publisher::Publication]
|
|
55
|
+
def publish(checkout:, subject:, letter: nil)
|
|
56
|
+
title = title_for(subject, letter)
|
|
57
|
+
|
|
58
|
+
unless checkout.dirty?
|
|
59
|
+
return refuse(subject, checkout, title, "nothing to publish — the worktree is unchanged")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
return Publication.new(**base_fields(subject, checkout, title), url: nil, refusal: nil) if @dry_run
|
|
63
|
+
|
|
64
|
+
commit(checkout, title)
|
|
65
|
+
push(checkout)
|
|
66
|
+
url = create_pull_request(checkout, subject, title)
|
|
67
|
+
|
|
68
|
+
Publication.new(**base_fields(subject, checkout, title), url:, refusal: nil)
|
|
69
|
+
rescue TTY::Command::ExitError => e
|
|
70
|
+
refuse(subject, checkout, title, git_complaint(e))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# git and gh put the useful sentence in stderr, several lines below the
|
|
74
|
+
# command they echo. Reporting only the first line reports "it failed".
|
|
75
|
+
#
|
|
76
|
+
# @param error [TTY::Command::ExitError]
|
|
77
|
+
# @return [String]
|
|
78
|
+
def git_complaint(error)
|
|
79
|
+
lines = error.message.lines.map(&:strip).reject(&:empty?)
|
|
80
|
+
stderr = lines.find { |l| l.start_with?("stderr:") }
|
|
81
|
+
complaint = stderr&.delete_prefix("stderr:")&.strip
|
|
82
|
+
|
|
83
|
+
(complaint.to_s.empty? || complaint == "Nothing written") ? lines.first.to_s : complaint
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The title a plan's next pull request should carry.
|
|
87
|
+
#
|
|
88
|
+
# @param subject [Agentilda::Subject]
|
|
89
|
+
# @param letter [String, nil]
|
|
90
|
+
# @return [String]
|
|
91
|
+
def title_for(subject, letter = nil)
|
|
92
|
+
"[#{subject.feature.ordinal}](#{letter || auto_letter(subject)}) #{subject.feature.title}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Which part this is. Always a letter, starting at A: a plan that turns out
|
|
96
|
+
# to need only one pull request still reads consistently with every other,
|
|
97
|
+
# and nothing has to be renamed when a second arrives.
|
|
98
|
+
#
|
|
99
|
+
# @param subject [Agentilda::Subject]
|
|
100
|
+
# @return [String]
|
|
101
|
+
def auto_letter(subject) = LETTERS.fetch(subject.pull_requests.size, LETTERS.last)
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# @return [Hash]
|
|
106
|
+
def base_fields(subject, checkout, title)
|
|
107
|
+
{ordinal: subject.feature.ordinal, branch: checkout.branch, title:}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Agentilda::Publisher::Publication]
|
|
111
|
+
def refuse(subject, checkout, title, why)
|
|
112
|
+
Publication.new(**base_fields(subject, checkout, title), url: nil, refusal: why)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @param checkout [Agentilda::Worktree::Checkout]
|
|
116
|
+
# @param title [String]
|
|
117
|
+
# @return [void]
|
|
118
|
+
def commit(checkout, title)
|
|
119
|
+
run(checkout, "git", "add", "-A")
|
|
120
|
+
# The subject line drops the bracketed prefix: it belongs in the pull
|
|
121
|
+
# request title, where things join on it, not in every commit message.
|
|
122
|
+
run(checkout, "git", "commit", "-m", title.sub(/\A\[[^\]]+\](?:\([A-Z]\))?\s*/, ""))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param checkout [Agentilda::Worktree::Checkout]
|
|
126
|
+
# @return [void]
|
|
127
|
+
def push(checkout) = run(checkout, "git", "push", "-u", "origin", checkout.branch)
|
|
128
|
+
|
|
129
|
+
# @return [String, nil] the pull request URL
|
|
130
|
+
def create_pull_request(checkout, subject, title)
|
|
131
|
+
Tempfile.create(["pr", ".md"]) do |file|
|
|
132
|
+
file.write(description(subject, title))
|
|
133
|
+
file.flush
|
|
134
|
+
result = run(checkout, "gh", "pr", "create", "-a", "@me", "-B", @base, "-t", title, "-F", file.path)
|
|
135
|
+
return result.out.to_s[%r{https://\S+}]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The body `/create-pr` would have written: a title line, a summary lifted
|
|
140
|
+
# from the specification, and a link back to the plan folder so a reviewer
|
|
141
|
+
# can find the rest.
|
|
142
|
+
#
|
|
143
|
+
# @return [String]
|
|
144
|
+
def description(subject, title)
|
|
145
|
+
<<~MARKDOWN
|
|
146
|
+
# #{title}
|
|
147
|
+
|
|
148
|
+
## Summary
|
|
149
|
+
|
|
150
|
+
#{summary_text(subject)}
|
|
151
|
+
|
|
152
|
+
## Plan
|
|
153
|
+
|
|
154
|
+
Implements `#{subject.feature.dirname}`. The specification and the plan
|
|
155
|
+
for this work live in that folder.
|
|
156
|
+
MARKDOWN
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The specification's own Goal section, verbatim. A pull request body that
|
|
160
|
+
# paraphrases the spec is a second copy that drifts; quoting it is not.
|
|
161
|
+
#
|
|
162
|
+
# @param subject [Agentilda::Subject]
|
|
163
|
+
# @return [String]
|
|
164
|
+
def summary_text(subject)
|
|
165
|
+
text = subject.goal.join("\n\n")
|
|
166
|
+
text.empty? ? "See `#{subject.feature.dirname}/spec.md`." : text
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @return [TTY::Command::Result]
|
|
170
|
+
def run(checkout, *argv) = @command.run(*argv, chdir: checkout.path)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# One row of a plan's `pull-requests.md`.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] number
|
|
7
|
+
# @return [String, nil] as written, without the `#`
|
|
8
|
+
# @!attribute [r] title
|
|
9
|
+
# @return [String]
|
|
10
|
+
# @!attribute [r] url
|
|
11
|
+
# @return [String, nil]
|
|
12
|
+
# @!attribute [r] state
|
|
13
|
+
# @return [String] normalised words plus emoji, e.g. "Open 🟡"
|
|
14
|
+
PullRequest = Data.define(:number, :title, :url, :state) do
|
|
15
|
+
# Still awaiting a decision. A closed-unmerged pull request is finished
|
|
16
|
+
# business, so it is neither open nor merged.
|
|
17
|
+
#
|
|
18
|
+
# @return [Boolean]
|
|
19
|
+
def open? = state.match?(/\b(?:open|wip|draft)\b/i)
|
|
20
|
+
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
def merged? = state.match?(/\bmerged\b/i) && !state.match?(/\bunmerged\b/i)
|
|
23
|
+
|
|
24
|
+
# @return [String] "#92 — Send mail through Resend"
|
|
25
|
+
def label = number ? "##{number} — #{title}" : title
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Extracts the pull request roll-up from a plan folder.
|
|
29
|
+
class PullRequests
|
|
30
|
+
# A markdown link whose text may contain escaped brackets, because every
|
|
31
|
+
# title this tool writes now opens with `\[NNN.MM\]`. Matching `[^\]]+`
|
|
32
|
+
# stops at the escaped `]` and loses the URL with it.
|
|
33
|
+
LINK = /\[((?:\\.|[^\]\\])+)\]\((https?:[^)\s]+)\)/
|
|
34
|
+
|
|
35
|
+
# Canonical wording for the states we recognise, most specific first.
|
|
36
|
+
STATES = [
|
|
37
|
+
[/\bclosed\b|\bunmerged\b|\babandoned\b/i, "Closed 🔴"],
|
|
38
|
+
[/\bmerged\b/i, "Merged 🟣"],
|
|
39
|
+
[/\bdraft\b|\bwip\b/i, "WIP 🟡"],
|
|
40
|
+
[/\bopen\b/i, "Open 🟡"]
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# Filenames that may hold the table.
|
|
44
|
+
CANDIDATES = %w[pull-requests.md pull_requests.md prs.md].freeze
|
|
45
|
+
|
|
46
|
+
# The file this class writes, and the first one it looks for.
|
|
47
|
+
FILENAME = CANDIDATES.first
|
|
48
|
+
|
|
49
|
+
# Render `pull-requests.md` for a set of pull requests fetched from GitHub.
|
|
50
|
+
#
|
|
51
|
+
# The table is what {#parse} reads back and what the state machine judges
|
|
52
|
+
# a folder by. The descriptions below it are for a reader — human or
|
|
53
|
+
# agent — synthesizing a specification from work that already shipped:
|
|
54
|
+
# the table says *which* pull requests, the bodies say *what they did*,
|
|
55
|
+
# and a retroactive `spec.md` cannot be written from numbers alone.
|
|
56
|
+
#
|
|
57
|
+
# Prose after the table is ignored by the parser, so the two can coexist
|
|
58
|
+
# in one file rather than needing a scratch file the folder rules forbid.
|
|
59
|
+
#
|
|
60
|
+
# @param prs [Array<Hash>] `{number:, title:, url:, state:, body:}`
|
|
61
|
+
# @return [String]
|
|
62
|
+
def self.render(prs)
|
|
63
|
+
rows = prs.map { |pr|
|
|
64
|
+
"| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
<<~MARKDOWN
|
|
68
|
+
# Pull Requests
|
|
69
|
+
|
|
70
|
+
| Pull Request Number | Pull Request Name | Status |
|
|
71
|
+
| ------------------: | :---------------- | -----: |
|
|
72
|
+
#{rows.join("\n")}
|
|
73
|
+
|
|
74
|
+
## What these pull requests did
|
|
75
|
+
|
|
76
|
+
#{prs.map { |pr| describe(pr) }.join("\n")}
|
|
77
|
+
MARKDOWN
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param pr [Hash]
|
|
81
|
+
# @return [String]
|
|
82
|
+
def self.describe(pr)
|
|
83
|
+
body = pr[:body].to_s.strip
|
|
84
|
+
body = "_No description was written on the pull request._" if body.empty?
|
|
85
|
+
|
|
86
|
+
<<~MARKDOWN
|
|
87
|
+
### ##{pr[:number]} — #{pr[:title]}
|
|
88
|
+
|
|
89
|
+
#{pr[:url]}
|
|
90
|
+
|
|
91
|
+
#{body}
|
|
92
|
+
MARKDOWN
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Three characters have to survive a round trip through a table cell.
|
|
96
|
+
#
|
|
97
|
+
# A `|` ends the cell, and a title containing one is not unusual —
|
|
98
|
+
# "fix: guard against a || b".
|
|
99
|
+
#
|
|
100
|
+
# `[` and `]` are newer and cost more. Every title this tool writes now
|
|
101
|
+
# opens with a plan number, so the row reads `[[013.00] Ship it](url)` —
|
|
102
|
+
# and a markdown link whose text starts with `[` does not parse. The
|
|
103
|
+
# title came back with the URL glued to it and `url` came back nil, which
|
|
104
|
+
# is not a cosmetic loss: it is every pull request link on the page, and
|
|
105
|
+
# the attachment on every Linear issue.
|
|
106
|
+
#
|
|
107
|
+
# @param text [String]
|
|
108
|
+
# @return [String]
|
|
109
|
+
def self.escape(text)
|
|
110
|
+
text.to_s.gsub(/([\[\]|])/) { "\\#{$1}" }.gsub(/\s+/, " ").strip
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param dir [String] absolute path to the plan folder
|
|
114
|
+
def initialize(dir:)
|
|
115
|
+
@dir = dir
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Array<Agentilda::PullRequest>] possibly empty
|
|
119
|
+
def all = @all ||= parse
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# @return [String]
|
|
124
|
+
attr_reader :dir
|
|
125
|
+
|
|
126
|
+
# @return [Array<Agentilda::PullRequest>]
|
|
127
|
+
def parse
|
|
128
|
+
path = CANDIDATES.map { |f| File.join(dir, f) }.find { |p| File.file?(p) }
|
|
129
|
+
return [] unless path
|
|
130
|
+
|
|
131
|
+
text = File.read(path, encoding: "UTF-8")
|
|
132
|
+
table = pick_table(Markdown.tables(text))
|
|
133
|
+
return scrape(text) unless table
|
|
134
|
+
|
|
135
|
+
rows = table[:rows].filter_map { |cells| row_to_pr(cells, columns(table[:header])) }
|
|
136
|
+
rows.empty? ? scrape(text) : rows
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @param tables [Array<Hash>]
|
|
140
|
+
# @return [Hash, nil]
|
|
141
|
+
def pick_table(tables)
|
|
142
|
+
tables.find { |t|
|
|
143
|
+
t[:header].any? { |h| h.match?(/pull\s*request|\bpr\b|\A#\z|\Anumber\z/i) } &&
|
|
144
|
+
t[:rows].any? { |r| r.any? { |c| c.match?(%r{/(?:pull|merge_requests)/\d+}) } }
|
|
145
|
+
} || tables.find { |t| t[:rows].any? { |r| r.any? { |c| c.match?(%r{/pull/\d+}) } } }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @param header [Array<String>]
|
|
149
|
+
# @return [Hash{Symbol => Integer, nil}]
|
|
150
|
+
def columns(header)
|
|
151
|
+
{
|
|
152
|
+
number: header.index { |h| h.match?(/number|\A#\z|\Apr\z|\Apr\s*#/i) },
|
|
153
|
+
title: header.index { |h| h.match?(/name|title|summary|description|scope/i) },
|
|
154
|
+
state: header.index { |h| h.match?(/status|state/i) }
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @param cells [Array<String>]
|
|
159
|
+
# @param idx [Hash{Symbol => Integer, nil}]
|
|
160
|
+
# @return [Agentilda::PullRequest, nil]
|
|
161
|
+
def row_to_pr(cells, idx)
|
|
162
|
+
title_cell = cells[idx[:title] || 1].to_s
|
|
163
|
+
|
|
164
|
+
# The title comes from the title column and the URL from wherever it is.
|
|
165
|
+
# Scanning every cell for the first link and taking both from it reads
|
|
166
|
+
# `| [#24](…) | Split verified into signed_off |` as an issue called
|
|
167
|
+
# "#24", because the number column is a link too and comes first.
|
|
168
|
+
titled = title_cell.match(LINK)
|
|
169
|
+
link = titled || cells.filter_map { |c| c.match(LINK) }.first
|
|
170
|
+
|
|
171
|
+
url = link && link[2]
|
|
172
|
+
title = titled ? titled[1] : unformat(title_cell)
|
|
173
|
+
title = title.gsub(/\\([\[\]|\\])/, '\1') # undo the escaping {.render} applies
|
|
174
|
+
number = cells[idx[:number] || 0].to_s[/\d+/] || url&.[](%r{/(?:pull|merge_requests)/(\d+)}, 1)
|
|
175
|
+
return nil if title.empty? && url.nil?
|
|
176
|
+
|
|
177
|
+
PullRequest.new(number:, url:, title: title.empty? ? "Pull request" : title,
|
|
178
|
+
state: normalize(cells[idx[:state] || -1].to_s))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# When there is no parsable table, scrape bare links so a folder that
|
|
182
|
+
# plainly has pull requests does not report none.
|
|
183
|
+
#
|
|
184
|
+
# @param text [String]
|
|
185
|
+
# @return [Array<Agentilda::PullRequest>]
|
|
186
|
+
def scrape(text)
|
|
187
|
+
text.scan(%r{https?://\S+?/(?:pull|merge_requests)/(\d+)}).flatten.uniq.map do |num|
|
|
188
|
+
PullRequest.new(number: num, title: "Pull request ##{num}", state: "Unknown",
|
|
189
|
+
url: text[%r{https?://\S+?/(?:pull|merge_requests)/#{num}\b}])
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Strip the markdown a title was wrapped in without eating the title.
|
|
194
|
+
#
|
|
195
|
+
# An underscore between letters is not emphasis, it is an identifier —
|
|
196
|
+
# `signed_off`, `as_of`, `pull_requests`. Removing every underscore turned
|
|
197
|
+
# "Split verified into signed_off" into "signedoff" on the way to Linear.
|
|
198
|
+
#
|
|
199
|
+
# @param cell [String]
|
|
200
|
+
# @return [String]
|
|
201
|
+
def unformat(cell)
|
|
202
|
+
cell.to_s.gsub(/[*`]/, "").sub(/\A_+/, "").sub(/_+\z/, "").strip
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @param cell [String]
|
|
206
|
+
# @return [String]
|
|
207
|
+
def normalize(cell)
|
|
208
|
+
STATES.each { |pattern, label| return label if cell.match?(pattern) }
|
|
209
|
+
stripped = cell.gsub(/[*_`]/, "").strip
|
|
210
|
+
stripped.empty? ? "Unknown" : stripped
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|