|
1
|
+ |
defmodule OpenAgents.Issues.Capture do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
Turns one sentence of a conversation into a scoped forge issue.
|
|
4
|
+ |
|
|
5
|
+ |
This module is the whole of the behavior. The chat tool
|
|
6
|
+ |
(`OpenAgents.Tools.IssueCapture`) and the authenticated API operation
|
|
7
|
+ |
(`POST /api/v3/repos/:owner/:repo/issues/capture`) are two transports over
|
|
8
|
+ |
it, so the two cannot drift: a refusal the tool gives is the refusal the API
|
|
9
|
+ |
gives, and a draft the API writes is the draft the tool writes.
|
|
10
|
+ |
|
|
11
|
+ |
Three things happen here, in this order, and the order matters.
|
|
12
|
+ |
|
|
13
|
+ |
1. **The repository is resolved under the caller's own membership.** Nothing
|
|
14
|
+ |
here mints authority. `authorize/2` asks
|
|
15
|
+ |
`OpenAgents.Repositories.writable?/2` about the account that asked, and a
|
|
16
|
+ |
caller who cannot write gets a typed refusal that names what is missing
|
|
17
|
+ |
rather than a silent fallback to some other repository. A repository the
|
|
18
|
+ |
caller cannot even see is reported as absent, because saying
|
|
19
|
+ |
"you lack write access" about a private repository discloses that it
|
|
20
|
+ |
exists.
|
|
21
|
+ |
2. **A near-duplicate is preferred over a new row.** See `dedupe/2`.
|
|
22
|
+ |
3. **Only then is an issue created**, through `OpenAgents.Issues.create_issue/3`
|
|
23
|
+ |
with the caller as author. Going through that function rather than
|
|
24
|
+ |
inserting directly is what subscribes the requester to the issue's own
|
|
25
|
+ |
notifications: `create_issue/3` calls `Notifications.issue_opened/2`
|
|
26
|
+ |
inside its transaction, so the requester follows the issue from the
|
|
27
|
+ |
moment it exists.
|
|
28
|
+ |
|
|
29
|
+ |
## What the public issue says
|
|
30
|
+ |
|
|
31
|
+ |
The body is a fixed template — outcome, current behavior, acceptance
|
|
32
|
+ |
criteria — filled only from what the caller supplied. Nothing else reaches
|
|
33
|
+ |
it. No conversation id, no message id, no prompt, no tool trace, no
|
|
34
|
+ |
repository metadata, and no model output the caller did not see. The
|
|
35
|
+ |
template placeholders are honest about being unfilled instead of inventing
|
|
36
|
+ |
a current behavior nobody observed.
|
|
37
|
+ |
"""
|
|
38
|
+ |
|
|
39
|
+ |
import Ecto.Changeset, only: [traverse_errors: 2]
|
|
40
|
+ |
|
|
41
|
+ |
alias OpenAgents.Accounts.User
|
|
42
|
+ |
alias OpenAgents.Issues
|
|
43
|
+ |
alias OpenAgents.Issues.Issue
|
|
44
|
+ |
alias OpenAgents.Repositories
|
|
45
|
+ |
alias OpenAgents.Repositories.Repository
|
|
46
|
+ |
|
|
47
|
+ |
@maximum_statement_bytes 4_000
|
|
48
|
+ |
@maximum_section_bytes 4_000
|
|
49
|
+ |
@maximum_criteria 12
|
|
50
|
+ |
@maximum_title_characters 72
|
|
51
|
+ |
|
|
52
|
+ |
@type outcome :: :created | :existing
|
|
53
|
+ |
@type result :: %{
|
|
54
|
+ |
issue: Issue.t(),
|
|
55
|
+ |
repository: Repository.t(),
|
|
56
|
+ |
outcome: outcome()
|
|
57
|
+ |
}
|
|
58
|
+ |
@type error ::
|
|
59
|
+ |
:blank_problem_statement
|
|
60
|
+ |
| :problem_statement_too_long
|
|
61
|
+ |
| :section_too_long
|
|
62
|
+ |
| :invalid_repository
|
|
63
|
+ |
| :repository_not_found
|
|
64
|
+ |
| :repository_write_access_required
|
|
65
|
+ |
| {:invalid_issue, map()}
|
|
66
|
+ |
|
|
67
|
+ |
@doc """
|
|
68
|
+ |
Captures `attrs` as an issue in `repository_path` on behalf of `actor`.
|
|
69
|
+ |
|
|
70
|
+ |
`repository_path` is `owner/name`. `attrs` accepts string keys:
|
|
71
|
+ |
|
|
72
|
+ |
- `"problem"` — required, the requester's own words.
|
|
73
|
+ |
- `"current_behavior"` — optional.
|
|
74
|
+ |
- `"acceptance_criteria"` — optional, a list of strings or a newline-separated
|
|
75
|
+ |
string.
|
|
76
|
+ |
|
|
77
|
+ |
Returns `{:ok, %{issue: issue, repository: repository, outcome: outcome}}`
|
|
78
|
+ |
where `outcome` is `:created` for a new issue and `:existing` when
|
|
79
|
+ |
deduplication matched one that was already open. Retrying the same statement
|
|
80
|
+ |
against the same repository therefore returns the same issue rather than a
|
|
81
|
+ |
second one.
|
|
82
|
+ |
"""
|
|
83
|
+ |
@spec capture(User.t(), String.t(), map()) :: {:ok, result()} | {:error, error()}
|
|
84
|
+ |
def capture(%User{} = actor, repository_path, attrs) when is_binary(repository_path) do
|
|
85
|
+ |
with {:ok, problem} <- problem(attrs),
|
|
86
|
+ |
{:ok, current_behavior} <- section(attrs, "current_behavior"),
|
|
87
|
+ |
{:ok, criteria} <- criteria(attrs),
|
|
88
|
+ |
{:ok, repository} <- authorize(actor, repository_path) do
|
|
89
|
+ |
title = draft_title(problem)
|
|
90
|
+ |
body = draft_body(problem, current_behavior, criteria)
|
|
91
|
+ |
|
|
92
|
+ |
case dedupe(repository, title) do
|
|
93
|
+ |
%Issue{} = existing ->
|
|
94
|
+ |
{:ok, %{issue: existing, repository: repository, outcome: :existing}}
|
|
95
|
+ |
|
|
96
|
+ |
nil ->
|
|
97
|
+ |
create(actor, repository, title, body)
|
|
98
|
+ |
end
|
|
99
|
+ |
end
|
|
100
|
+ |
end
|
|
101
|
+ |
|
|
102
|
+ |
@doc """
|
|
103
|
+ |
Resolves `repository_path` to a repository `actor` may write to.
|
|
104
|
+ |
|
|
105
|
+ |
The two refusals are deliberately different facts. `:repository_not_found`
|
|
106
|
+ |
means the caller cannot see it, and is also what a caller gets for a private
|
|
107
|
+ |
repository they hold no membership in — the refusal must not become an
|
|
108
|
+ |
existence oracle. `:repository_write_access_required` means the caller can
|
|
109
|
+ |
see it and holds no writing role, which is safe to say because they already
|
|
110
|
+ |
know it exists, and is the refusal that names the missing authority so the
|
|
111
|
+ |
person knows what to ask for.
|
|
112
|
+ |
"""
|
|
113
|
+ |
@spec authorize(User.t(), String.t()) :: {:ok, Repository.t()} | {:error, error()}
|
|
114
|
+ |
def authorize(%User{} = actor, repository_path) when is_binary(repository_path) do
|
|
115
|
+ |
with {:ok, owner, name} <- parse_path(repository_path),
|
|
116
|
+ |
%Repository{} = repository <- Repositories.visible_by_path(owner, name, actor) do
|
|
117
|
+ |
if Repositories.writable?(repository, actor) do
|
|
118
|
+ |
{:ok, repository}
|
|
119
|
+ |
else
|
|
120
|
+ |
{:error, :repository_write_access_required}
|
|
121
|
+ |
end
|
|
122
|
+ |
else
|
|
123
|
+ |
nil -> {:error, :repository_not_found}
|
|
124
|
+ |
{:error, reason} -> {:error, reason}
|
|
125
|
+ |
end
|
|
126
|
+ |
end
|
|
127
|
+ |
|
|
128
|
+ |
@doc """
|
|
129
|
+ |
The open issue this request should be folded into, or `nil`.
|
|
130
|
+ |
|
|
131
|
+ |
**This is an exact match on the normalized title, not a semantic one, and
|
|
132
|
+ |
that is a limitation rather than a preference.** The repository has no
|
|
133
|
+ |
embedding index over issues: `OpenAgents.Tools.Embeddings` covers the tool
|
|
134
|
+ |
catalog, and the pgvector tables under `OpenAgents.Memory.SemanticIndex`
|
|
135
|
+ |
cover conversation messages. Neither indexes issue text, and standing one up
|
|
136
|
+ |
is a larger change than this one.
|
|
137
|
+ |
|
|
138
|
+ |
So the choice was between an exact normalized-title check and inventing
|
|
139
|
+ |
keyword or substring heuristics. Heuristics are the worse failure: a
|
|
140
|
+ |
substring match folds "search is slow" into "search is slow on mobile" and
|
|
141
|
+ |
the second request disappears without anyone deciding it should. An exact
|
|
142
|
+ |
match fails in the safe direction — it misses real duplicates, which a person
|
|
143
|
+ |
can still close by hand, and it never swallows a distinct request.
|
|
144
|
+ |
|
|
145
|
+ |
Normalization is case, punctuation, and whitespace only, so
|
|
146
|
+ |
`"Add dark mode"`, `"add dark mode."`, and `"Add dark mode"` are one
|
|
147
|
+ |
issue. When issue embeddings exist, this function is the single place that
|
|
148
|
+ |
changes.
|
|
149
|
+ |
"""
|
|
150
|
+ |
@spec dedupe(Repository.t(), String.t()) :: Issue.t() | nil
|
|
151
|
+ |
def dedupe(%Repository{} = repository, title) when is_binary(title) do
|
|
152
|
+ |
Issues.open_issue_with_normalized_title(repository, normalize_title(title))
|
|
153
|
+ |
end
|
|
154
|
+ |
|
|
155
|
+ |
@doc """
|
|
156
|
+ |
The comparison form of a title: lowercase, alphanumeric runs, single spaces.
|
|
157
|
+ |
|
|
158
|
+ |
`OpenAgents.Issues.open_issue_with_normalized_title/2` reproduces this in
|
|
159
|
+ |
SQL. Change one and you must change the other, or deduplication silently
|
|
160
|
+ |
stops matching.
|
|
161
|
+ |
"""
|
|
162
|
+ |
@spec normalize_title(String.t()) :: String.t()
|
|
163
|
+ |
def normalize_title(title) when is_binary(title) do
|
|
164
|
+ |
title
|
|
165
|
+ |
|> String.downcase()
|
|
166
|
+ |
|> String.replace(~r/[^a-z0-9]+/u, " ")
|
|
167
|
+ |
|> String.trim()
|
|
168
|
+ |
end
|
|
169
|
+ |
|
|
170
|
+ |
@doc "The title this statement drafts to, exposed so a preview can show it."
|
|
171
|
+ |
@spec draft_title(String.t()) :: String.t()
|
|
172
|
+ |
def draft_title(problem) when is_binary(problem) do
|
|
173
|
+ |
problem
|
|
174
|
+ |
|> collapse()
|
|
175
|
+ |
|> first_sentence()
|
|
176
|
+ |
|> truncate(@maximum_title_characters)
|
|
177
|
+ |
|> capitalize_first()
|
|
178
|
+ |
end
|
|
179
|
+ |
|
|
180
|
+ |
@doc "The body this statement drafts to, exposed so a preview can show it."
|
|
181
|
+ |
@spec draft_body(String.t(), String.t() | nil, [String.t()]) :: String.t()
|
|
182
|
+ |
def draft_body(problem, current_behavior, criteria)
|
|
183
|
+ |
when is_binary(problem) and is_list(criteria) do
|
|
184
|
+ |
"""
|
|
185
|
+ |
## Outcome
|
|
186
|
+ |
|
|
187
|
+ |
#{collapse_lines(problem)}
|
|
188
|
+ |
|
|
189
|
+ |
## Current behavior
|
|
190
|
+ |
|
|
191
|
+ |
#{current_behavior_section(current_behavior)}
|
|
192
|
+ |
|
|
193
|
+ |
## Acceptance criteria
|
|
194
|
+ |
|
|
195
|
+ |
#{criteria_section(criteria)}
|
|
196
|
+ |
"""
|
|
197
|
+ |
|> String.trim()
|
|
198
|
+ |
|> Kernel.<>("\n")
|
|
199
|
+ |
end
|
|
200
|
+ |
|
|
201
|
+ |
defp create(actor, repository, title, body) do
|
|
202
|
+ |
case Issues.create_issue(repository, %{"title" => title, "body" => body}, actor) do
|
|
203
|
+ |
{:ok, %Issue{} = issue} ->
|
|
204
|
+ |
{:ok, %{issue: issue, repository: repository, outcome: :created}}
|
|
205
|
+ |
|
|
206
|
+ |
{:error, changeset} ->
|
|
207
|
+ |
{:error, {:invalid_issue, changeset_errors(changeset)}}
|
|
208
|
+ |
end
|
|
209
|
+ |
end
|
|
210
|
+ |
|
|
211
|
+ |
defp changeset_errors(changeset) do
|
|
212
|
+ |
traverse_errors(changeset, fn {message, options} ->
|
|
213
|
+ |
Regex.replace(~r/%\{(\w+)\}/, message, fn _whole, key ->
|
|
214
|
+ |
options |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
|
215
|
+ |
end)
|
|
216
|
+ |
end)
|
|
217
|
+ |
end
|
|
218
|
+ |
|
|
219
|
+ |
defp problem(attrs) do
|
|
220
|
+ |
case attrs |> Map.get("problem") |> normalize_input() do
|
|
221
|
+ |
nil ->
|
|
222
|
+ |
{:error, :blank_problem_statement}
|
|
223
|
+ |
|
|
224
|
+ |
problem when byte_size(problem) > @maximum_statement_bytes ->
|
|
225
|
+ |
{:error, :problem_statement_too_long}
|
|
226
|
+ |
|
|
227
|
+ |
problem ->
|
|
228
|
+ |
{:ok, problem}
|
|
229
|
+ |
end
|
|
230
|
+ |
end
|
|
231
|
+ |
|
|
232
|
+ |
defp section(attrs, key) do
|
|
233
|
+ |
case attrs |> Map.get(key) |> normalize_input() do
|
|
234
|
+ |
nil -> {:ok, nil}
|
|
235
|
+ |
value when byte_size(value) > @maximum_section_bytes -> {:error, :section_too_long}
|
|
236
|
+ |
value -> {:ok, value}
|
|
237
|
+ |
end
|
|
238
|
+ |
end
|
|
239
|
+ |
|
|
240
|
+ |
defp criteria(attrs) do
|
|
241
|
+ |
attrs
|
|
242
|
+ |
|> Map.get("acceptance_criteria")
|
|
243
|
+ |
|> List.wrap()
|
|
244
|
+ |
|> Enum.flat_map(&String.split(to_string(&1), "\n"))
|
|
245
|
+ |
|> Enum.map(&(&1 |> String.replace_prefix("- [ ]", "") |> String.replace_prefix("-", "")))
|
|
246
|
+ |
|> Enum.map(&normalize_input/1)
|
|
247
|
+ |
|> Enum.reject(&is_nil/1)
|
|
248
|
+ |
|> Enum.take(@maximum_criteria)
|
|
249
|
+ |
|> then(fn criteria ->
|
|
250
|
+ |
if Enum.any?(criteria, &(byte_size(&1) > @maximum_section_bytes)) do
|
|
251
|
+ |
{:error, :section_too_long}
|
|
252
|
+ |
else
|
|
253
|
+ |
{:ok, criteria}
|
|
254
|
+ |
end
|
|
255
|
+ |
end)
|
|
256
|
+ |
end
|
|
257
|
+ |
|
|
258
|
+ |
defp normalize_input(value) when is_binary(value) do
|
|
259
|
+ |
case String.trim(value) do
|
|
260
|
+ |
"" -> nil
|
|
261
|
+ |
trimmed -> trimmed
|
|
262
|
+ |
end
|
|
263
|
+ |
end
|
|
264
|
+ |
|
|
265
|
+ |
defp normalize_input(_value), do: nil
|
|
266
|
+ |
|
|
267
|
+ |
defp current_behavior_section(nil),
|
|
268
|
+ |
do: "Not recorded when this was captured. Fill this in before the work starts."
|
|
269
|
+ |
|
|
270
|
+ |
defp current_behavior_section(current_behavior), do: collapse_lines(current_behavior)
|
|
271
|
+ |
|
|
272
|
+ |
defp criteria_section([]),
|
|
273
|
+ |
do: "- [ ] Not recorded when this was captured. Agree these before the work starts."
|
|
274
|
+ |
|
|
275
|
+ |
defp criteria_section(criteria), do: Enum.map_join(criteria, "\n", &"- [ ] #{collapse(&1)}")
|
|
276
|
+ |
|
|
277
|
+ |
defp parse_path(repository_path) do
|
|
278
|
+ |
case repository_path |> String.trim() |> String.split("/", trim: true) do
|
|
279
|
+ |
[owner, name] when byte_size(owner) in 1..100 and byte_size(name) in 1..100 ->
|
|
280
|
+ |
{:ok, owner, name}
|
|
281
|
+ |
|
|
282
|
+ |
_invalid ->
|
|
283
|
+ |
{:error, :invalid_repository}
|
|
284
|
+ |
end
|
|
285
|
+ |
end
|
|
286
|
+ |
|
|
287
|
+ |
defp collapse(value), do: value |> String.replace(~r/\s+/u, " ") |> String.trim()
|
|
288
|
+ |
|
|
289
|
+ |
# Paragraphs survive; runs of blank lines and trailing spaces do not. The
|
|
290
|
+ |
# requester's own prose reaches the issue intact.
|
|
291
|
+ |
defp collapse_lines(value) do
|
|
292
|
+ |
value
|
|
293
|
+ |
|> String.split("\n")
|
|
294
|
+ |
|> Enum.map(&String.trim_trailing/1)
|
|
295
|
+ |
|> Enum.join("\n")
|
|
296
|
+ |
|> String.replace(~r/\n{3,}/, "\n\n")
|
|
297
|
+ |
|> String.trim()
|
|
298
|
+ |
end
|
|
299
|
+ |
|
|
300
|
+ |
defp first_sentence(value) do
|
|
301
|
+ |
case String.split(value, ~r/(?<=[.!?])\s+/, parts: 2) do
|
|
302
|
+ |
[sentence, _rest] -> String.trim(sentence)
|
|
303
|
+ |
[whole] -> whole
|
|
304
|
+ |
end
|
|
305
|
+ |
|> String.trim_trailing(".")
|
|
306
|
+ |
end
|
|
307
|
+ |
|
|
308
|
+ |
defp truncate(value, limit) do
|
|
309
|
+ |
if String.length(value) <= limit do
|
|
310
|
+ |
value
|
|
311
|
+ |
else
|
|
312
|
+ |
value
|
|
313
|
+ |
|> String.slice(0, limit)
|
|
314
|
+ |
|> String.replace(~r/\s+\S*$/u, "")
|
|
315
|
+ |
|> String.trim()
|
|
316
|
+ |
|> then(fn trimmed ->
|
|
317
|
+ |
if trimmed == "", do: String.slice(value, 0, limit), else: trimmed
|
|
318
|
+ |
end)
|
|
319
|
+ |
end
|
|
320
|
+ |
end
|
|
321
|
+ |
|
|
322
|
+ |
defp capitalize_first(""), do: ""
|
|
323
|
+ |
|
|
324
|
+ |
defp capitalize_first(value) do
|
|
325
|
+ |
{first, rest} = String.split_at(value, 1)
|
|
326
|
+ |
String.upcase(first) <> rest
|
|
327
|
+ |
end
|
|
328
|
+ |
end
|