agentilda 2.0.1 → 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.
- checksums.yaml +4 -4
- data/lib/agentilda/agents/agent.rb +79 -0
- data/lib/agentilda/agents/registry.rb +121 -0
- data/lib/agentilda/agents/roster.rb +92 -0
- data/lib/agentilda/agents.rb +11 -113
- data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
- data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
- data/lib/agentilda/cli/base.rb +3 -3
- data/lib/agentilda/cli/create/create.rb +17 -18
- data/lib/agentilda/cli/docs/docs.rb +1 -1
- data/lib/agentilda/cli/index/index.rb +1 -1
- data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
- data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
- data/lib/agentilda/cli/mail/mail.rb +4 -4
- data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
- data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
- data/lib/agentilda/cli/run/run.rb +40 -40
- data/lib/agentilda/cli/states/states.rb +1 -1
- data/lib/agentilda/cli/unblock/unblock.rb +17 -17
- data/lib/agentilda/cli/worktree/worktree.rb +4 -4
- data/lib/agentilda/cli.rb +1 -1
- data/lib/agentilda/engine/board.rb +93 -0
- data/lib/agentilda/engine/dispatcher.rb +646 -0
- data/lib/agentilda/engine/progress_log.rb +125 -0
- data/lib/agentilda/engine/runner.rb +141 -0
- data/lib/agentilda/engine/state_file.rb +176 -0
- data/lib/agentilda/engine/tally.rb +177 -0
- data/lib/agentilda/engine.rb +19 -0
- data/lib/agentilda/execution/child.rb +94 -0
- data/lib/agentilda/execution/clock.rb +116 -0
- data/lib/agentilda/execution/control.rb +180 -0
- data/lib/agentilda/execution/executor.rb +699 -0
- data/lib/agentilda/execution/transcript.rb +468 -0
- data/lib/agentilda/execution.rb +18 -0
- data/lib/agentilda/lifecycle/adoption.rb +197 -0
- data/lib/agentilda/lifecycle/brief.rb +285 -0
- data/lib/agentilda/lifecycle/creator.rb +125 -0
- data/lib/agentilda/lifecycle/resync.rb +428 -0
- data/lib/agentilda/lifecycle/unblocker.rb +223 -0
- data/lib/agentilda/lifecycle.rb +28 -0
- data/lib/agentilda/linear/api.rb +1 -1
- data/lib/agentilda/linear/attribution.rb +7 -7
- data/lib/agentilda/linear/import.rb +18 -18
- data/lib/agentilda/linear/mapping.rb +6 -6
- data/lib/agentilda/linear/push.rb +5 -5
- data/lib/agentilda/linear/survey.rb +7 -7
- data/lib/agentilda/linear/unit.rb +5 -5
- data/lib/agentilda/plans/dev_work.rb +56 -0
- data/lib/agentilda/plans/feature.rb +131 -0
- data/lib/agentilda/plans/index.rb +208 -0
- data/lib/agentilda/plans/ledger.rb +235 -0
- data/lib/agentilda/plans/mailbox.rb +143 -0
- data/lib/agentilda/plans/ordinal.rb +92 -0
- data/lib/agentilda/plans/pull_request.rb +29 -0
- data/lib/agentilda/plans/pull_requests.rb +229 -0
- data/lib/agentilda/plans/state_machine.rb +383 -0
- data/lib/agentilda/plans/status.rb +365 -0
- data/lib/agentilda/plans/subject.rb +131 -0
- data/lib/agentilda/plans/tree.rb +79 -0
- data/lib/agentilda/plans.rb +39 -0
- data/lib/agentilda/presentation/console.rb +159 -0
- data/lib/agentilda/presentation/dashboard.rb +221 -0
- data/lib/agentilda/presentation/diagram.rb +149 -0
- data/lib/agentilda/presentation/documentation.rb +439 -0
- data/lib/agentilda/presentation/keyboard.rb +158 -0
- data/lib/agentilda/presentation/reporter.rb +181 -0
- data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
- data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
- data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
- data/lib/agentilda/presentation/viewer.rb +62 -0
- data/lib/agentilda/presentation.rb +23 -0
- data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
- data/lib/agentilda/vcs/github.rb +170 -0
- data/lib/agentilda/vcs/publisher.rb +177 -0
- data/lib/agentilda/vcs/worktree.rb +209 -0
- data/lib/agentilda/vcs.rb +19 -0
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda.rb +6 -5
- metadata +56 -49
- data/lib/agentilda/adoption.rb +0 -197
- data/lib/agentilda/agent.rb +0 -77
- data/lib/agentilda/board.rb +0 -91
- data/lib/agentilda/brief.rb +0 -283
- data/lib/agentilda/child.rb +0 -92
- data/lib/agentilda/clock.rb +0 -114
- data/lib/agentilda/console.rb +0 -157
- data/lib/agentilda/control.rb +0 -178
- data/lib/agentilda/creator.rb +0 -125
- data/lib/agentilda/dashboard.rb +0 -219
- data/lib/agentilda/dev_work.rb +0 -56
- data/lib/agentilda/diagram.rb +0 -149
- data/lib/agentilda/dispatcher.rb +0 -646
- data/lib/agentilda/documentation.rb +0 -437
- data/lib/agentilda/executor.rb +0 -697
- data/lib/agentilda/feature.rb +0 -132
- data/lib/agentilda/github.rb +0 -168
- data/lib/agentilda/index.rb +0 -206
- data/lib/agentilda/keyboard.rb +0 -156
- data/lib/agentilda/ledger.rb +0 -233
- data/lib/agentilda/mailbox.rb +0 -141
- data/lib/agentilda/ordinal.rb +0 -90
- data/lib/agentilda/progress_log.rb +0 -126
- data/lib/agentilda/publisher.rb +0 -175
- data/lib/agentilda/pull_request.rb +0 -27
- data/lib/agentilda/pull_requests.rb +0 -230
- data/lib/agentilda/reporter.rb +0 -181
- data/lib/agentilda/resync.rb +0 -426
- data/lib/agentilda/roster.rb +0 -93
- data/lib/agentilda/runner.rb +0 -139
- data/lib/agentilda/screen/ratatui/bar.rb +0 -57
- data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
- data/lib/agentilda/screen/ratatui.rb +0 -318
- data/lib/agentilda/state_file.rb +0 -174
- data/lib/agentilda/state_machine.rb +0 -383
- data/lib/agentilda/status.rb +0 -363
- data/lib/agentilda/subject.rb +0 -133
- data/lib/agentilda/tally.rb +0 -175
- data/lib/agentilda/transcript.rb +0 -466
- data/lib/agentilda/tree.rb +0 -81
- data/lib/agentilda/unblocker.rb +0 -221
- data/lib/agentilda/viewer.rb +0 -60
- data/lib/agentilda/worktree.rb +0 -207
- /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
- /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
- /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
data/lib/agentilda/publisher.rb
DELETED
|
@@ -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
|
data/lib/agentilda/reporter.rb
DELETED
|
@@ -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
|