agentilda 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +26 -0
  3. data/Gemfile.lock +261 -0
  4. data/agentilda.gemspec +57 -0
  5. data/agents/hansolo-reviewer.md +29 -0
  6. data/agents/lando-broker.md +74 -0
  7. data/agents/leah-researcher.md +80 -0
  8. data/agents/luke-backend.md +81 -0
  9. data/agents/palpatine-planner.md +40 -0
  10. data/agents/rey-frontend.md +106 -0
  11. data/agents/yoda-writer.md +54 -0
  12. data/bin/create-plan-folder +125 -0
  13. data/bin/plan-number +164 -0
  14. data/exe/agentilda +111 -0
  15. data/exe/tilda +1 -0
  16. data/lib/agentilda/adoption.rb +192 -0
  17. data/lib/agentilda/agent.rb +136 -0
  18. data/lib/agentilda/brief.rb +234 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
  20. data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
  21. data/lib/agentilda/cli/base.rb +88 -0
  22. data/lib/agentilda/cli/create/create.rb +309 -0
  23. data/lib/agentilda/cli/docs/docs.rb +30 -0
  24. data/lib/agentilda/cli/index/index.rb +38 -0
  25. data/lib/agentilda/cli/linear/linear.rb +35 -0
  26. data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
  27. data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
  28. data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
  31. data/lib/agentilda/cli/run/run.rb +289 -0
  32. data/lib/agentilda/cli/states/states.rb +15 -0
  33. data/lib/agentilda/cli/unblock/unblock.rb +227 -0
  34. data/lib/agentilda/cli/version/version.rb +13 -0
  35. data/lib/agentilda/cli.rb +74 -0
  36. data/lib/agentilda/config.rb +44 -0
  37. data/lib/agentilda/control.rb +115 -0
  38. data/lib/agentilda/creator.rb +120 -0
  39. data/lib/agentilda/dev_work.rb +54 -0
  40. data/lib/agentilda/diagram.rb +144 -0
  41. data/lib/agentilda/documentation.rb +429 -0
  42. data/lib/agentilda/executor.rb +539 -0
  43. data/lib/agentilda/feature.rb +253 -0
  44. data/lib/agentilda/frontmatter.rb +36 -0
  45. data/lib/agentilda/github.rb +160 -0
  46. data/lib/agentilda/index.rb +206 -0
  47. data/lib/agentilda/keyboard.rb +88 -0
  48. data/lib/agentilda/linear/api.rb +220 -0
  49. data/lib/agentilda/linear/attribution.rb +185 -0
  50. data/lib/agentilda/linear/fuzzy.rb +68 -0
  51. data/lib/agentilda/linear/import.rb +298 -0
  52. data/lib/agentilda/linear/issue.rb +184 -0
  53. data/lib/agentilda/linear/mapping.rb +115 -0
  54. data/lib/agentilda/linear/push.rb +190 -0
  55. data/lib/agentilda/linear/survey.rb +173 -0
  56. data/lib/agentilda/linear/unit.rb +274 -0
  57. data/lib/agentilda/linear.rb +42 -0
  58. data/lib/agentilda/markdown.rb +56 -0
  59. data/lib/agentilda/ordinal.rb +90 -0
  60. data/lib/agentilda/progress_log.rb +122 -0
  61. data/lib/agentilda/publisher.rb +172 -0
  62. data/lib/agentilda/pull_request.rb +213 -0
  63. data/lib/agentilda/reporter.rb +175 -0
  64. data/lib/agentilda/resync.rb +358 -0
  65. data/lib/agentilda/roster.rb +110 -0
  66. data/lib/agentilda/runner.rb +456 -0
  67. data/lib/agentilda/state_machine.rb +355 -0
  68. data/lib/agentilda/status.rb +280 -0
  69. data/lib/agentilda/tally.rb +169 -0
  70. data/lib/agentilda/transcript.rb +435 -0
  71. data/lib/agentilda/tree.rb +77 -0
  72. data/lib/agentilda/ui.rb +681 -0
  73. data/lib/agentilda/unblocker.rb +207 -0
  74. data/lib/agentilda/version.rb +10 -0
  75. data/lib/agentilda/viewer.rb +60 -0
  76. data/lib/agentilda/worktree.rb +211 -0
  77. data/lib/agentilda.rb +155 -0
  78. data/lib/dry/cli/banner.rb +293 -0
  79. metadata +349 -0
@@ -0,0 +1,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