agentilda 1.0.3 → 2.0.1
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/agentilda.gemspec +10 -6
- data/agents/hansolo-reviewer.md +42 -13
- data/agents/lando-broker.md +40 -46
- data/agents/leah-researcher.md +36 -58
- data/agents/luke-backend.md +49 -61
- data/agents/palpatine-planner.md +39 -21
- data/agents/rey-frontend.md +44 -86
- data/agents/yoda-writer.md +38 -36
- data/bin/setup +26 -0
- data/exe/agentilda +13 -92
- data/exe/tilda-rs +0 -0
- data/lib/agentilda/adoption.rb +7 -2
- data/lib/agentilda/agent.rb +40 -99
- data/lib/agentilda/agents.rb +122 -0
- data/lib/agentilda/board.rb +91 -0
- data/lib/agentilda/brief.rb +54 -5
- data/lib/agentilda/child.rb +92 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
- data/lib/agentilda/cli/base.rb +46 -6
- data/lib/agentilda/cli/create/create.rb +46 -19
- data/lib/agentilda/cli/docs/docs.rb +6 -2
- data/lib/agentilda/cli/index/index.rb +6 -4
- data/lib/agentilda/cli/linear/linear.rb +3 -2
- data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
- data/lib/agentilda/cli/mail/mail.rb +31 -0
- data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
- data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
- data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
- data/lib/agentilda/cli/run/run.rb +207 -99
- data/lib/agentilda/cli/unblock/unblock.rb +24 -13
- data/lib/agentilda/cli/worktree/worktree.rb +85 -0
- data/lib/agentilda/cli.rb +43 -0
- data/lib/agentilda/clock.rb +114 -0
- data/lib/agentilda/console.rb +157 -0
- data/lib/agentilda/control.rb +63 -0
- data/lib/agentilda/creator.rb +7 -2
- data/lib/agentilda/dashboard.rb +219 -0
- data/lib/agentilda/dev_work.rb +2 -0
- data/lib/agentilda/diagram.rb +12 -7
- data/lib/agentilda/dispatcher.rb +646 -0
- data/lib/agentilda/documentation.rb +14 -6
- data/lib/agentilda/executor.rb +362 -204
- data/lib/agentilda/feature.rb +14 -135
- data/lib/agentilda/frontmatter.rb +4 -2
- data/lib/agentilda/github.rb +19 -11
- data/lib/agentilda/index.rb +11 -11
- data/lib/agentilda/keyboard.rb +76 -8
- data/lib/agentilda/launcher.rb +69 -0
- data/lib/agentilda/ledger.rb +233 -0
- data/lib/agentilda/linear/api.rb +14 -10
- data/lib/agentilda/linear/attribution.rb +5 -5
- data/lib/agentilda/linear/import.rb +30 -14
- data/lib/agentilda/linear/issue.rb +6 -3
- data/lib/agentilda/linear/mapping.rb +18 -17
- data/lib/agentilda/linear/push.rb +19 -12
- data/lib/agentilda/linear/survey.rb +3 -2
- data/lib/agentilda/linear/unit.rb +12 -12
- data/lib/agentilda/linear.rb +5 -6
- data/lib/agentilda/mailbox.rb +141 -0
- data/lib/agentilda/markdown.rb +1 -1
- data/lib/agentilda/progress_log.rb +5 -1
- data/lib/agentilda/publisher.rb +6 -3
- data/lib/agentilda/pull_request.rb +0 -186
- data/lib/agentilda/pull_requests.rb +230 -0
- data/lib/agentilda/reporter.rb +9 -3
- data/lib/agentilda/resync.rb +120 -52
- data/lib/agentilda/roster.rb +31 -48
- data/lib/agentilda/runner.rb +72 -389
- data/lib/agentilda/screen/ratatui/bar.rb +57 -0
- data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
- data/lib/agentilda/screen/ratatui.rb +318 -0
- data/lib/agentilda/state_file.rb +174 -0
- data/lib/agentilda/state_machine.rb +60 -32
- data/lib/agentilda/status.rb +120 -37
- data/lib/agentilda/subject.rb +133 -0
- data/lib/agentilda/tally.rb +17 -11
- data/lib/agentilda/transcript.rb +47 -16
- data/lib/agentilda/tree.rb +10 -6
- data/lib/agentilda/ui.rb +204 -190
- data/lib/agentilda/unblocker.rb +25 -11
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda/worktree.rb +14 -18
- data/lib/agentilda.rb +57 -70
- metadata +65 -13
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -261
- data/bin/create-plan-folder +0 -125
- data/lib/dry/cli/banner.rb +0 -293
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# The one channel two agents on the same plan have to each other.
|
|
5
|
+
#
|
|
6
|
+
# `luke-backend` and `rey-frontend` build one plan in one worktree at the
|
|
7
|
+
# same time, but each is its own `claude -p` process. Claude Code can
|
|
8
|
+
# message between local sessions, and the prompts used to say so, but a
|
|
9
|
+
# session is named after its directory plus a random suffix, so a pair had
|
|
10
|
+
# to guess its partner from a list of every session on the machine by start
|
|
11
|
+
# time. It worked once, by luck. With ten plans running there would have
|
|
12
|
+
# been ten `agentilda-xx` entries and nothing to tell them apart.
|
|
13
|
+
#
|
|
14
|
+
# So the mailbox is a file in the plan folder, append-only, one numbered
|
|
15
|
+
# entry per message, readable by `agentilda mail read` during the round and
|
|
16
|
+
# by a person after it. The harness names the file and the partner in each
|
|
17
|
+
# agent's prompt, so nobody guesses.
|
|
18
|
+
class Mailbox
|
|
19
|
+
FILENAME = "mailbox.md"
|
|
20
|
+
|
|
21
|
+
# Between the fields of a heading. Neither a name nor a timestamp
|
|
22
|
+
# contains it, which is what makes the heading parseable.
|
|
23
|
+
SEPARATOR = " · "
|
|
24
|
+
|
|
25
|
+
# `## 3 · 2026-09-06 10:30:53 -0700 · rey-frontend → luke-backend`
|
|
26
|
+
HEADING = /\A\#\# (\d+) · (.+?) · (\S+) → (\S+)\s*\z/
|
|
27
|
+
|
|
28
|
+
TIME_FORMAT = "%Y-%m-%d %H:%M:%S %z"
|
|
29
|
+
|
|
30
|
+
# One entry.
|
|
31
|
+
#
|
|
32
|
+
# @!attribute [r] number
|
|
33
|
+
# @return [Integer] 1-based, in order of writing
|
|
34
|
+
# @!attribute [r] at
|
|
35
|
+
# @return [String] when, as {TIME_FORMAT} writes it
|
|
36
|
+
# @!attribute [r] from
|
|
37
|
+
# @return [String] the agent that wrote it
|
|
38
|
+
# @!attribute [r] to
|
|
39
|
+
# @return [String] the agent it is for
|
|
40
|
+
# @!attribute [r] body
|
|
41
|
+
# @return [String]
|
|
42
|
+
Message = Data.define(:number, :at, :from, :to, :body) do
|
|
43
|
+
# @return [String] as it stands in the file
|
|
44
|
+
def to_s = "## #{number}#{SEPARATOR}#{at}#{SEPARATOR}#{from} → #{to}\n\n#{body.strip}\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param dir [String] the plan folder
|
|
48
|
+
def initialize(dir:)
|
|
49
|
+
@dir = dir
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [String] the plan folder
|
|
53
|
+
attr_reader :dir
|
|
54
|
+
|
|
55
|
+
# @return [String]
|
|
56
|
+
def path = File.join(dir, FILENAME)
|
|
57
|
+
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def exist? = File.file?(path)
|
|
60
|
+
|
|
61
|
+
# Every message, in the order it was written.
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<Agentilda::Mailbox::Message>]
|
|
64
|
+
def messages
|
|
65
|
+
return [] unless exist?
|
|
66
|
+
|
|
67
|
+
parse(File.read(path, encoding: "UTF-8"))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# What is waiting for one agent.
|
|
71
|
+
#
|
|
72
|
+
# @param name [String] the reader
|
|
73
|
+
# @param after [Integer] a number the reader has already seen; only
|
|
74
|
+
# messages above it are returned
|
|
75
|
+
# @return [Array<Agentilda::Mailbox::Message>]
|
|
76
|
+
def for(name, after: 0)
|
|
77
|
+
messages.select { |m| m.to == name && m.number > after }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Append one message and give it the next number.
|
|
81
|
+
#
|
|
82
|
+
# The append takes a lock on the file. Two agents finishing a unit in the
|
|
83
|
+
# same second would otherwise read the same last number and both write
|
|
84
|
+
# the one after it.
|
|
85
|
+
#
|
|
86
|
+
# @param from [String]
|
|
87
|
+
# @param to [String]
|
|
88
|
+
# @param body [String]
|
|
89
|
+
# @param at [Time]
|
|
90
|
+
# @return [Agentilda::Mailbox::Message] as written
|
|
91
|
+
# @raise [Agentilda::Error] on an empty body, which would be a heading
|
|
92
|
+
# with nothing under it for the reader to act on, or on a sender or
|
|
93
|
+
# recipient that is blank or holds whitespace: {HEADING} could not
|
|
94
|
+
# parse such an entry back, so it would be written and never read
|
|
95
|
+
def append(from:, to:, body:, at: Time.now)
|
|
96
|
+
text = body.to_s.strip
|
|
97
|
+
raise Error, "a message needs a body" if text.empty?
|
|
98
|
+
raise Error, "a message needs --from and --to, each one agent name" unless name?(from) && name?(to)
|
|
99
|
+
|
|
100
|
+
File.open(path, File::RDWR | File::CREAT | File::APPEND, 0o644) do |f|
|
|
101
|
+
f.flock(File::LOCK_EX)
|
|
102
|
+
f.rewind
|
|
103
|
+
existing = f.read
|
|
104
|
+
number = (parse(existing).last&.number || 0) + 1
|
|
105
|
+
message = Message.new(number:, at: at.strftime(TIME_FORMAT), from:, to:, body: text)
|
|
106
|
+
f.write(existing.empty? ? "# Mailbox\n\n#{message}" : "\n#{message}")
|
|
107
|
+
message
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# @param value [Object]
|
|
114
|
+
# @return [Boolean] one token, as {HEADING} expects a name to be
|
|
115
|
+
def name?(value) = value.to_s.match?(/\A\S+\z/)
|
|
116
|
+
|
|
117
|
+
# Only a line shaped exactly like {HEADING} starts a message, so a body
|
|
118
|
+
# may hold headings of its own, code, anything.
|
|
119
|
+
#
|
|
120
|
+
# @param text [String]
|
|
121
|
+
# @return [Array<Agentilda::Mailbox::Message>]
|
|
122
|
+
def parse(text)
|
|
123
|
+
messages = []
|
|
124
|
+
current = nil
|
|
125
|
+
body = []
|
|
126
|
+
|
|
127
|
+
text.to_s.each_line(chomp: true) do |line|
|
|
128
|
+
if (heading = HEADING.match(line))
|
|
129
|
+
messages << current.with(body: body.join("\n").strip) if current
|
|
130
|
+
current = Message.new(number: heading[1].to_i, at: heading[2], from: heading[3], to: heading[4], body: "")
|
|
131
|
+
body = []
|
|
132
|
+
elsif current
|
|
133
|
+
body << line
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
messages << current.with(body: body.join("\n").strip) if current
|
|
138
|
+
messages
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
data/lib/agentilda/markdown.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "agentilda/status"
|
|
4
|
+
require "agentilda/ui"
|
|
5
|
+
|
|
3
6
|
module Agentilda
|
|
4
7
|
# One line of `run --log FILE`, laid out as fixed-width columns.
|
|
5
8
|
#
|
|
@@ -18,6 +21,7 @@ module Agentilda
|
|
|
18
21
|
# @example
|
|
19
22
|
# ProgressLog.render("editing spec.md", plan: "003.00", status: "⭐️ Planned",
|
|
20
23
|
# agent: "yoda-writer", seconds: 42, round: "01", pid: 91_234)
|
|
24
|
+
|
|
21
25
|
# #=> "[16:22:14 | 003.00 | ⭐️ Planned | yoda-writer | 01 | 91234 | 42s] editing spec.md"
|
|
22
26
|
module ProgressLog
|
|
23
27
|
# Wall clock only. A log line answers "when", and the date is the file's.
|
|
@@ -60,7 +64,7 @@ module Agentilda
|
|
|
60
64
|
# that line happens to be missing, so a reader indenting a wrapped message
|
|
61
65
|
# has a number to indent by.
|
|
62
66
|
COLUMN_WIDTHS = [TIME_WIDTH, PLAN_WIDTH, STATUS_WIDTH, AGENT_WIDTH, ROUND_WIDTH, PID_WIDTH,
|
|
63
|
-
|
|
67
|
+
SECONDS_WIDTH].freeze
|
|
64
68
|
|
|
65
69
|
# @see COLUMN_WIDTHS
|
|
66
70
|
LINE_WIDTH = COLUMN_WIDTHS.sum + (SEPARATOR.length * (COLUMN_WIDTHS.size - 1)) + 2
|
data/lib/agentilda/publisher.rb
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "tty/command"
|
|
5
|
+
|
|
3
6
|
module Agentilda
|
|
4
7
|
# Turns a finished worktree into a pushed branch and a pull request.
|
|
5
8
|
#
|
|
6
9
|
# This is the one place the harness reaches off the machine, so it requires
|
|
7
|
-
# `--commit`, is skipped with `--
|
|
10
|
+
# `--commit`, is skipped with `--no-git-push`, and refuses anything
|
|
8
11
|
# it is not certain about. Everything it does is reported before it does it.
|
|
9
12
|
#
|
|
10
13
|
# Titles follow the convention the whole system joins on:
|
|
@@ -80,7 +83,7 @@ module Agentilda
|
|
|
80
83
|
stderr = lines.find { |l| l.start_with?("stderr:") }
|
|
81
84
|
complaint = stderr&.delete_prefix("stderr:")&.strip
|
|
82
85
|
|
|
83
|
-
|
|
86
|
+
complaint.to_s.empty? || complaint == "Nothing written" ? lines.first.to_s : complaint
|
|
84
87
|
end
|
|
85
88
|
|
|
86
89
|
# The title a plan's next pull request should carry.
|
|
@@ -104,7 +107,7 @@ module Agentilda
|
|
|
104
107
|
|
|
105
108
|
# @return [Hash]
|
|
106
109
|
def base_fields(subject, checkout, title)
|
|
107
|
-
{ordinal: subject.feature.ordinal, branch: checkout.branch, title:}
|
|
110
|
+
{ ordinal: subject.feature.ordinal, branch: checkout.branch, title: }
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
# @return [Agentilda::Publisher::Publication]
|
|
@@ -24,190 +24,4 @@ module Agentilda
|
|
|
24
24
|
# @return [String] "#92 — Send mail through Resend"
|
|
25
25
|
def label = number ? "##{number} — #{title}" : title
|
|
26
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
27
|
end
|
|
@@ -0,0 +1,230 @@
|
|
|
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
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "agentilda/status"
|
|
4
|
+
|
|
3
5
|
module Agentilda
|
|
4
6
|
# `/spec-status` — every plan, its state in icon and words, and its pull
|
|
5
7
|
# requests as clickable links.
|
|
@@ -76,8 +78,12 @@ module Agentilda
|
|
|
76
78
|
#
|
|
77
79
|
# @return [Integer]
|
|
78
80
|
def pr_column
|
|
79
|
-
@pr_column ||= row(" ",
|
|
80
|
-
" " *
|
|
81
|
+
@pr_column ||= row(" ",
|
|
82
|
+
" " * ORDINAL_WIDTH,
|
|
83
|
+
" " * EMOJI_WIDTH,
|
|
84
|
+
" " * label_width,
|
|
85
|
+
" " * title_width,
|
|
86
|
+
"").length
|
|
81
87
|
end
|
|
82
88
|
|
|
83
89
|
# @return [Integer] cell at which the feature name starts
|
|
@@ -158,7 +164,7 @@ module Agentilda
|
|
|
158
164
|
# @return [String]
|
|
159
165
|
def footer
|
|
160
166
|
counts = totals.map { |key, n| "#{STATUS_BY_KEY.fetch(key).emoji} #{n}" }.join(" ")
|
|
161
|
-
lines = [" #{tree.subjects.size} #{
|
|
167
|
+
lines = [" #{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} #{counts}"]
|
|
162
168
|
|
|
163
169
|
unless inconsistent.empty?
|
|
164
170
|
lines << " #{UI.paint("#{inconsistent.size} with a status their contents do not justify", :red)}"
|