agentilda 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +261 -0
- data/agentilda.gemspec +57 -0
- data/agents/hansolo-reviewer.md +29 -0
- data/agents/lando-broker.md +74 -0
- data/agents/leah-researcher.md +80 -0
- data/agents/luke-backend.md +81 -0
- data/agents/palpatine-planner.md +40 -0
- data/agents/rey-frontend.md +106 -0
- data/agents/yoda-writer.md +54 -0
- data/bin/create-plan-folder +125 -0
- data/bin/plan-number +164 -0
- data/exe/agentilda +111 -0
- data/exe/tilda +1 -0
- data/lib/agentilda/adoption.rb +192 -0
- data/lib/agentilda/agent.rb +136 -0
- data/lib/agentilda/brief.rb +234 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
- data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
- data/lib/agentilda/cli/base.rb +88 -0
- data/lib/agentilda/cli/create/create.rb +309 -0
- data/lib/agentilda/cli/docs/docs.rb +30 -0
- data/lib/agentilda/cli/index/index.rb +38 -0
- data/lib/agentilda/cli/linear/linear.rb +35 -0
- data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
- data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
- data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
- data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
- data/lib/agentilda/cli/run/run.rb +289 -0
- data/lib/agentilda/cli/states/states.rb +15 -0
- data/lib/agentilda/cli/unblock/unblock.rb +227 -0
- data/lib/agentilda/cli/version/version.rb +13 -0
- data/lib/agentilda/cli.rb +74 -0
- data/lib/agentilda/config.rb +44 -0
- data/lib/agentilda/control.rb +115 -0
- data/lib/agentilda/creator.rb +120 -0
- data/lib/agentilda/dev_work.rb +54 -0
- data/lib/agentilda/diagram.rb +144 -0
- data/lib/agentilda/documentation.rb +429 -0
- data/lib/agentilda/executor.rb +539 -0
- data/lib/agentilda/feature.rb +253 -0
- data/lib/agentilda/frontmatter.rb +36 -0
- data/lib/agentilda/github.rb +160 -0
- data/lib/agentilda/index.rb +206 -0
- data/lib/agentilda/keyboard.rb +88 -0
- data/lib/agentilda/linear/api.rb +220 -0
- data/lib/agentilda/linear/attribution.rb +185 -0
- data/lib/agentilda/linear/fuzzy.rb +68 -0
- data/lib/agentilda/linear/import.rb +298 -0
- data/lib/agentilda/linear/issue.rb +184 -0
- data/lib/agentilda/linear/mapping.rb +115 -0
- data/lib/agentilda/linear/push.rb +190 -0
- data/lib/agentilda/linear/survey.rb +173 -0
- data/lib/agentilda/linear/unit.rb +274 -0
- data/lib/agentilda/linear.rb +42 -0
- data/lib/agentilda/markdown.rb +56 -0
- data/lib/agentilda/ordinal.rb +90 -0
- data/lib/agentilda/progress_log.rb +122 -0
- data/lib/agentilda/publisher.rb +172 -0
- data/lib/agentilda/pull_request.rb +213 -0
- data/lib/agentilda/reporter.rb +175 -0
- data/lib/agentilda/resync.rb +358 -0
- data/lib/agentilda/roster.rb +110 -0
- data/lib/agentilda/runner.rb +456 -0
- data/lib/agentilda/state_machine.rb +355 -0
- data/lib/agentilda/status.rb +280 -0
- data/lib/agentilda/tally.rb +169 -0
- data/lib/agentilda/transcript.rb +435 -0
- data/lib/agentilda/tree.rb +77 -0
- data/lib/agentilda/ui.rb +681 -0
- data/lib/agentilda/unblocker.rb +207 -0
- data/lib/agentilda/version.rb +10 -0
- data/lib/agentilda/viewer.rb +60 -0
- data/lib/agentilda/worktree.rb +211 -0
- data/lib/agentilda.rb +155 -0
- data/lib/dry/cli/banner.rb +293 -0
- metadata +349 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# Lowercase in the middle of a title, capitalised at the front.
|
|
5
|
+
SMALL_WORDS = %w[a an and as at but by for from in into nor of on or per the to via vs with].freeze
|
|
6
|
+
|
|
7
|
+
# Slug words that are really acronyms and should shout.
|
|
8
|
+
ACRONYMS = %w[
|
|
9
|
+
abac ai api aws cdn ci cd cli cms cors cpu crm css csv db dns dsl e2e ec2 etl gcp gdpr gpu gui
|
|
10
|
+
html http https iam id ide jwt json k8s llm ml mcp mvp npm oauth orm otp pdf pii poc pr prs qa
|
|
11
|
+
rbac rds rest rls rpc rss s3 saas sdk seo sns spa sql sqs sre ssh sso ssl ssr tls tui ts tsx ui
|
|
12
|
+
ux uuid vpc vpn xml yaml
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# Words with a house spelling that neither capitalize nor upcase gets right.
|
|
16
|
+
SPECIAL_CASE = {
|
|
17
|
+
"github" => "GitHub", "gitlab" => "GitLab", "graphql" => "GraphQL", "ios" => "iOS",
|
|
18
|
+
"javascript" => "JavaScript", "macos" => "macOS", "nodejs" => "Node.js", "oauth" => "OAuth",
|
|
19
|
+
"openai" => "OpenAI", "postgres" => "PostgreSQL", "postgresql" => "PostgreSQL",
|
|
20
|
+
"typescript" => "TypeScript", "uuidv7" => "UUIDv7", "websocket" => "WebSocket"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Turn a kebab slug into a proper name: `law-as-data` → "Law as Data".
|
|
24
|
+
#
|
|
25
|
+
# @param slug [String]
|
|
26
|
+
# @return [String]
|
|
27
|
+
def self.titleize(slug)
|
|
28
|
+
words = slug.to_s.split(/[-_\s.]+/).reject(&:empty?)
|
|
29
|
+
return "" if words.empty?
|
|
30
|
+
|
|
31
|
+
words.each_with_index.map { |word, i|
|
|
32
|
+
lower = word.downcase
|
|
33
|
+
if SPECIAL_CASE.key?(lower)
|
|
34
|
+
SPECIAL_CASE[lower]
|
|
35
|
+
elsif ACRONYMS.include?(lower)
|
|
36
|
+
lower.upcase
|
|
37
|
+
elsif i.positive? && SMALL_WORDS.include?(lower)
|
|
38
|
+
lower
|
|
39
|
+
elsif lower.match?(/\A\d+\z/)
|
|
40
|
+
lower
|
|
41
|
+
else
|
|
42
|
+
lower.sub(/\A./, &:upcase)
|
|
43
|
+
end
|
|
44
|
+
}.join(" ")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The one place a plan folder's name is spelled out: `NNN.MM-<emoji>--<slug>`.
|
|
48
|
+
#
|
|
49
|
+
# The double dash after the emoji is deliberate: an emoji renders two cells
|
|
50
|
+
# wide and visually swallows a single dash beside it, so `🔎-refactor` reads
|
|
51
|
+
# as if the emoji and the slug were touching. Everything that mints or
|
|
52
|
+
# renames a folder goes through here; {Feature.parse} accepts the older
|
|
53
|
+
# single-dash spelling too, and `resync dirs` normalises it on contact.
|
|
54
|
+
#
|
|
55
|
+
# @param ordinal [Agentilda::Ordinal, String]
|
|
56
|
+
# @param status [Agentilda::Status]
|
|
57
|
+
# @param slug [String]
|
|
58
|
+
# @return [String]
|
|
59
|
+
def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji}--#{slug}"
|
|
60
|
+
|
|
61
|
+
# One `NNN.MM-<emoji>--<slug>` folder, decoded.
|
|
62
|
+
#
|
|
63
|
+
# @!attribute [r] ordinal
|
|
64
|
+
# @return [Agentilda::Ordinal]
|
|
65
|
+
# @!attribute [r] status
|
|
66
|
+
# @return [Agentilda::Status]
|
|
67
|
+
# @!attribute [r] slug
|
|
68
|
+
# @return [String]
|
|
69
|
+
# @!attribute [r] dirname
|
|
70
|
+
# @return [String] exactly as it is on disk
|
|
71
|
+
# @!attribute [r] path
|
|
72
|
+
# @return [String] absolute
|
|
73
|
+
Feature = Data.define(:ordinal, :status, :slug, :dirname, :path) do
|
|
74
|
+
include Comparable
|
|
75
|
+
|
|
76
|
+
# Decode a folder name, or return nil when it is not a plan folder.
|
|
77
|
+
#
|
|
78
|
+
# @param path [String] absolute path to a candidate directory
|
|
79
|
+
# @return [Agentilda::Feature, nil]
|
|
80
|
+
def self.parse(path)
|
|
81
|
+
dirname = File.basename(path)
|
|
82
|
+
ordinal = Ordinal.from_dirname(dirname) or return nil
|
|
83
|
+
|
|
84
|
+
rest = dirname.sub(/\A[\d.]+[-_]/, "")
|
|
85
|
+
head, tail = rest.split(/[-_]/, 2)
|
|
86
|
+
|
|
87
|
+
# A leading segment with no ASCII word character is the status emoji.
|
|
88
|
+
# The slug strips any further separators: the canonical spelling puts
|
|
89
|
+
# two dashes after the emoji, and folders from before that rule put one.
|
|
90
|
+
status = (Agentilda.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
|
|
91
|
+
slug = status ? tail.sub(/\A[-_]+/, "") : rest
|
|
92
|
+
|
|
93
|
+
new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [String] proper name, e.g. "Law as Data"
|
|
97
|
+
def title = Agentilda.titleize(slug)
|
|
98
|
+
|
|
99
|
+
# The folder name this feature would carry in a given state — the slug
|
|
100
|
+
# never moves, and the number is always rendered canonically, so this is
|
|
101
|
+
# also what repairs a folder written `018-⚪️--foo` before the `NNN.MM` rule.
|
|
102
|
+
#
|
|
103
|
+
# @param status [Agentilda::Status]
|
|
104
|
+
# @return [String]
|
|
105
|
+
def dirname_as(status) = Agentilda.plan_dirname(ordinal, status, slug)
|
|
106
|
+
|
|
107
|
+
# The number exactly as the folder writes it, which is not always the
|
|
108
|
+
# canonical rendering: `018-⚪️--foo` yields "018" where {#ordinal} renders
|
|
109
|
+
# "018.00".
|
|
110
|
+
#
|
|
111
|
+
# @return [String]
|
|
112
|
+
def dirname_ordinal = dirname.to_s[/\A[\d.]+/].to_s
|
|
113
|
+
|
|
114
|
+
# @return [Boolean] whether the number is written in full `NNN.MM` form
|
|
115
|
+
def padded? = dirname_ordinal == ordinal.to_s
|
|
116
|
+
|
|
117
|
+
# Whether the folder is already named the way this tool would name it —
|
|
118
|
+
# number padded, emoji matching the state it claims, slug unchanged.
|
|
119
|
+
#
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def canonical? = dirname == dirname_as(status)
|
|
122
|
+
|
|
123
|
+
# @param other [Object]
|
|
124
|
+
# @return [Integer, nil]
|
|
125
|
+
def <=>(other) = other.is_a?(self.class) ? [ordinal, dirname] <=> [other.ordinal, other.dirname] : nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A plan folder as the state machine sees it: which files exist, what they
|
|
129
|
+
# say, and which pull requests they record.
|
|
130
|
+
#
|
|
131
|
+
# It exists because the invariants ask the same questions repeatedly and
|
|
132
|
+
# {Feature} is a frozen `Data` with nowhere to memoize the answers.
|
|
133
|
+
class Subject
|
|
134
|
+
# @param feature [Agentilda::Feature]
|
|
135
|
+
def initialize(feature)
|
|
136
|
+
@feature = feature
|
|
137
|
+
@reads = {}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Agentilda::Feature]
|
|
141
|
+
attr_reader :feature
|
|
142
|
+
|
|
143
|
+
# @return [Agentilda::Status] the status the folder name claims
|
|
144
|
+
def status = feature.status
|
|
145
|
+
|
|
146
|
+
# @param name [String] a bare filename
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
def file?(name) = File.file?(File.join(feature.path, name))
|
|
149
|
+
|
|
150
|
+
# @param name [String]
|
|
151
|
+
# @return [String, nil] contents, or nil when absent
|
|
152
|
+
def read(name)
|
|
153
|
+
@reads.fetch(name) do
|
|
154
|
+
@reads[name] = file?(name) ? File.read(File.join(feature.path, name), encoding: "UTF-8") : nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [Array<Agentilda::PullRequest>]
|
|
159
|
+
def pull_requests = @pull_requests ||= PullRequests.new(dir: feature.path).all
|
|
160
|
+
|
|
161
|
+
# The specification's own Goal section, verbatim and at most two
|
|
162
|
+
# paragraphs. Anything that paraphrases the spec is a second copy that
|
|
163
|
+
# drifts; quoting it is not — which is why the pull request body and the
|
|
164
|
+
# index both read it from here rather than each writing their own.
|
|
165
|
+
#
|
|
166
|
+
# @return [Array<String>] paragraphs, empty when there is no Goal to read
|
|
167
|
+
def goal
|
|
168
|
+
body = read("spec.md").to_s
|
|
169
|
+
section = body[/^\#{"#"}{2,3}\s*Goals?\b[^\n]*\n+(.*?)(?=\n\#{"#"}{1,3}\s|\z)/mi, 1]
|
|
170
|
+
|
|
171
|
+
paragraphs(section) || paragraphs(body.sub(/\A\s*\#{"#"}[^\n]*\n/, "")) || []
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Specifications written before the template existed have no Goal section,
|
|
175
|
+
# and they are exactly the ones an index most needs to describe. So the
|
|
176
|
+
# opening prose stands in — skipping headings, quotes, lists and tables,
|
|
177
|
+
# which describe the document rather than the work.
|
|
178
|
+
#
|
|
179
|
+
# @param text [String, nil]
|
|
180
|
+
# @return [Array<String>, nil] nil when there is no prose to be had
|
|
181
|
+
def paragraphs(text)
|
|
182
|
+
found = text.to_s.strip.split(/\n{2,}/)
|
|
183
|
+
.map(&:strip)
|
|
184
|
+
.reject { |p| p.empty? || p.match?(/\A[\#>|\-*\d`_=]/) }
|
|
185
|
+
.first(2)
|
|
186
|
+
|
|
187
|
+
found.empty? ? nil : found
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The questions `blocked.md` still names, by number.
|
|
191
|
+
#
|
|
192
|
+
# Empty means one of two very different things, and a caller that treats
|
|
193
|
+
# them alike is how a folder with thirty kilobytes of open questions gets
|
|
194
|
+
# reported as "nothing left open": either there is no `blocked.md` at all,
|
|
195
|
+
# or there is one whose questions are not written as `## B<n>` and are
|
|
196
|
+
# therefore invisible to every part of this tool. Ask {#file?} which.
|
|
197
|
+
#
|
|
198
|
+
# @return [Array<Integer>]
|
|
199
|
+
def open_blocks = Agentilda.block_numbers(read("blocked.md"), OPEN_BLOCK)
|
|
200
|
+
|
|
201
|
+
# The answers waiting in `blocked.md`, by number. `## A1` settles `## B1`.
|
|
202
|
+
#
|
|
203
|
+
# Waiting, not folded. An `## A<n>` heading is not the same as a settled
|
|
204
|
+
# question: one may say in its own body that it is a draft pending a
|
|
205
|
+
# conversation. `lando-broker` makes that call; this only counts headings.
|
|
206
|
+
#
|
|
207
|
+
# @return [Array<Integer>]
|
|
208
|
+
def block_answers = Agentilda.block_numbers(read("blocked.md"), ANSWER_BLOCK)
|
|
209
|
+
|
|
210
|
+
# A `blocked.md` this tool cannot read: the file is there, and not one
|
|
211
|
+
# question in it is written as `## B<n>`. Nothing can drain it and nothing
|
|
212
|
+
# currently says so, which is the whole reason this exists.
|
|
213
|
+
#
|
|
214
|
+
# @return [Boolean]
|
|
215
|
+
def unreadable_block? = file?("blocked.md") && open_blocks.empty?
|
|
216
|
+
|
|
217
|
+
# @return [String, nil] why the folder's name is not justified
|
|
218
|
+
def violation = status.violation(self)
|
|
219
|
+
|
|
220
|
+
# @return [Boolean] whether the name matches the contents
|
|
221
|
+
def consistent? = violation.nil?
|
|
222
|
+
|
|
223
|
+
# @return [Agentilda::StateMachine] positioned at the current state
|
|
224
|
+
def machine = StateMachine.new(self)
|
|
225
|
+
|
|
226
|
+
# @return [Array<Symbol>] states reachable right now, guards applied
|
|
227
|
+
def allowed = machine.allowed
|
|
228
|
+
|
|
229
|
+
# @return [Agentilda::Status, nil] the state these contents justify
|
|
230
|
+
def best_fit = machine.best_fit
|
|
231
|
+
|
|
232
|
+
# Move the folder into +status+ — the side effect a transition *is*.
|
|
233
|
+
#
|
|
234
|
+
# The {Feature} is a frozen `Data` holding the old name, so it is replaced
|
|
235
|
+
# rather than mutated, and the memoized reads go with it.
|
|
236
|
+
#
|
|
237
|
+
# @param status [Agentilda::Status]
|
|
238
|
+
# @return [Agentilda::Feature] the feature under its new name
|
|
239
|
+
# @raise [Agentilda::Error] when the target name is already taken
|
|
240
|
+
def rename_to(status)
|
|
241
|
+
return @feature if status.key == @feature.status.key
|
|
242
|
+
|
|
243
|
+
target = File.join(File.dirname(@feature.path), @feature.dirname_as(status))
|
|
244
|
+
unless Agentilda.move_directory(@feature.path, target)
|
|
245
|
+
raise Error, "cannot rename #{@feature.dirname} — #{File.basename(target)} already exists"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
@reads = {}
|
|
249
|
+
@pull_requests = nil
|
|
250
|
+
@feature = Feature.parse(target) or raise Error, "#{File.basename(target)} is not a plan folder"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Agentilda
|
|
7
|
+
# Splits a markdown file into its YAML frontmatter and its body.
|
|
8
|
+
#
|
|
9
|
+
# The agent definitions and `create --from` both read frontmatter, and both
|
|
10
|
+
# used to call `YAML.safe_load` on their own. Its defaults refuse to build a
|
|
11
|
+
# `Date`, so an ordinary `date: 2026-08-31` raised Psych::DisallowedClass and
|
|
12
|
+
# `create` reported a missing `title:` on a file whose title was right there.
|
|
13
|
+
# One parser, one list of permitted classes, so that cannot happen twice.
|
|
14
|
+
module Frontmatter
|
|
15
|
+
# Frontmatter, then body.
|
|
16
|
+
PATTERN = /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
|
|
17
|
+
|
|
18
|
+
# Dates and timestamps are ordinary frontmatter, so they load. Nothing else
|
|
19
|
+
# does: the point of `safe_load` is that a seed file cannot name a class.
|
|
20
|
+
PERMITTED_CLASSES = [Date, Time].freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# @param content [String] a whole markdown file
|
|
24
|
+
# @return [Array(Hash, String)] the frontmatter and the body. A file with
|
|
25
|
+
# no frontmatter is all body, and frontmatter that is not a mapping —
|
|
26
|
+
# a bare list, a lone string — reads as no keys rather than raising.
|
|
27
|
+
# @raise [Psych::Exception] when the frontmatter is not valid YAML
|
|
28
|
+
def split(content)
|
|
29
|
+
match = PATTERN.match(content) or return [{}, content]
|
|
30
|
+
|
|
31
|
+
meta = YAML.safe_load(match[1], permitted_classes: PERMITTED_CLASSES)
|
|
32
|
+
[meta.is_a?(Hash) ? meta : {}, match[2]]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
# The `gh` CLI, wrapped thinly.
|
|
7
|
+
#
|
|
8
|
+
# It is a seam rather than a convenience: every example in the suite injects
|
|
9
|
+
# a double here, so nothing in the tests reaches the network or a real
|
|
10
|
+
# repository.
|
|
11
|
+
class GitHub
|
|
12
|
+
# Fields asked of `gh pr list`.
|
|
13
|
+
FIELDS = %w[number title url headRefName files state isDraft mergedAt].freeze
|
|
14
|
+
|
|
15
|
+
# @param command [TTY::Command] runner, injectable for tests
|
|
16
|
+
# @param limit [Integer] how many pull requests to fetch
|
|
17
|
+
def initialize(command: TTY::Command.new(printer: :null), limit: 200)
|
|
18
|
+
@command = command
|
|
19
|
+
@limit = limit
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Every pull request, normalised into plain hashes.
|
|
23
|
+
#
|
|
24
|
+
# @param state [String] "open", "closed", "merged" or "all"
|
|
25
|
+
# @return [Array<Hash>] `{number:, title:, url:, branch:, files:}`
|
|
26
|
+
def pulls(state: "all")
|
|
27
|
+
out = UI.spinning("Fetching pull requests from GitHub") {
|
|
28
|
+
@command.run("gh", "pr", "list", "--state", state, "--limit", @limit.to_s,
|
|
29
|
+
"--json", FIELDS.join(",")).out
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# `gh` can exit 0 having printed NOTHING — most often when it cannot reach
|
|
33
|
+
# the credential store, as in a non-interactive shell that has no keyring
|
|
34
|
+
# access, or when GH_TOKEN is set to something invalid and shadows a
|
|
35
|
+
# working login. Left alone this parses as a JSON error and reports as
|
|
36
|
+
# "bad output", sending you to look at the wrong thing entirely.
|
|
37
|
+
raise Error, no_output_message if out.to_s.strip.empty?
|
|
38
|
+
|
|
39
|
+
JSON.parse(out).map do |pr|
|
|
40
|
+
{
|
|
41
|
+
number: pr["number"],
|
|
42
|
+
title: pr["title"].to_s,
|
|
43
|
+
url: pr["url"],
|
|
44
|
+
branch: pr["headRefName"].to_s,
|
|
45
|
+
files: Array(pr["files"]).map { |f| f["path"] }.compact,
|
|
46
|
+
state: self.class.state_label(pr),
|
|
47
|
+
open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
51
|
+
raise Error, "could not list pull requests via `gh`: #{e.message.lines.first.to_s.strip}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String] the diagnosis for a silent `gh`
|
|
55
|
+
def no_output_message
|
|
56
|
+
<<~MESSAGE.strip
|
|
57
|
+
`gh` produced no output and did not report an error.
|
|
58
|
+
|
|
59
|
+
That is almost always authentication rather than an empty repository:
|
|
60
|
+
|
|
61
|
+
- Check `gh auth status`. An invalid GH_TOKEN in the environment
|
|
62
|
+
shadows a working keyring login and fails without saying so.
|
|
63
|
+
|
|
64
|
+
- A non-interactive shell may have no access to the system keyring
|
|
65
|
+
even when an interactive one does.
|
|
66
|
+
|
|
67
|
+
Verify with: gh pr list --state all --limit 1
|
|
68
|
+
MESSAGE
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Fields asked of `gh pr view`, which unlike `pr list` can be told about
|
|
72
|
+
# one pull request in another repository.
|
|
73
|
+
VIEW_FIELDS = %w[number title url state isDraft mergedAt body].freeze
|
|
74
|
+
|
|
75
|
+
# A reference to one pull request: a bare number, a `#`-prefixed number,
|
|
76
|
+
# or a full URL to a GitHub pull request or a GitLab merge request.
|
|
77
|
+
REF = %r{\A(?:\#?\d+|https?://\S+?/(?:pull|merge_requests)/\d+/?)\z}
|
|
78
|
+
|
|
79
|
+
# Split and validate a `--prs` value before any of it reaches the network,
|
|
80
|
+
# so a typo fails in a hundredth of a second with the offending token named
|
|
81
|
+
# rather than after four round trips with a `gh` diagnostic.
|
|
82
|
+
#
|
|
83
|
+
# @param text [String] e.g. "12,15,18" or "https://…/pull/12, #15"
|
|
84
|
+
# @return [Array<String>] references, in the order given, de-duplicated
|
|
85
|
+
# @raise [Agentilda::Error] on anything that is not a reference
|
|
86
|
+
def self.parse_refs(text)
|
|
87
|
+
refs = text.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
88
|
+
raise Error, "no pull requests given" if refs.empty?
|
|
89
|
+
|
|
90
|
+
bad = refs.reject { |r| r.match?(REF) }
|
|
91
|
+
unless bad.empty?
|
|
92
|
+
raise Error, "not a pull request number or URL: #{bad.join(", ")}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
refs.uniq
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# One pull request, by number or URL.
|
|
99
|
+
#
|
|
100
|
+
# @param ref [String] "12", "#12" or "https://github.com/o/r/pull/12"
|
|
101
|
+
# @return [Hash] `{number:, title:, url:, state:, body:}`
|
|
102
|
+
# @raise [Agentilda::Error]
|
|
103
|
+
def pull_request(ref)
|
|
104
|
+
out = @command.run("gh", "pr", "view", ref.to_s, "--json", VIEW_FIELDS.join(",")).out
|
|
105
|
+
raise Error, no_output_message if out.to_s.strip.empty?
|
|
106
|
+
|
|
107
|
+
pr = JSON.parse(out)
|
|
108
|
+
{
|
|
109
|
+
number: pr["number"],
|
|
110
|
+
title: pr["title"].to_s,
|
|
111
|
+
url: pr["url"].to_s,
|
|
112
|
+
state: self.class.state_label(pr),
|
|
113
|
+
body: pr["body"].to_s
|
|
114
|
+
}
|
|
115
|
+
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
116
|
+
raise Error, "could not read pull request #{ref}: #{e.message.lines.first.to_s.strip}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Several pull requests, in the order asked for.
|
|
120
|
+
#
|
|
121
|
+
# @param refs [Array<String>]
|
|
122
|
+
# @return [Array<Hash>]
|
|
123
|
+
def pull_requests(refs)
|
|
124
|
+
UI.stepping(refs, "Fetching pull requests") { |ref| ref }
|
|
125
|
+
refs.map { |ref| pull_request(ref) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# `gh` speaks in enums; `pull-requests.md` speaks in the words the state
|
|
129
|
+
# machine parses. Translate once, here, rather than at each call site.
|
|
130
|
+
#
|
|
131
|
+
# @param pr [Hash] a decoded `gh pr view` payload
|
|
132
|
+
# @return [String] one of the labels in {PullRequests::STATES}
|
|
133
|
+
def self.state_label(pr)
|
|
134
|
+
return "Merged 🟣" if pr["mergedAt"]
|
|
135
|
+
return "WIP 🟡" if pr["isDraft"]
|
|
136
|
+
|
|
137
|
+
case pr["state"].to_s.upcase
|
|
138
|
+
when "OPEN" then "Open 🟡"
|
|
139
|
+
when "CLOSED" then "Closed 🔴"
|
|
140
|
+
else "Unknown"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Change a pull request's title.
|
|
145
|
+
#
|
|
146
|
+
# @param number [Integer]
|
|
147
|
+
# @param title [String]
|
|
148
|
+
# @return [void]
|
|
149
|
+
def retitle(number:, title:)
|
|
150
|
+
@command.run("gh", "pr", "edit", number.to_s, "--title", title)
|
|
151
|
+
rescue TTY::Command::ExitError => e
|
|
152
|
+
raise Error, "could not retitle ##{number}: #{e.message.lines.first.to_s.strip}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @return [Boolean] whether `gh` is installed and authenticated
|
|
156
|
+
def available?
|
|
157
|
+
@command.run!("gh", "auth", "status").success?
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "erb"
|
|
5
|
+
|
|
6
|
+
module Agentilda
|
|
7
|
+
# `agentilda index` — the project's plans as one browsable page.
|
|
8
|
+
#
|
|
9
|
+
# {Reporter} answers "what is the state of things" in a terminal, in columns,
|
|
10
|
+
# for someone standing at a prompt. This answers "what is this project, and
|
|
11
|
+
# where is everything" for someone arriving at the repository on the web: the
|
|
12
|
+
# goal of each plan in its own words, every pull request as a link, and every
|
|
13
|
+
# document in the folder one click away.
|
|
14
|
+
#
|
|
15
|
+
# It is generated for the same reason the conventions document is. A
|
|
16
|
+
# hand-written index of twenty plans cannot survive a rename, and renaming is
|
|
17
|
+
# exactly what `resync dirs` exists to do — the first hand-made INDEX.md this
|
|
18
|
+
# replaced had all 82 of its links pointing at folder names that had been
|
|
19
|
+
# padded to `NNN.MM` underneath it.
|
|
20
|
+
class Index
|
|
21
|
+
# The file this writes, relative to the `.plans` directory.
|
|
22
|
+
FILENAME = "INDEX.md"
|
|
23
|
+
|
|
24
|
+
# Documents that get a proper name rather than their filename.
|
|
25
|
+
ARTIFACT_NAMES = {
|
|
26
|
+
"spec.md" => "Spec",
|
|
27
|
+
"plan.md" => "Plan",
|
|
28
|
+
"pull-requests.md" => "Pull Requests",
|
|
29
|
+
"linear.md" => "Linear",
|
|
30
|
+
"blocked.md" => "Blocked",
|
|
31
|
+
"delayed.md" => "Deferred",
|
|
32
|
+
"rewrite.md" => "Rewrite",
|
|
33
|
+
"deployed.md" => "Deployed",
|
|
34
|
+
"rollback.md" => "Rollback",
|
|
35
|
+
"discarded.md" => "Discarded"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# @param tree [Agentilda::Tree]
|
|
39
|
+
# @param project [String, nil] the heading; defaults to the repository name
|
|
40
|
+
def initialize(tree:, project: nil)
|
|
41
|
+
@tree = tree
|
|
42
|
+
@project = project
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Agentilda::Tree]
|
|
46
|
+
attr_reader :tree
|
|
47
|
+
|
|
48
|
+
# @return [String] the project's name, titleized from its directory
|
|
49
|
+
def project = @project ||= Agentilda.titleize(File.basename(File.dirname(tree.dir)))
|
|
50
|
+
|
|
51
|
+
# @return [String] the whole document
|
|
52
|
+
def render
|
|
53
|
+
[heading, *tree.subjects.map { |subject| section(subject) }].join("\n") + footer
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Write it next to the plans it describes.
|
|
57
|
+
#
|
|
58
|
+
# @param path [String, nil] override the destination
|
|
59
|
+
# @return [String] where it was written
|
|
60
|
+
def write(path = nil)
|
|
61
|
+
path ||= File.join(tree.dir, FILENAME)
|
|
62
|
+
File.write(path, render)
|
|
63
|
+
path
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# @return [String]
|
|
69
|
+
def heading
|
|
70
|
+
<<~MARKDOWN
|
|
71
|
+
# Project #{project}
|
|
72
|
+
|
|
73
|
+
> [!IMPORTANT]
|
|
74
|
+
> **This file is auto generated.** To regenerate it, run `agentilda index`.
|
|
75
|
+
> Editing it by hand lasts until the next `resync dirs` renames a folder.
|
|
76
|
+
|
|
77
|
+
## Current specifications and their status
|
|
78
|
+
|
|
79
|
+
MARKDOWN
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# One plan: a heading that carries its number and state, then a table.
|
|
83
|
+
#
|
|
84
|
+
# Raw HTML rather than a markdown table because the cells are not one-liners
|
|
85
|
+
# — a goal is a paragraph or two and a plan may have a dozen pull requests,
|
|
86
|
+
# neither of which a pipe-delimited row can hold.
|
|
87
|
+
#
|
|
88
|
+
# @param subject [Agentilda::Subject]
|
|
89
|
+
# @return [String]
|
|
90
|
+
def section(subject)
|
|
91
|
+
feature = subject.feature
|
|
92
|
+
|
|
93
|
+
<<~MARKDOWN
|
|
94
|
+
## #{feature.ordinal} — #{feature.status.emoji} #{escape(feature.title)}
|
|
95
|
+
|
|
96
|
+
<table>
|
|
97
|
+
<thead>
|
|
98
|
+
<tr>
|
|
99
|
+
<th align="left">Status</th>
|
|
100
|
+
<th align="left">Pull Requests</th>
|
|
101
|
+
<th align="left">What it is</th>
|
|
102
|
+
<th align="left">Artifacts</th>
|
|
103
|
+
</tr>
|
|
104
|
+
</thead>
|
|
105
|
+
<tbody>
|
|
106
|
+
<tr>
|
|
107
|
+
<td valign="top">#{status_cell(subject)}</td>
|
|
108
|
+
<td valign="top">#{pulls_cell(subject)}</td>
|
|
109
|
+
<td valign="top">#{goal_cell(subject)}</td>
|
|
110
|
+
<td valign="top">#{artifacts_cell(feature)}</td>
|
|
111
|
+
</tr>
|
|
112
|
+
</tbody>
|
|
113
|
+
</table>
|
|
114
|
+
|
|
115
|
+
MARKDOWN
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The emoji and the words, kept on one line — a status that wraps mid-label
|
|
119
|
+
# makes the column look like two states rather than one.
|
|
120
|
+
#
|
|
121
|
+
# @param subject [Agentilda::Subject]
|
|
122
|
+
# @return [String]
|
|
123
|
+
def status_cell(subject)
|
|
124
|
+
cell = "#{subject.status.emoji} #{escape(subject.status.label)}"
|
|
125
|
+
return cell if subject.consistent?
|
|
126
|
+
|
|
127
|
+
"#{cell}<br><sub>⚠️ #{escape(subject.violation)}</sub>"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @param subject [Agentilda::Subject]
|
|
131
|
+
# @return [String]
|
|
132
|
+
def pulls_cell(subject)
|
|
133
|
+
prs = subject.pull_requests
|
|
134
|
+
return "<em>—</em>" if prs.empty?
|
|
135
|
+
|
|
136
|
+
items = prs.map { |pr|
|
|
137
|
+
"<li>#{link(pr.url, pr.label)} #{escape(pr.state)}</li>"
|
|
138
|
+
}
|
|
139
|
+
"<ul>#{items.join}</ul>"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @param subject [Agentilda::Subject]
|
|
143
|
+
# @return [String]
|
|
144
|
+
def goal_cell(subject)
|
|
145
|
+
paragraphs = subject.goal
|
|
146
|
+
return "<em>No specification yet.</em>" if paragraphs.empty?
|
|
147
|
+
|
|
148
|
+
paragraphs.map { |p| "<p>#{inline(p)}</p>" }.join
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Markdown inside a raw HTML block is not parsed — GitHub stops parsing
|
|
152
|
+
# markdown the moment it sees a block-level tag — so a goal quoted verbatim
|
|
153
|
+
# would show its own `**` and backticks. Only the three that carry meaning
|
|
154
|
+
# are translated; single-underscore emphasis is deliberately left alone,
|
|
155
|
+
# because `deleted_at` is more common in these documents than italics.
|
|
156
|
+
#
|
|
157
|
+
# @param text [String] markdown, unescaped
|
|
158
|
+
# @return [String] HTML
|
|
159
|
+
def inline(text)
|
|
160
|
+
escape(text)
|
|
161
|
+
.gsub(/\[([^\]]+)\]\((https?:[^)\s]+)\)/) { %(<a href="#{$2}">#{$1}</a>) }
|
|
162
|
+
.gsub(/\*\*([^*]+)\*\*/) { "<strong>#{$1}</strong>" }
|
|
163
|
+
.gsub(/`([^`]+)`/) { "<code>#{$1}</code>" }
|
|
164
|
+
.tr("\n", " ")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Every document in the folder, linked. The folder name is percent-encoded
|
|
168
|
+
# because it contains an emoji, and a raw one in an href is a broken link
|
|
169
|
+
# on GitHub.
|
|
170
|
+
#
|
|
171
|
+
# @param feature [Agentilda::Feature]
|
|
172
|
+
# @return [String]
|
|
173
|
+
def artifacts_cell(feature)
|
|
174
|
+
files = Dir.children(feature.path).select { |f| File.file?(File.join(feature.path, f)) }
|
|
175
|
+
return "<em>empty</em>" if files.empty?
|
|
176
|
+
|
|
177
|
+
# The documents that carry the lifecycle come first, in lifecycle order;
|
|
178
|
+
# whatever else the folder holds follows alphabetically.
|
|
179
|
+
known = ARTIFACT_NAMES.keys
|
|
180
|
+
files = files.sort_by { |f| [known.index(f) || known.size, f] }
|
|
181
|
+
|
|
182
|
+
items = files.map { |file|
|
|
183
|
+
href = "#{ERB::Util.url_encode(feature.dirname)}/#{ERB::Util.url_encode(file)}"
|
|
184
|
+
"<li>#{link(href, ARTIFACT_NAMES.fetch(file, file))}</li>"
|
|
185
|
+
}
|
|
186
|
+
"<ul>#{items.join}</ul>"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @return [String]
|
|
190
|
+
def footer
|
|
191
|
+
counts = tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status] += 1 }
|
|
192
|
+
.map { |status, n| "#{status.emoji} #{n}" }.join(" ")
|
|
193
|
+
|
|
194
|
+
"\n---\n\n#{tree.subjects.size} #{(tree.subjects.size == 1) ? "plan" : "plans"} #{counts}\n"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @param href [String]
|
|
198
|
+
# @param text [String]
|
|
199
|
+
# @return [String]
|
|
200
|
+
def link(href, text) = %(<a href="#{escape(href)}">#{escape(text)}</a>)
|
|
201
|
+
|
|
202
|
+
# @param text [String]
|
|
203
|
+
# @return [String]
|
|
204
|
+
def escape(text) = CGI.escapeHTML(text.to_s)
|
|
205
|
+
end
|
|
206
|
+
end
|