agentilda 2.0.3 → 2.0.4

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -1,175 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "tempfile"
4
- require "tty/command"
5
-
6
- module Agentilda
7
- # Turns a finished worktree into a pushed branch and a pull request.
8
- #
9
- # This is the one place the harness reaches off the machine, so it requires
10
- # `--commit`, is skipped with `--no-git-push`, and refuses anything
11
- # it is not certain about. Everything it does is reported before it does it.
12
- #
13
- # Titles follow the convention the whole system joins on:
14
- #
15
- # [002.00](A) Tenancy Households — a plan's first pull request
16
- # [002.00](B) Tenancy Households — its second
17
- #
18
- # The slug is title-cased from the folder name, so the title reads as a
19
- # sentence rather than as a path fragment.
20
- class Publisher
21
- # A published, or publishable, pull request.
22
- #
23
- # @!attribute [r] ordinal
24
- # @return [Agentilda::Ordinal]
25
- # @!attribute [r] branch
26
- # @return [String]
27
- # @!attribute [r] title
28
- # @return [String]
29
- # @!attribute [r] url
30
- # @return [String, nil] nil on a dry run or a refusal
31
- # @!attribute [r] refusal
32
- # @return [String, nil]
33
- Publication = Data.define(:ordinal, :branch, :title, :url, :refusal) do
34
- # @return [Boolean]
35
- def published? = !url.nil?
36
- end
37
-
38
- # A..Z, which is more parts than any feature should be split into.
39
- LETTERS = ("A".."Z").to_a.freeze
40
-
41
- # @param root [String] repository the worktree belongs to
42
- # @param command [TTY::Command]
43
- # @param base [String] the branch pull requests target
44
- # @param dry_run [Boolean]
45
- def initialize(root:, command: TTY::Command.new(printer: :null), base: "main", dry_run: true)
46
- @root = root
47
- @command = command
48
- @base = base
49
- @dry_run = dry_run
50
- end
51
-
52
- # Push one plan's branch and open its pull request.
53
- #
54
- # @param checkout [Agentilda::Worktree::Checkout]
55
- # @param subject [Agentilda::Subject]
56
- # @param letter [String, nil] force a part letter; nil auto-assigns
57
- # @return [Agentilda::Publisher::Publication]
58
- def publish(checkout:, subject:, letter: nil)
59
- title = title_for(subject, letter)
60
-
61
- unless checkout.dirty?
62
- return refuse(subject, checkout, title, "nothing to publish — the worktree is unchanged")
63
- end
64
-
65
- return Publication.new(**base_fields(subject, checkout, title), url: nil, refusal: nil) if @dry_run
66
-
67
- commit(checkout, title)
68
- push(checkout)
69
- url = create_pull_request(checkout, subject, title)
70
-
71
- Publication.new(**base_fields(subject, checkout, title), url:, refusal: nil)
72
- rescue TTY::Command::ExitError => e
73
- refuse(subject, checkout, title, git_complaint(e))
74
- end
75
-
76
- # git and gh put the useful sentence in stderr, several lines below the
77
- # command they echo. Reporting only the first line reports "it failed".
78
- #
79
- # @param error [TTY::Command::ExitError]
80
- # @return [String]
81
- def git_complaint(error)
82
- lines = error.message.lines.map(&:strip).reject(&:empty?)
83
- stderr = lines.find { |l| l.start_with?("stderr:") }
84
- complaint = stderr&.delete_prefix("stderr:")&.strip
85
-
86
- complaint.to_s.empty? || complaint == "Nothing written" ? lines.first.to_s : complaint
87
- end
88
-
89
- # The title a plan's next pull request should carry.
90
- #
91
- # @param subject [Agentilda::Subject]
92
- # @param letter [String, nil]
93
- # @return [String]
94
- def title_for(subject, letter = nil)
95
- "[#{subject.feature.ordinal}](#{letter || auto_letter(subject)}) #{subject.feature.title}"
96
- end
97
-
98
- # Which part this is. Always a letter, starting at A: a plan that turns out
99
- # to need only one pull request still reads consistently with every other,
100
- # and nothing has to be renamed when a second arrives.
101
- #
102
- # @param subject [Agentilda::Subject]
103
- # @return [String]
104
- def auto_letter(subject) = LETTERS.fetch(subject.pull_requests.size, LETTERS.last)
105
-
106
- private
107
-
108
- # @return [Hash]
109
- def base_fields(subject, checkout, title)
110
- { ordinal: subject.feature.ordinal, branch: checkout.branch, title: }
111
- end
112
-
113
- # @return [Agentilda::Publisher::Publication]
114
- def refuse(subject, checkout, title, why)
115
- Publication.new(**base_fields(subject, checkout, title), url: nil, refusal: why)
116
- end
117
-
118
- # @param checkout [Agentilda::Worktree::Checkout]
119
- # @param title [String]
120
- # @return [void]
121
- def commit(checkout, title)
122
- run(checkout, "git", "add", "-A")
123
- # The subject line drops the bracketed prefix: it belongs in the pull
124
- # request title, where things join on it, not in every commit message.
125
- run(checkout, "git", "commit", "-m", title.sub(/\A\[[^\]]+\](?:\([A-Z]\))?\s*/, ""))
126
- end
127
-
128
- # @param checkout [Agentilda::Worktree::Checkout]
129
- # @return [void]
130
- def push(checkout) = run(checkout, "git", "push", "-u", "origin", checkout.branch)
131
-
132
- # @return [String, nil] the pull request URL
133
- def create_pull_request(checkout, subject, title)
134
- Tempfile.create(["pr", ".md"]) do |file|
135
- file.write(description(subject, title))
136
- file.flush
137
- result = run(checkout, "gh", "pr", "create", "-a", "@me", "-B", @base, "-t", title, "-F", file.path)
138
- return result.out.to_s[%r{https://\S+}]
139
- end
140
- end
141
-
142
- # The body `/create-pr` would have written: a title line, a summary lifted
143
- # from the specification, and a link back to the plan folder so a reviewer
144
- # can find the rest.
145
- #
146
- # @return [String]
147
- def description(subject, title)
148
- <<~MARKDOWN
149
- # #{title}
150
-
151
- ## Summary
152
-
153
- #{summary_text(subject)}
154
-
155
- ## Plan
156
-
157
- Implements `#{subject.feature.dirname}`. The specification and the plan
158
- for this work live in that folder.
159
- MARKDOWN
160
- end
161
-
162
- # The specification's own Goal section, verbatim. A pull request body that
163
- # paraphrases the spec is a second copy that drifts; quoting it is not.
164
- #
165
- # @param subject [Agentilda::Subject]
166
- # @return [String]
167
- def summary_text(subject)
168
- text = subject.goal.join("\n\n")
169
- text.empty? ? "See `#{subject.feature.dirname}/spec.md`." : text
170
- end
171
-
172
- # @return [TTY::Command::Result]
173
- def run(checkout, *argv) = @command.run(*argv, chdir: checkout.path)
174
- end
175
- end
@@ -1,27 +0,0 @@
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
- end
@@ -1,230 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "agentilda/markdown"
4
- require "agentilda/pull_request"
5
-
6
- module Agentilda
7
- # Extracts the pull request roll-up from a plan folder.
8
- class PullRequests
9
- # A markdown link whose text may contain escaped brackets, because every
10
- # title this tool writes now opens with `\[NNN.MM\]`. Matching `[^\]]+`
11
- # stops at the escaped `]` and loses the URL with it.
12
- LINK = /\[((?:\\.|[^\]\\])+)\]\((https?:[^)\s]+)\)/
13
-
14
- # Canonical wording for the states we recognise, most specific first.
15
- STATES = [
16
- [/\bclosed\b|\bunmerged\b|\babandoned\b/i, "Closed 🔴"],
17
- [/\bmerged\b/i, "Merged 🟣"],
18
- [/\bdraft\b|\bwip\b/i, "WIP 🟡"],
19
- [/\bopen\b/i, "Open 🟡"]
20
- ].freeze
21
-
22
- # Filenames that may hold the table.
23
- CANDIDATES = %w[pull-requests.md pull_requests.md prs.md].freeze
24
-
25
- # The file this class writes, and the first one it looks for.
26
- FILENAME = CANDIDATES.first
27
-
28
- # Render `pull-requests.md` for a set of pull requests fetched from GitHub.
29
- #
30
- # The table is what {#parse} reads back and what the state machine judges
31
- # a folder by. The descriptions below it are for a reader — human or
32
- # agent — synthesizing a specification from work that already shipped:
33
- # the table says *which* pull requests, the bodies say *what they did*,
34
- # and a retroactive `spec.md` cannot be written from numbers alone.
35
- #
36
- # Prose after the table is ignored by the parser, so the two can coexist
37
- # in one file rather than needing a scratch file the folder rules forbid.
38
- #
39
- # @param prs [Array<Hash>] `{number:, title:, url:, state:, body:}`
40
- # @return [String]
41
- def self.render(prs)
42
- rows = prs.map { |pr|
43
- "| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |"
44
- }
45
-
46
- <<~MARKDOWN
47
- # Pull Requests
48
-
49
- | Pull Request Number | Pull Request Name | Status |
50
- | ------------------: | :---------------- | -----: |
51
- #{rows.join("\n")}
52
-
53
- ## What these pull requests did
54
-
55
- #{prs.map { |pr| describe(pr) }.join("\n")}
56
- MARKDOWN
57
- end
58
-
59
- # The table alone, for splicing into a file that already says other
60
- # things: the ledger blocks luke and rey write before a pull request
61
- # exists. Rewriting the whole file deleted their signatures.
62
- #
63
- # @param prs [Array<Hash>]
64
- # @return [String] header, rule and rows, newline-terminated
65
- def self.table(prs)
66
- rows = prs.map { |pr| "| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |" }
67
- ["| Pull Request Number | Pull Request Name | Status |",
68
- "| ------------------: | :---------------- | -----: |", *rows].join("\n") + "\n"
69
- end
70
-
71
- # Write the table into +path+ without touching anything else there.
72
- #
73
- # @param path [String]
74
- # @param prs [Array<Hash>]
75
- # @return [void]
76
- def self.upsert(path, prs)
77
- return File.write(path, render(prs)) unless File.file?(path)
78
-
79
- lines = File.read(path, encoding: "UTF-8").lines
80
- start = lines.index { |l| l.strip.start_with?("|") && lines[lines.index(l) + 1].to_s.strip.start_with?("|") }
81
- if start
82
- stop = start
83
- stop += 1 while lines[stop + 1]&.strip&.start_with?("|")
84
- lines[start..stop] = [table(prs)]
85
- else
86
- at = lines.index { |l| l.start_with?("# ") }
87
- insertion = ["\n", table(prs)]
88
- at ? lines.insert(at + 1, *insertion) : lines.unshift("# Pull Requests\n", *insertion, "\n")
89
- end
90
- File.write(path, lines.join)
91
- end
92
-
93
- # @param pr [Hash]
94
- # @return [String]
95
- def self.describe(pr)
96
- body = pr[:body].to_s.strip
97
- body = "_No description was written on the pull request._" if body.empty?
98
-
99
- <<~MARKDOWN
100
- ### ##{pr[:number]} — #{pr[:title]}
101
-
102
- #{pr[:url]}
103
-
104
- #{body}
105
- MARKDOWN
106
- end
107
-
108
- # Three characters have to survive a round trip through a table cell.
109
- #
110
- # A `|` ends the cell, and a title containing one is not unusual —
111
- # "fix: guard against a || b".
112
- #
113
- # `[` and `]` are newer and cost more. Every title this tool writes now
114
- # opens with a plan number, so the row reads `[[013.00] Ship it](url)` —
115
- # and a markdown link whose text starts with `[` does not parse. The
116
- # title came back with the URL glued to it and `url` came back nil, which
117
- # is not a cosmetic loss: it is every pull request link on the page, and
118
- # the attachment on every Linear issue.
119
- #
120
- # @param text [String]
121
- # @return [String]
122
- def self.escape(text)
123
- text.to_s.gsub(/([\[\]|])/) { "\\#{$1}" }.gsub(/\s+/, " ").strip
124
- end
125
-
126
- # @param dir [String] absolute path to the plan folder
127
- def initialize(dir:)
128
- @dir = dir
129
- end
130
-
131
- # @return [Array<Agentilda::PullRequest>] possibly empty
132
- def all = @all ||= parse
133
-
134
- private
135
-
136
- # @return [String]
137
- attr_reader :dir
138
-
139
- # @return [Array<Agentilda::PullRequest>]
140
- def parse
141
- path = CANDIDATES.map { |f| File.join(dir, f) }.find { |p| File.file?(p) }
142
- return [] unless path
143
-
144
- text = File.read(path, encoding: "UTF-8")
145
- table = pick_table(Markdown.tables(text))
146
- return scrape(text) unless table
147
-
148
- rows = table[:rows].filter_map { |cells| row_to_pr(cells, columns(table[:header])) }
149
- rows.empty? ? scrape(text) : rows
150
- end
151
-
152
- # @param tables [Array<Hash>]
153
- # @return [Hash, nil]
154
- def pick_table(tables)
155
- tables.find { |t|
156
- t[:header].any? { |h| h.match?(/pull\s*request|\bpr\b|\A#\z|\Anumber\z/i) } &&
157
- t[:rows].any? { |r| r.any? { |c| c.match?(%r{/(?:pull|merge_requests)/\d+}) } }
158
- } || tables.find { |t| t[:rows].any? { |r| r.any? { |c| c.match?(%r{/pull/\d+}) } } }
159
- end
160
-
161
- # @param header [Array<String>]
162
- # @return [Hash{Symbol => Integer, nil}]
163
- def columns(header)
164
- {
165
- number: header.index { |h| h.match?(/number|\A#\z|\Apr\z|\Apr\s*#/i) },
166
- title: header.index { |h| h.match?(/name|title|summary|description|scope/i) },
167
- state: header.index { |h| h.match?(/status|state/i) }
168
- }
169
- end
170
-
171
- # @param cells [Array<String>]
172
- # @param idx [Hash{Symbol => Integer, nil}]
173
- # @return [Agentilda::PullRequest, nil]
174
- def row_to_pr(cells, idx)
175
- title_cell = cells[idx[:title] || 1].to_s
176
-
177
- # The title comes from the title column and the URL from wherever it is.
178
- # Scanning every cell for the first link and taking both from it reads
179
- # `| [#24](...) | Split verified into signed_off |` as an issue called
180
- # "#24", because the number column is a link too and comes first.
181
- titled = title_cell.match(LINK)
182
- link = titled || cells.filter_map { |c| c.match(LINK) }.first
183
-
184
- url = link && link[2]
185
- title = titled ? titled[1] : unformat(title_cell)
186
- title = title.gsub(/\\([\[\]|\\])/, '\1') # undo the escaping {.render} applies
187
- number = cells[idx[:number] || 0].to_s[/\d+/] || url&.[](%r{/(?:pull|merge_requests)/(\d+)}, 1)
188
- return nil if title.empty? && url.nil?
189
-
190
- PullRequest.new(number:,
191
- url:,
192
- title: title.empty? ? "Pull request" : title,
193
- state: normalize(cells[idx[:state] || -1].to_s))
194
- end
195
-
196
- # When there is no parsable table, scrape bare links so a folder that
197
- # plainly has pull requests does not report none.
198
- #
199
- # @param text [String]
200
- # @return [Array<Agentilda::PullRequest>]
201
- def scrape(text)
202
- text.scan(%r{https?://\S+?/(?:pull|merge_requests)/(\d+)}).flatten.uniq.map do |num|
203
- PullRequest.new(number: num,
204
- title: "Pull request ##{num}",
205
- state: "Unknown",
206
- url: text[%r{https?://\S+?/(?:pull|merge_requests)/#{num}\b}])
207
- end
208
- end
209
-
210
- # Strip the markdown a title was wrapped in without eating the title.
211
- #
212
- # An underscore between letters is not emphasis, it is an identifier —
213
- # `signed_off`, `as_of`, `pull_requests`. Removing every underscore turned
214
- # "Split verified into signed_off" into "signedoff" on the way to Linear.
215
- #
216
- # @param cell [String]
217
- # @return [String]
218
- def unformat(cell)
219
- cell.to_s.gsub(/[*`]/, "").sub(/\A_+/, "").sub(/_+\z/, "").strip
220
- end
221
-
222
- # @param cell [String]
223
- # @return [String]
224
- def normalize(cell)
225
- STATES.each { |pattern, label| return label if cell.match?(pattern) }
226
- stripped = cell.gsub(/[*_`]/, "").strip
227
- stripped.empty? ? "Unknown" : stripped
228
- end
229
- end
230
- end
@@ -1,181 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "agentilda/status"
4
-
5
- module Agentilda
6
- # `/spec-status` — every plan, its state in icon and words, and its pull
7
- # requests as clickable links.
8
- #
9
- # {#render} returns a string and prints nothing. Whoever calls it decides
10
- # where it goes, which keeps the table pipeable.
11
- class Reporter
12
- # Columns, so the header and the rows cannot drift apart.
13
- HEADINGS = ["Plan", "⬇", "Status", "Feature", "Pull Requests"].freeze
14
-
15
- # Cells given to the plan number: "018.01" with room to grow.
16
- ORDINAL_WIDTH = 7
17
-
18
- # Cells given to a status icon. Emoji vary in both character count and
19
- # display width, so the cell is padded to this by {UI.fit} rather than
20
- # trusted to be any particular size — see the note there.
21
- EMOJI_WIDTH = 2
22
-
23
- # Cells given to a pull request's state, so the URLs line up beneath each
24
- # other whichever states the tree happens to contain.
25
- STATE_WIDTH = PullRequests::STATES.map { |_, label| UI.display_width(label) }.max
26
-
27
- # @param tree [Agentilda::Tree]
28
- def initialize(tree:)
29
- @tree = tree
30
- end
31
-
32
- # @return [Agentilda::Tree]
33
- attr_reader :tree
34
-
35
- # @return [String] the whole table, newline-terminated
36
- def render
37
- return "No plans found in #{tree.dir}\n" if tree.subjects.empty?
38
-
39
- [header, *tree.subjects.flat_map { |s| rows_for(s) }, "", footer].join("\n") + "\n"
40
- end
41
-
42
- # @return [Hash{Symbol => Integer}] how many plans sit in each state
43
- def totals
44
- tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status.key] += 1 }
45
- end
46
-
47
- # Plans whose folder name their contents do not justify.
48
- #
49
- # @return [Array<Agentilda::Subject>]
50
- def inconsistent = tree.subjects.reject(&:consistent?)
51
-
52
- private
53
-
54
- # @return [Integer] widest status label, so the column lines up
55
- def label_width = @label_width ||= tree.subjects.map { |s| UI.display_width(s.status.label) }.max.to_i + 4
56
-
57
- # @return [Integer] widest feature name
58
- def title_width = @title_width ||= tree.subjects.map { |s| UI.display_width(s.feature.title) }.max.to_i + 2
59
-
60
- # The one place the table's shape is written down. The heading, the plan
61
- # rows, the continuation lines and the violations all come through here, so
62
- # a column cannot widen in one of them and stay put in the others.
63
- #
64
- # Cells arrive already fitted and already painted, in that order, because
65
- # the order matters: escape codes count toward a string's length, so
66
- # padding a coloured string pads it to a width of which several characters
67
- # are invisible, and the column collapses by exactly that much.
68
- #
69
- # @return [String]
70
- def row(flag, ordinal, emoji, label, title, tail)
71
- format("%s %s %s %s %s %s", flag, ordinal, emoji, label, title, tail)
72
- end
73
-
74
- # Cell at which the pull requests start, so a plan's second and third pull
75
- # requests sit under its first. Measured by running blanks through {#row}
76
- # rather than counted by hand, so the indent cannot drift when the layout
77
- # changes.
78
- #
79
- # @return [Integer]
80
- def pr_column
81
- @pr_column ||= row(" ",
82
- " " * ORDINAL_WIDTH,
83
- " " * EMOJI_WIDTH,
84
- " " * label_width,
85
- " " * title_width,
86
- "").length
87
- end
88
-
89
- # @return [Integer] cell at which the feature name starts
90
- def title_column = pr_column - title_width - 1
91
-
92
- # @return [String]
93
- def header
94
- text = row(" ",
95
- UI.fit(HEADINGS[0], ORDINAL_WIDTH),
96
- UI.fit(HEADINGS[1], EMOJI_WIDTH),
97
- UI.fit(HEADINGS[2], label_width),
98
- UI.fit(HEADINGS[3], title_width),
99
- HEADINGS[4])
100
-
101
- rule = " " + ("─" * (UI.display_width(text) - 2))
102
-
103
- [UI.paint(text, :bold), UI.paint(rule, :bright_yellow)].join("\n")
104
- end
105
-
106
- # One line per plan, plus a continuation line for each extra pull request
107
- # so that every URL is visible and clickable rather than truncated.
108
- #
109
- # @param subject [Agentilda::Subject]
110
- # @return [Array<String>]
111
- def rows_for(subject)
112
- feature = subject.feature
113
- prs = subject.pull_requests
114
-
115
- first = row(
116
- subject.consistent? ? " " : UI.paint("!", :red),
117
- UI.paint(UI.fit(feature.ordinal.to_s, ORDINAL_WIDTH), :bold),
118
- UI.fit(feature.status.emoji, EMOJI_WIDTH),
119
- UI.paint(UI.fit(feature.status.label, label_width), :yellow),
120
- UI.paint(UI.fit(feature.title, title_width), :blue, :bold),
121
- prs.empty? ? UI.paint("[none yet]", :bright_red) : link(prs.first)
122
- )
123
-
124
- rest = prs.drop(1).map { |pr| (" " * pr_column) + link(pr) }
125
-
126
- [first, *rest, *violation_line(subject)]
127
- end
128
-
129
- # The URL is left bare rather than wrapped in an OSC 8 hyperlink: the table
130
- # is as likely to be piped into a file as read in a terminal, and every
131
- # terminal worth the name linkifies a bare URL anyway.
132
- #
133
- # @param pr [Agentilda::PullRequest]
134
- # @return [String]
135
- def link(pr)
136
- colour = state_colour(pr)
137
-
138
- format("#%4s %s %s",
139
- pr.number,
140
- UI.paint(UI.fit(pr.state, STATE_WIDTH), colour),
141
- UI.paint(pr.url.to_s, colour))
142
- end
143
-
144
- # @param pr [Agentilda::PullRequest]
145
- # @return [Symbol] a pastel style name
146
- def state_colour(pr)
147
- if pr.merged?
148
- :magenta
149
- elsif pr.open?
150
- :yellow
151
- else
152
- :blue
153
- end
154
- end
155
-
156
- # @param subject [Agentilda::Subject]
157
- # @return [Array<String>]
158
- def violation_line(subject)
159
- return [] if subject.consistent?
160
-
161
- [(" " * title_column) + UI.paint("↳ #{subject.violation}", :red)]
162
- end
163
-
164
- # @return [String]
165
- def footer
166
- counts = totals.map { |key, n| "#{STATUS_BY_KEY.fetch(key).emoji} #{n}" }.join(" ")
167
- lines = [" #{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} #{counts}"]
168
-
169
- unless inconsistent.empty?
170
- lines << " #{UI.paint("#{inconsistent.size} with a status their contents do not justify", :red)}"
171
- end
172
-
173
- tree.duplicates.each do |ordinal, dirnames|
174
- lines << " #{UI.paint("#{ordinal} is claimed by #{dirnames.size} folders — a number is an identity:", :red)}"
175
- dirnames.each { |d| lines << " #{UI.paint(d, :bright_black)}" }
176
- end
177
-
178
- lines.join("\n")
179
- end
180
- end
181
- end