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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +26 -0
  3. data/Gemfile.lock +261 -0
  4. data/agentilda.gemspec +57 -0
  5. data/agents/hansolo-reviewer.md +29 -0
  6. data/agents/lando-broker.md +74 -0
  7. data/agents/leah-researcher.md +80 -0
  8. data/agents/luke-backend.md +81 -0
  9. data/agents/palpatine-planner.md +40 -0
  10. data/agents/rey-frontend.md +106 -0
  11. data/agents/yoda-writer.md +54 -0
  12. data/bin/create-plan-folder +125 -0
  13. data/bin/plan-number +164 -0
  14. data/exe/agentilda +111 -0
  15. data/exe/tilda +1 -0
  16. data/lib/agentilda/adoption.rb +192 -0
  17. data/lib/agentilda/agent.rb +136 -0
  18. data/lib/agentilda/brief.rb +234 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
  20. data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
  21. data/lib/agentilda/cli/base.rb +88 -0
  22. data/lib/agentilda/cli/create/create.rb +309 -0
  23. data/lib/agentilda/cli/docs/docs.rb +30 -0
  24. data/lib/agentilda/cli/index/index.rb +38 -0
  25. data/lib/agentilda/cli/linear/linear.rb +35 -0
  26. data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
  27. data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
  28. data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
  31. data/lib/agentilda/cli/run/run.rb +289 -0
  32. data/lib/agentilda/cli/states/states.rb +15 -0
  33. data/lib/agentilda/cli/unblock/unblock.rb +227 -0
  34. data/lib/agentilda/cli/version/version.rb +13 -0
  35. data/lib/agentilda/cli.rb +74 -0
  36. data/lib/agentilda/config.rb +44 -0
  37. data/lib/agentilda/control.rb +115 -0
  38. data/lib/agentilda/creator.rb +120 -0
  39. data/lib/agentilda/dev_work.rb +54 -0
  40. data/lib/agentilda/diagram.rb +144 -0
  41. data/lib/agentilda/documentation.rb +429 -0
  42. data/lib/agentilda/executor.rb +539 -0
  43. data/lib/agentilda/feature.rb +253 -0
  44. data/lib/agentilda/frontmatter.rb +36 -0
  45. data/lib/agentilda/github.rb +160 -0
  46. data/lib/agentilda/index.rb +206 -0
  47. data/lib/agentilda/keyboard.rb +88 -0
  48. data/lib/agentilda/linear/api.rb +220 -0
  49. data/lib/agentilda/linear/attribution.rb +185 -0
  50. data/lib/agentilda/linear/fuzzy.rb +68 -0
  51. data/lib/agentilda/linear/import.rb +298 -0
  52. data/lib/agentilda/linear/issue.rb +184 -0
  53. data/lib/agentilda/linear/mapping.rb +115 -0
  54. data/lib/agentilda/linear/push.rb +190 -0
  55. data/lib/agentilda/linear/survey.rb +173 -0
  56. data/lib/agentilda/linear/unit.rb +274 -0
  57. data/lib/agentilda/linear.rb +42 -0
  58. data/lib/agentilda/markdown.rb +56 -0
  59. data/lib/agentilda/ordinal.rb +90 -0
  60. data/lib/agentilda/progress_log.rb +122 -0
  61. data/lib/agentilda/publisher.rb +172 -0
  62. data/lib/agentilda/pull_request.rb +213 -0
  63. data/lib/agentilda/reporter.rb +175 -0
  64. data/lib/agentilda/resync.rb +358 -0
  65. data/lib/agentilda/roster.rb +110 -0
  66. data/lib/agentilda/runner.rb +456 -0
  67. data/lib/agentilda/state_machine.rb +355 -0
  68. data/lib/agentilda/status.rb +280 -0
  69. data/lib/agentilda/tally.rb +169 -0
  70. data/lib/agentilda/transcript.rb +435 -0
  71. data/lib/agentilda/tree.rb +77 -0
  72. data/lib/agentilda/ui.rb +681 -0
  73. data/lib/agentilda/unblocker.rb +207 -0
  74. data/lib/agentilda/version.rb +10 -0
  75. data/lib/agentilda/viewer.rb +60 -0
  76. data/lib/agentilda/worktree.rb +211 -0
  77. data/lib/agentilda.rb +155 -0
  78. data/lib/dry/cli/banner.rb +293 -0
  79. 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