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,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
# Listens for single keypresses while a run is in flight, and turns them
|
|
7
|
+
# into {Control} broadcasts.
|
|
8
|
+
#
|
|
9
|
+
# The listener is a thread reading STDIN in raw mode, which only makes
|
|
10
|
+
# sense when STDIN is a terminal: piped input must never be eaten one byte
|
|
11
|
+
# at a time, and a run under cron has nobody at the keys. {.listen} returns
|
|
12
|
+
# nil in both cases and every caller treats nil as "no keyboard".
|
|
13
|
+
#
|
|
14
|
+
# Raw mode swallows Ctrl-C along with everything else, so ETX is forwarded
|
|
15
|
+
# to the main thread as the Interrupt it would have been — the listener
|
|
16
|
+
# must never make a run harder to kill than it was without one.
|
|
17
|
+
class Keyboard
|
|
18
|
+
# Key → what it does, rendered by {#help} and dispatched by {#handle}.
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
["h ?", "this help"],
|
|
21
|
+
["w", "ask every running agent to wrap up as fast as possible"],
|
|
22
|
+
["n", "ask agents to write out what they have and stop; the loop continues"],
|
|
23
|
+
["q", "write out, stop everything, and quit after a #{Control::GRACE}s grace"],
|
|
24
|
+
["ctrl-c", "interrupt the run, as ever"]
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# @return [Agentilda::Keyboard, nil] a running listener, or nil when
|
|
28
|
+
# STDIN is not a terminal
|
|
29
|
+
def self.listen(input: $stdin)
|
|
30
|
+
return nil unless input.tty?
|
|
31
|
+
|
|
32
|
+
new(input:).start
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param input [IO]
|
|
36
|
+
def initialize(input: $stdin)
|
|
37
|
+
@input = input
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [self]
|
|
41
|
+
def start
|
|
42
|
+
@thread = Thread.new do
|
|
43
|
+
Thread.current.report_on_exception = false
|
|
44
|
+
loop { handle(@input.getch) }
|
|
45
|
+
rescue IOError, Errno::EIO
|
|
46
|
+
# STDIN went away; a keyboard with no keys just stops listening.
|
|
47
|
+
end
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [void]
|
|
52
|
+
def stop
|
|
53
|
+
@thread&.kill
|
|
54
|
+
@thread = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param key [String, nil]
|
|
58
|
+
# @return [void]
|
|
59
|
+
def handle(key)
|
|
60
|
+
case key
|
|
61
|
+
when "h", "?" then UI.popup("Keys", help)
|
|
62
|
+
when "w" then acted("w — agents asked to wrap up") { Control.wrap_up! }
|
|
63
|
+
when "n" then acted("n — agents asked to write out and stop") { Control.stop! }
|
|
64
|
+
when "q" then acted("q — quitting; #{Control::GRACE}s grace to write out") { Control.quit! }
|
|
65
|
+
when "\u0003" then Thread.main.raise(Interrupt)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [String] the bindings, one per line, widest key first
|
|
70
|
+
def help
|
|
71
|
+
width = BINDINGS.map { |key, _| key.length }.max
|
|
72
|
+
BINDINGS.map { |key, does| "#{key.ljust(width)} #{does}" }.join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# A keypress with no acknowledgement looks like a keypress that did
|
|
78
|
+
# nothing, so each one says what it just asked for.
|
|
79
|
+
#
|
|
80
|
+
# @param note [String]
|
|
81
|
+
# @return [void]
|
|
82
|
+
def acted(note)
|
|
83
|
+
yield
|
|
84
|
+
UI.log(note)
|
|
85
|
+
UI.line(note)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Agentilda
|
|
8
|
+
module Linear
|
|
9
|
+
# Linear's GraphQL API, wrapped as thinly as {Agentilda::GitHub} wraps
|
|
10
|
+
# `gh`, and for the same reason: it is a seam. Every example in the suite
|
|
11
|
+
# injects a transport here, so nothing in the tests reaches Linear.
|
|
12
|
+
#
|
|
13
|
+
# Only the dozen operations an import needs are here. This is not a client
|
|
14
|
+
# library, and it should not grow into one — anything Linear can do that
|
|
15
|
+
# `.plans` cannot express does not belong in a tool whose whole premise is
|
|
16
|
+
# that the folder is the source of truth.
|
|
17
|
+
class API
|
|
18
|
+
ENDPOINT = "https://api.linear.app/graphql"
|
|
19
|
+
|
|
20
|
+
# The environment variable holding a personal API key.
|
|
21
|
+
TOKEN_VARIABLE = "LINEAR_API_KEY"
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] the token, from the environment
|
|
24
|
+
def self.token_from_env = ENV[TOKEN_VARIABLE].to_s.strip.then { |t| t.empty? ? nil : t }
|
|
25
|
+
|
|
26
|
+
# @param token [String] a Linear personal API key
|
|
27
|
+
# @param transport [#call, nil] `(query, variables) -> Hash`, for tests
|
|
28
|
+
# @param endpoint [String]
|
|
29
|
+
def initialize(token: nil, transport: nil, endpoint: ENDPOINT)
|
|
30
|
+
@token = token
|
|
31
|
+
@transport = transport
|
|
32
|
+
@endpoint = endpoint
|
|
33
|
+
return if transport || (token && !token.empty?)
|
|
34
|
+
|
|
35
|
+
raise Error, "no Linear token. Set #{TOKEN_VARIABLE}, or use the MCP transport:\n " \
|
|
36
|
+
"agentilda linear import <TEAM> -p <PROJECT> --format json"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The team, its workflow states and its labels, in one round trip.
|
|
40
|
+
#
|
|
41
|
+
# @param key [String] the team key, e.g. "TAX"
|
|
42
|
+
# @return [Hash] `{id:, name:, key:, states: [...], labels: [...]}`
|
|
43
|
+
# @raise [Agentilda::Error] when no team wears that key
|
|
44
|
+
def team(key)
|
|
45
|
+
node = query(TEAM, key: key.to_s.upcase).dig("teams", "nodes")&.first
|
|
46
|
+
raise Error, "no Linear team has the key #{key.to_s.upcase}" unless node
|
|
47
|
+
|
|
48
|
+
{id: node["id"], name: node["name"], key: node["key"],
|
|
49
|
+
states: node.dig("states", "nodes").to_a, labels: node.dig("labels", "nodes").to_a}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param team_id [String]
|
|
53
|
+
# @return [Array<Hash>] `{id:, name:, url:}`
|
|
54
|
+
def projects(team_id) = query(PROJECTS, teamId: team_id).dig("team", "projects", "nodes").to_a
|
|
55
|
+
|
|
56
|
+
# @param input [Hash] `{name:, teamIds:, content:, icon:}`
|
|
57
|
+
# @return [Hash] `{id:, name:, url:}`
|
|
58
|
+
def create_project(input) = unwrap(query(PROJECT_CREATE, input:), "projectCreate", "project")
|
|
59
|
+
|
|
60
|
+
# @param id [String]
|
|
61
|
+
# @param input [Hash]
|
|
62
|
+
# @return [Hash]
|
|
63
|
+
def update_project(id, input) = unwrap(query(PROJECT_UPDATE, id:, input:), "projectUpdate", "project")
|
|
64
|
+
|
|
65
|
+
# @param input [Hash] `{teamId:, projectId:, title:, description:, stateId:, labelIds:}`
|
|
66
|
+
# @return [Hash] `{id:, identifier:, url:}`
|
|
67
|
+
def create_issue(input) = unwrap(query(ISSUE_CREATE, input:), "issueCreate", "issue")
|
|
68
|
+
|
|
69
|
+
# @param id [String] a UUID or an identifier such as "TAX-41"
|
|
70
|
+
# @param input [Hash]
|
|
71
|
+
# @return [Hash]
|
|
72
|
+
def update_issue(id, input) = unwrap(query(ISSUE_UPDATE, id:, input:), "issueUpdate", "issue")
|
|
73
|
+
|
|
74
|
+
# @param name [String]
|
|
75
|
+
# @param team_id [String]
|
|
76
|
+
# @return [Hash] `{id:, name:}`
|
|
77
|
+
def create_label(name, team_id)
|
|
78
|
+
unwrap(query(LABEL_CREATE, input: {name:, teamId: team_id}), "issueLabelCreate", "issueLabel")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param issue_id [String]
|
|
82
|
+
# @param url [String]
|
|
83
|
+
# @param title [String]
|
|
84
|
+
# @return [void]
|
|
85
|
+
def link(issue_id:, url:, title:)
|
|
86
|
+
query(ATTACHMENT_CREATE, input: {issueId: issue_id, url:, title:})
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param query [String] a GraphQL document
|
|
90
|
+
# @param variables [Hash]
|
|
91
|
+
# @return [Hash] the `data` object
|
|
92
|
+
# @raise [Agentilda::Error] on a transport or GraphQL error
|
|
93
|
+
def query(query, **variables)
|
|
94
|
+
body = transport.call(query, variables)
|
|
95
|
+
errors = body["errors"]
|
|
96
|
+
raise Error, "Linear rejected the request: #{describe(errors)}" if errors&.any?
|
|
97
|
+
|
|
98
|
+
body.fetch("data") { raise Error, "Linear returned no data" }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# @return [String, nil]
|
|
104
|
+
attr_reader :token
|
|
105
|
+
|
|
106
|
+
# @return [String]
|
|
107
|
+
attr_reader :endpoint
|
|
108
|
+
|
|
109
|
+
# @return [#call]
|
|
110
|
+
def transport = @transport ||= method(:post)
|
|
111
|
+
|
|
112
|
+
# Linear reports a failed mutation as `success: false` rather than as an
|
|
113
|
+
# error, so the envelope has to be checked as well as the errors array.
|
|
114
|
+
#
|
|
115
|
+
# @param data [Hash]
|
|
116
|
+
# @param mutation [String]
|
|
117
|
+
# @param field [String]
|
|
118
|
+
# @return [Hash]
|
|
119
|
+
def unwrap(data, mutation, field)
|
|
120
|
+
payload = data[mutation] or raise Error, "Linear returned no #{mutation} payload"
|
|
121
|
+
raise Error, "Linear declined the #{mutation}" unless payload["success"]
|
|
122
|
+
|
|
123
|
+
payload.fetch(field)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @param errors [Array<Hash>]
|
|
127
|
+
# @return [String]
|
|
128
|
+
def describe(errors)
|
|
129
|
+
Array(errors).map { |e| e["message"] || e.to_s }.join("; ")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param document [String]
|
|
133
|
+
# @param variables [Hash]
|
|
134
|
+
# @return [Hash] the parsed response body
|
|
135
|
+
def post(document, variables)
|
|
136
|
+
uri = URI(endpoint)
|
|
137
|
+
request = Net::HTTP::Post.new(uri)
|
|
138
|
+
request["Content-Type"] = "application/json"
|
|
139
|
+
request["Authorization"] = token
|
|
140
|
+
request.body = JSON.generate(query: document, variables:)
|
|
141
|
+
|
|
142
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https",
|
|
143
|
+
open_timeout: 10, read_timeout: 30) { |http| http.request(request) }
|
|
144
|
+
|
|
145
|
+
parse(response)
|
|
146
|
+
rescue JSON::ParserError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
147
|
+
raise Error, "could not reach Linear at #{endpoint}: #{e.message}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @param response [Net::HTTPResponse]
|
|
151
|
+
# @return [Hash]
|
|
152
|
+
def parse(response)
|
|
153
|
+
return JSON.parse(response.body.to_s) if response.is_a?(Net::HTTPSuccess)
|
|
154
|
+
|
|
155
|
+
hint = if ["400", "401"].include?(response.code)
|
|
156
|
+
"\n\nCheck #{TOKEN_VARIABLE}. A personal API key is sent verbatim, without a `Bearer` prefix."
|
|
157
|
+
end
|
|
158
|
+
raise Error, "Linear returned HTTP #{response.code}#{hint}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
TEAM = <<~GRAPHQL
|
|
162
|
+
query Team($key: String!) {
|
|
163
|
+
teams(filter: { key: { eq: $key } }, first: 1) {
|
|
164
|
+
nodes {
|
|
165
|
+
id name key
|
|
166
|
+
states(first: 100) { nodes { id name type position } }
|
|
167
|
+
labels(first: 250) { nodes { id name } }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
GRAPHQL
|
|
172
|
+
|
|
173
|
+
PROJECTS = <<~GRAPHQL
|
|
174
|
+
query Projects($teamId: String!) {
|
|
175
|
+
team(id: $teamId) {
|
|
176
|
+
projects(first: 250) {
|
|
177
|
+
nodes { id name url description content status { name type } }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
GRAPHQL
|
|
182
|
+
|
|
183
|
+
PROJECT_CREATE = <<~GRAPHQL
|
|
184
|
+
mutation CreateProject($input: ProjectCreateInput!) {
|
|
185
|
+
projectCreate(input: $input) { success project { id name url } }
|
|
186
|
+
}
|
|
187
|
+
GRAPHQL
|
|
188
|
+
|
|
189
|
+
PROJECT_UPDATE = <<~GRAPHQL
|
|
190
|
+
mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {
|
|
191
|
+
projectUpdate(id: $id, input: $input) { success project { id name url } }
|
|
192
|
+
}
|
|
193
|
+
GRAPHQL
|
|
194
|
+
|
|
195
|
+
ISSUE_CREATE = <<~GRAPHQL
|
|
196
|
+
mutation CreateIssue($input: IssueCreateInput!) {
|
|
197
|
+
issueCreate(input: $input) { success issue { id identifier url } }
|
|
198
|
+
}
|
|
199
|
+
GRAPHQL
|
|
200
|
+
|
|
201
|
+
ISSUE_UPDATE = <<~GRAPHQL
|
|
202
|
+
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
|
|
203
|
+
issueUpdate(id: $id, input: $input) { success issue { id identifier url } }
|
|
204
|
+
}
|
|
205
|
+
GRAPHQL
|
|
206
|
+
|
|
207
|
+
LABEL_CREATE = <<~GRAPHQL
|
|
208
|
+
mutation CreateLabel($input: IssueLabelCreateInput!) {
|
|
209
|
+
issueLabelCreate(input: $input) { success issueLabel { id name } }
|
|
210
|
+
}
|
|
211
|
+
GRAPHQL
|
|
212
|
+
|
|
213
|
+
ATTACHMENT_CREATE = <<~GRAPHQL
|
|
214
|
+
mutation CreateAttachment($input: AttachmentCreateInput!) {
|
|
215
|
+
attachmentCreate(input: $input) { success attachment { id } }
|
|
216
|
+
}
|
|
217
|
+
GRAPHQL
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Linear
|
|
5
|
+
# Which plan a pull request belongs to, when its title does not say.
|
|
6
|
+
#
|
|
7
|
+
# `resync prs` puts an `[NNN.MM]` on every title it can resolve, and most
|
|
8
|
+
# of them resolve. What is left is the tail: work that shipped before the
|
|
9
|
+
# convention existed, or from a branch named after nothing in particular.
|
|
10
|
+
# Those pull requests are real work with no home, and dropping them means
|
|
11
|
+
# the issues that stand for a plan are missing pieces of it.
|
|
12
|
+
#
|
|
13
|
+
# The rule is word overlap between the pull request's title and the
|
|
14
|
+
# folder's name. It works here, where it did not work for matching plans
|
|
15
|
+
# to projects, because the candidates are different: a folder slug is
|
|
16
|
+
# `ledger-carryforward-vintages`, three specific words about one thing,
|
|
17
|
+
# and there are twenty of them to choose between. A project is called
|
|
18
|
+
# "US Tax Law: Self Contained Ruby Gem" and there are four.
|
|
19
|
+
#
|
|
20
|
+
# It is still a guess, and it says so. A tie is refused rather than broken,
|
|
21
|
+
# because two folders matching equally well is evidence that neither is
|
|
22
|
+
# right rather than a reason to pick the first.
|
|
23
|
+
class Attribution
|
|
24
|
+
# One pull request, placed.
|
|
25
|
+
#
|
|
26
|
+
# @!attribute [r] pull
|
|
27
|
+
# @return [Agentilda::PullRequest]
|
|
28
|
+
# @!attribute [r] subject
|
|
29
|
+
# @return [Agentilda::Subject, nil] nil when nothing matched
|
|
30
|
+
# @!attribute [r] score
|
|
31
|
+
# @return [Integer] words the title and the folder name shared
|
|
32
|
+
# @!attribute [r] rivals
|
|
33
|
+
# @return [Array<String>] folders that matched equally well
|
|
34
|
+
Placed = Data.define(:pull, :subject, :score, :rivals, :widened) do
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def placed? = !subject.nil?
|
|
37
|
+
|
|
38
|
+
# @return [String]
|
|
39
|
+
def percent = "#{(score * 100).round}%"
|
|
40
|
+
|
|
41
|
+
# @return [String] where the matching words came from
|
|
42
|
+
def source = widened ? "title and description" : "title"
|
|
43
|
+
|
|
44
|
+
# @return [String]
|
|
45
|
+
def why
|
|
46
|
+
return "matched no folder at all" if subject.nil? && score.zero?
|
|
47
|
+
return "#{percent} of a folder name is not enough" if subject.nil? && rivals.empty?
|
|
48
|
+
# `rivals` already holds every tied folder — there is no winner to
|
|
49
|
+
# add back in, and `+ 1` here once reported a two-way tie as three.
|
|
50
|
+
return "#{rivals.size} folders matched equally well (#{percent})" if subject.nil?
|
|
51
|
+
|
|
52
|
+
"#{source}: covers #{percent} of the folder name"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Words too common in this domain to carry a match on their own. Every
|
|
57
|
+
# plan in a tax engine says "tax"; a pull request that says it too has
|
|
58
|
+
# told you nothing.
|
|
59
|
+
# How much of a folder's name a title must cover to claim it.
|
|
60
|
+
FLOOR = 0.5
|
|
61
|
+
|
|
62
|
+
# And how much a description must cover, which is more.
|
|
63
|
+
#
|
|
64
|
+
# A title is written to say what the change is. A description is written
|
|
65
|
+
# to get the change reviewed, and it opens with whatever this repository
|
|
66
|
+
# puts at the top of every one of them — a stacking note, a diff
|
|
67
|
+
# summary, a checklist. Read at the same bar as a title it produced two
|
|
68
|
+
# placements here and both were wrong, one of them on the word
|
|
69
|
+
# "documents" inside "diff includes nineteen documents".
|
|
70
|
+
WIDER_FLOOR = 0.75
|
|
71
|
+
|
|
72
|
+
# Words, not just a fraction. Half of a two-word folder name is one
|
|
73
|
+
# word, which is the case that was already established as too thin —
|
|
74
|
+
# "core" alone claiming `deterministic-core-and-as-of`.
|
|
75
|
+
MINIMUM_WORDS = 3
|
|
76
|
+
|
|
77
|
+
NOISE = %w[the and for with from into that this add adds added fix fixes
|
|
78
|
+
update updates use uses spec plan pull request pr tax app web api].freeze
|
|
79
|
+
|
|
80
|
+
# @param tree [Agentilda::Tree]
|
|
81
|
+
def initialize(tree:)
|
|
82
|
+
@tree = tree
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# How much of a pull request's description to read when its title was
|
|
86
|
+
# not enough. The opening sentence or two of a description says what the
|
|
87
|
+
# change is for; further down it turns into checklists, test plans and
|
|
88
|
+
# generated tables, which are the same words on every pull request in
|
|
89
|
+
# the repository and match everything equally.
|
|
90
|
+
OPENING_WORDS = 20
|
|
91
|
+
|
|
92
|
+
# @param pulls [Array<Agentilda::PullRequest>] the ones with no number
|
|
93
|
+
# @param bodies [Hash{String => String}] descriptions, by pull request number
|
|
94
|
+
# @return [Array<Agentilda::Linear::Attribution::Placed>]
|
|
95
|
+
def call(pulls, bodies: {})
|
|
96
|
+
pulls.map do |pull|
|
|
97
|
+
found = place(pull, words(pull.title))
|
|
98
|
+
next found if found.placed?
|
|
99
|
+
|
|
100
|
+
# Second pass. A title is a headline and sometimes says nothing
|
|
101
|
+
# useful — "Add the Drake reference-return worklist" names a vendor
|
|
102
|
+
# rather than the work. The description usually opens by saying what
|
|
103
|
+
# the change is actually about.
|
|
104
|
+
opening = opening_words(bodies[pull.number.to_s])
|
|
105
|
+
next found if opening.empty?
|
|
106
|
+
|
|
107
|
+
wider = place(pull, words(pull.title) | opening, floor: WIDER_FLOOR)
|
|
108
|
+
wider.placed? ? wider.with(widened: true) : found
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @param body [String, nil]
|
|
113
|
+
# @return [Array<String>]
|
|
114
|
+
def opening_words(body)
|
|
115
|
+
text = body.to_s
|
|
116
|
+
.gsub(/<!--.*?-->/m, "")
|
|
117
|
+
.gsub(/```.*?```/m, "")
|
|
118
|
+
.gsub(/^\s*[-*|#>]+/, " ")
|
|
119
|
+
words(text.split(/\s+/).first(OPENING_WORDS * 3).join(" ")).first(OPENING_WORDS)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# @return [Agentilda::Tree]
|
|
125
|
+
attr_reader :tree
|
|
126
|
+
|
|
127
|
+
# @param pull [Agentilda::PullRequest]
|
|
128
|
+
# @param wanted [Array<String>] the words to match on
|
|
129
|
+
# @param floor [Float] how much of a folder name is enough
|
|
130
|
+
# @return [Agentilda::Linear::Attribution::Placed]
|
|
131
|
+
def place(pull, wanted, floor: FLOOR)
|
|
132
|
+
return Placed.new(pull:, subject: nil, score: 0.0, rivals: [], widened: false) if wanted.empty?
|
|
133
|
+
|
|
134
|
+
ranked = tree.subjects.filter_map { |s|
|
|
135
|
+
overlap = shared(folder_words(s), wanted)
|
|
136
|
+
[s, overlap] if overlap.positive?
|
|
137
|
+
}.sort_by { |_, overlap| -overlap }
|
|
138
|
+
|
|
139
|
+
best, score = ranked.first
|
|
140
|
+
|
|
141
|
+
# One word in common is not evidence. "Add year-keyed tax rules" shares
|
|
142
|
+
# "rules" with `rules-retirement-and-engine-convergence` and has
|
|
143
|
+
# nothing to do with it.
|
|
144
|
+
enough = best && score >= floor && (score * folder_words(best).size).round >= MINIMUM_WORDS
|
|
145
|
+
return Placed.new(pull:, subject: nil, score: score.to_f, rivals: [], widened: false) unless enough
|
|
146
|
+
|
|
147
|
+
tied = ranked.select { |_, overlap| overlap == score }
|
|
148
|
+
return Placed.new(pull:, subject: best, score:, rivals: [], widened: false) if tied.one?
|
|
149
|
+
|
|
150
|
+
Placed.new(pull:, subject: nil, score:, rivals: tied.map { |s, _| s.feature.dirname }, widened: false)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @param subject [Agentilda::Subject]
|
|
154
|
+
# @return [Array<String>]
|
|
155
|
+
def folder_words(subject)
|
|
156
|
+
(@folder_words ||= {})[subject.feature.path] ||=
|
|
157
|
+
words(subject.feature.slug.tr("-", " ")) | words(subject.feature.title)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @param text [String]
|
|
161
|
+
# @return [Array<String>]
|
|
162
|
+
def words(text)
|
|
163
|
+
text.to_s.downcase.gsub(/[^a-z0-9]+/, " ").split
|
|
164
|
+
.reject { |w| w.length < 4 || NOISE.include?(w) }.uniq
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# How much of a folder's name a pull request title actually said.
|
|
168
|
+
#
|
|
169
|
+
# A pull request said `signed_off` where its folder says `sign-off`, and
|
|
170
|
+
# `vintages` where the folder says `vintage`. Compared literally those
|
|
171
|
+
# are strangers, and the first cost a placement a human makes instantly
|
|
172
|
+
# — one a later, numbered pull request went on to prove correct.
|
|
173
|
+
#
|
|
174
|
+
# Matching on a shared prefix rather than by stripping suffixes, because
|
|
175
|
+
# stripping is destructive and gets it wrong in both directions:
|
|
176
|
+
# `corpus` is not a plural, and turning it into `corpu` reads as a typo
|
|
177
|
+
# in every diagnostic that prints it.
|
|
178
|
+
#
|
|
179
|
+
# @param folder [Array<String>]
|
|
180
|
+
# @param title [Array<String>]
|
|
181
|
+
# @return [Float] 0.0 to 1.0
|
|
182
|
+
def shared(folder, title) = Fuzzy.coverage(folder, title)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fuzzystringmatch"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
module Linear
|
|
7
|
+
# How alike two short strings are, on a scale rather than a yes or no.
|
|
8
|
+
#
|
|
9
|
+
# Counting shared words gives whole numbers, and whole numbers tie. Three
|
|
10
|
+
# folders scoring 2 against the same pull request is not three good
|
|
11
|
+
# answers — it is no answer — but a tie has to be *detected* to be
|
|
12
|
+
# refused, and with a coarse enough score everything ties.
|
|
13
|
+
#
|
|
14
|
+
# Jaro-Winkler grades instead, and it is the right measure for these
|
|
15
|
+
# inputs: it is built for short strings, and its prefix bonus is exactly
|
|
16
|
+
# the shape of the errors here — a word and its inflection agree from the
|
|
17
|
+
# front and diverge at the end.
|
|
18
|
+
module Fuzzy
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Two words count as the same word above this.
|
|
22
|
+
#
|
|
23
|
+
# Calibrated rather than chosen. The prefix bonus that makes
|
|
24
|
+
# `rounding`/`round` (0.938) work also lifts `plaid`/`plain` (0.920) and
|
|
25
|
+
# `state`/`statement` (0.926), which are different words that would
|
|
26
|
+
# wrongly file a pull request. 0.93 is the gap between those two groups.
|
|
27
|
+
# It costs `corpus`/`corpora` (0.848), which is rarer than either.
|
|
28
|
+
AKIN = 0.93
|
|
29
|
+
|
|
30
|
+
# Deliberately the pure-Ruby implementation. The native one needs a C
|
|
31
|
+
# extension that does not build everywhere — it fails on this machine
|
|
32
|
+
# over a jemalloc header — and asking for it prints a compile warning to
|
|
33
|
+
# STDERR on every single load, which would land in the middle of every
|
|
34
|
+
# command's output. An import compares a few hundred short strings; that
|
|
35
|
+
# is nowhere near enough work to be worth a build step.
|
|
36
|
+
#
|
|
37
|
+
# @return [FuzzyStringMatch::JaroWinklerPure]
|
|
38
|
+
def matcher = @matcher ||= FuzzyStringMatch::JaroWinkler.create(:pure)
|
|
39
|
+
|
|
40
|
+
# @param one [String]
|
|
41
|
+
# @param other [String]
|
|
42
|
+
# @return [Float] 0.0 to 1.0
|
|
43
|
+
def similarity(one, other) = matcher.getDistance(one.to_s.downcase, other.to_s.downcase)
|
|
44
|
+
|
|
45
|
+
# @param one [String]
|
|
46
|
+
# @param other [String]
|
|
47
|
+
# @return [Boolean] whether these are the same word, inflections aside
|
|
48
|
+
def akin?(one, other) = similarity(one, other) >= AKIN
|
|
49
|
+
|
|
50
|
+
# What fraction of `wanted` has a counterpart in `found`.
|
|
51
|
+
#
|
|
52
|
+
# Asymmetric on purpose. A folder is named for what it is about, in two
|
|
53
|
+
# or three words; a pull request title is a sentence. Asking how much of
|
|
54
|
+
# the sentence the folder covers would punish every long title, so the
|
|
55
|
+
# question is the other way round: how much of this folder's name did
|
|
56
|
+
# the title actually say?
|
|
57
|
+
#
|
|
58
|
+
# @param wanted [Array<String>] the folder's words
|
|
59
|
+
# @param found [Array<String>] the title's words
|
|
60
|
+
# @return [Float] 0.0 to 1.0
|
|
61
|
+
def coverage(wanted, found)
|
|
62
|
+
return 0.0 if wanted.empty?
|
|
63
|
+
|
|
64
|
+
wanted.count { |word| found.any? { |other| akin?(word, other) } }.fdiv(wanted.size)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|