Turn a sentence in chat into a scoped forge issue

00831f2f8005 · AtlantisPleb · · parent 33114bca04b7

Turn a sentence in chat into a scoped forge issue

Issue #77 asked for one chat tool and a matching authenticated API operation
that turn an unmet request into a canonical issue. Both land here as transports
over `OpenAgents.Issues.Capture`, so the draft, the authorization decision, and
the deduplication answer are one implementation rather than two that agree
today.

Authority stays the caller's. `Capture.authorize/2` reads the requesting
account's own repository membership, so the agent can file exactly where the
person could have filed by hand and nowhere else. The two refusals are kept
apart on purpose: a repository the caller can already read but not write to
answers `repository_write_access_required`, naming the missing role so they
know what to ask for, while one they cannot read stays absent, because saying
"you lack write access" about a private repository discloses that it exists.

Deduplication matches normalized titles exactly, not semantically. No embedding
index over issues exists — `Tools.Embeddings` covers the tool catalog and the
pgvector tables cover conversation messages — so the choice was between an
exact check and invented keyword heuristics. Heuristics fail in the worse
direction: a substring match folds "search is slow" into "search is slow on
mobile" and the second request disappears without anyone deciding it should.
The exact check misses real duplicates instead, which a person can still close
by hand. `Issues.Capture.dedupe/2` is the single place that changes if issue
embeddings ever land.

Admitting the tool moves TOOL-006, which said the shipped catalog is read-only.
It is now read-only *or* gated on a current consent receipt, and nothing else.
A filed issue is public and numbered, so `capture_issue` declares
`:external_effect`, which the surface contract then forces to
`external_confirmation` rather than the `executor_consent` path that would let
a write run unasked. `SurfacePolicy` demands an explicit, person-signed receipt
bound to this module, version, and conversation, and `AdmittedCatalog` applies
the same check when it builds the catalog — so on a turn with no consent the
tool is not offered rather than offered and refused. That is the ask-every-time
rung of the approval ladder, and it means the standing prompt cost is zero.

Section 11 of the zero-base document answers the six re-admission criteria and
names what this did not settle: nothing mints that receipt yet, so the tool is
inert in production until the chat surface grows the preview-and-consent step
#77's contract calls for. The API operation needs no receipt, because the
person is asking directly rather than through a model, and works today.

The requester is subscribed for free: capture goes through
`Issues.create_issue/3`, which already calls `Notifications.issue_opened/2`
inside its transaction.

One adjacent test moved. `selector_test` cut a six-way score tie in half at
`top_k: 12`; `computer_list` scores 0.333 on the stopwords "the", "this", and
"use" alone for a delegation intent and held slot 13 only until a tool sorting
before it joined the fixture catalog. Admitting the whole tie tier tests what
it means to test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/test.exs
  • modified docs/2026-08-23-agent-tools-zero-base.md
  • modified lib/openagents/issues.ex
  • added lib/openagents/issues/capture.ex
  • added lib/openagents/tools/issue_capture.ex
  • modified lib/openagents/tools/runner.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/issue_controller.ex
  • modified lib/openagents_web/router.ex
  • added test/openagents/issues/capture_test.exs
  • added test/openagents/tools/issue_capture_test.exs
  • modified test/openagents/tools/reach_test.exs
  • modified test/openagents/tools/selector_test.exs
  • modified test/openagents/tools/shipped_catalog_test.exs
  • added test/openagents_web/controllers/issue_capture_controller_test.exs

Diff

18 files changed, +1568 -23

INVARIANTS.md modified +34 -7

@@ -1664,17 +1664,44 @@ Evidence: `OpenAgents.Tools.Reach`, `OpenAgents.Tools.Selector`,

1664 1664
`test/openagents/chat/open_router/tool_runtime_test.exs`, and
1665 1665
`OpenAgents.DependencyBoundaryTest`.
1666 1666
1667
### TOOL-006 — The shipped tool catalog is a closed, read-only set
1667
### TOOL-006 — The shipped tool catalog is a closed set, read-only but for consent
1668 1668
1669 1669
Status: Current
1670 1670
1671 1671
The catalog the product installs at boot is enumerated, not accumulated. Every
1672
module in `config/config.exs` under `:tools` is read-only, requires an
1673
authority every conversation caller already holds, and is admitted by name.
1674
A tool that ships must work for every caller that can see it; offering a tool
1675
that always refuses trains the model and the person to ignore refusals.
1676
TOOL-005 narrows the offer to what this caller can reach; this invariant
1677
narrows what exists to be offered at all. Neither substitutes for the other.
1672
module in `config/config.exs` under `:tools` requires an authority every
1673
conversation caller already holds, is admitted by name, and is **either
1674
read-only, or gated on a current consent receipt**. A tool that ships must
1675
work for every caller that can see it; offering a tool that always refuses
1676
trains the model and the person to ignore refusals. TOOL-005 narrows the offer
1677
to what this caller can reach; this invariant narrows what exists to be
1678
offered at all. Neither substitutes for the other.
1679
1680
"Gated on a current consent receipt" is not a softening, and it is the only
1681
admitted exception to read-only. It means the module declares a non-read-only
1682
`side_effect` together with metadata that sends
1683
`OpenAgents.Modules.SurfacePolicy.authorize_execution/2` down its receipt path
1684
— an explicit, person-signed receipt bound to that exact module, version, and
1685
`scope_ref`. For an `external_effect` that is the `external_confirmation` or
1686
`explicit_operator_approval` class the surface contract already requires. The
1687
`reversible_write` plus `executor_consent` combination, which runs without
1688
asking anyone, does not qualify. Because
1689
`OpenAgents.Tools.AdmittedCatalog` applies the same check when it builds the
1690
catalog, such a tool is *not offered* on a turn where the person has not
1691
consented, rather than offered and refused: the standing prompt cost is zero
1692
and the "works for every caller that can see it" rule above still holds, since
1693
the only callers who can see it are the ones who consented. There is no
1694
standing grant to record and no "always" to accumulate — the receipt is scoped
1695
to one conversation and re-checked on every call, which is the ask-every-time
1696
rung of the approval ladder in
1697
`docs/2026-08-23-openagents-coder-cli-spec.md`, section 7.3.
1698
1699
Filing an issue from chat (`OpenAgents.Tools.IssueCapture`) is the first and
1700
currently the only module admitted under that exception. It writes to a public
1701
tracker, so it is `:external_effect`; it files under the requesting account's
1702
own repository membership, so it mints no authority of its own; and a caller
1703
holding no writing role is refused with a typed error that names the missing
1704
role rather than a silent fallback to a repository they did not choose.
1678 1705
1679 1706
Modules that are not admitted stay in `lib/openagents/tools/` and stay under
1680 1707
test through the broader fixture catalog in `config/test.exs`, which must
config/config.exs modified +13 -1

@@ -231,7 +231,19 @@ config :openagents,

231 231
    # through `owner_user_id`, the one owner field every conversation caller
232 232
    # populates correctly today.
233 233
    OpenAgents.Tools.ConnectedRepositoryRead,
234
    OpenAgents.Tools.ConnectedRepositoryList
234
    OpenAgents.Tools.ConnectedRepositoryList,
235
236
    # File a request the person made in chat as an issue, in a repository they
237
    # can write to (#77). The one tool here that is not read-only, and the
238
    # reason TOOL-006 now says "read-only, or gated on a current consent
239
    # receipt" rather than "read-only". It declares `:external_effect` with
240
    # `approval_enforcement: "host_receipt"`, so `SurfacePolicy` demands an
241
    # explicit, current, person-signed receipt bound to this module, version,
242
    # and conversation — checked when the catalog is built as well as when the
243
    # tool runs. On a turn with no such receipt it is not offered, so it costs
244
    # nothing and never trains anyone to ignore a refusal. Authority stays the
245
    # caller's: it files under their own repository membership.
246
    OpenAgents.Tools.IssueCapture
235 247
  ],
236 248
  conversation_reset_enabled: false,
237 249
  github_api: [
config/test.exs modified +1

@@ -175,5 +175,6 @@ config :openagents, :tools, [

175 175
  OpenAgents.Tools.RepoEdit,
176 176
  OpenAgents.Tools.RepoWrite,
177 177
  OpenAgents.Tools.RepoCommitPush,
178
  OpenAgents.Tools.IssueCapture,
178 179
  OpenAgents.Tools.TestRecall
179 180
]
docs/2026-08-23-agent-tools-zero-base.md modified +73 -1

@@ -22,7 +22,10 @@

22 22
23 23
The catalog held **37 tool modules** in one flat list at `config/config.exs:176`,
24 24
installed at boot by `lib/openagents/application.ex:41` and gated only by the
25
`:tools` feature flag. It is now **6**.
25
`:tools` feature flag. It is now **6** — **7 since 2026-08-24**, when #77
26
re-admitted `capture_issue` through the section 6 criteria. Section 11 records
27
that decision and what it changed in TOOL-006; sections 5 and 6 describe the
28
six-tool base it started from.
26 29
27 30
Three findings drove the size of the cut, and each is stronger than "too many
28 31
tools".

@@ -687,6 +690,75 @@ bring them back in a change that names the criteria each one now meets.

687 690
688 691
---
689 692
693
## 11. The first re-admission: `capture_issue` (2026-08-24)
694
695
Issue #77 admitted a seventh module, `OpenAgents.Tools.IssueCapture`. It is the
696
first tool to come back, and the first that is not read-only, so TOOL-006 moved
697
with it: the shipped catalog is now "read-only, **or** gated on a current
698
consent receipt", and nothing else. Section 6's six criteria, answered.
699
700
1. **It works for every caller that can see it.** It resolves the account
701
   through `owner_user_id`, the one owner field every conversation caller
702
   populates correctly today — the same field that let `read_repository_file`
703
   survive the cut, and the field summary finding 1 shows `owner_visitor_id`
704
   gets wrong. It declares `reach: [:signed_in_owner]`, so an unresolved owner
705
   never sees it, and its `external_confirmation` gate means the only callers
706
   offered it are those holding a current consent receipt.
707
2. **Its authority is one the caller genuinely holds.** `repository.write`, and
708
   the blanket grant is deliberately *not* the evidence. The real gate is
709
   per-repository membership, checked at execution against the requesting
710
   account through `Repositories.writable?/2` — the same shape as the
711
   repository read tools, whose gate also depends on an argument the catalog
712
   has not seen. The tool mints no authority: it can file exactly where the
713
   person could have filed by hand.
714
3. **Its refusal path has a test.**
715
   `test/openagents/tools/issue_capture_test.exs` asserts the typed refusals a
716
   person actually reads: `repository_write_access_required` (with a message
717
   naming the missing role), `repository_not_found` for a repository the caller
718
   cannot see, `repository_authentication_required` for a conversation with no
719
   account, and `module_approval_required` with no consent receipt. Each
720
   asserts nothing was filed.
721
4. **Its declared effect matches what it does.** `:external_effect`. A filed
722
   issue is public, numbered, and notifies watchers; section 7.2 of the coder
723
   spec settles that "open an issue" is an external effect even with nothing on
724
   disk changed. This is the criterion `deep_work`, `box_new`, and `code_check`
725
   each fail, and it is why the surface contract then forces
726
   `external_confirmation` rather than the `executor_consent` path that would
727
   have let it run unasked.
728
5. **Its description says when not to use it.** Four negatives: not to answer a
729
   question or look something up, not to record a preference or a note, not
730
   without the person asking for the request to be tracked, not twice for one
731
   request, and never with a guessed repository.
732
6. **It earns its prompt budget.** 1,570 bytes encoded, which would be the
733
   largest definition in the catalog — but **zero on a turn
734
   where the person has not consented**, because `AdmittedCatalog` applies the
735
   receipt check when it builds the catalog, so the tool is omitted rather than
736
   offered-and-refused. It is charged only on turns where someone has already
737
   said yes to filing. That is the opposite of the `scv_deploy` shape section 6
738
   warns about, which paid its full cost on every turn to be refused on almost
739
   all of them.
740
741
Two things this did not settle, named rather than hidden.
742
743
- **Nothing mints the consent receipt yet.** The gate is real and tested, but
744
  no surface currently produces a `sarah.module_approval.v1` receipt with
745
  `approval_class: "external_confirmation"` for this module, so in production
746
  the tool is presently inert: never offered, never run. That is the correct
747
  failure direction — an ungated public write would be the wrong one — but the
748
  capability is not reachable from chat until the preview-and-consent step
749
  #77's contract calls for is built on the chat surface. The authenticated API
750
  operation, `POST /api/v3/repos/:owner/:repo/issues/capture`, needs no receipt
751
  because the person is asking directly rather than through a model, and it
752
  works today.
753
- **Deduplication is exact, not semantic.** No embedding index over issues
754
  exists — `Tools.Embeddings` covers the tool catalog and the pgvector tables
755
  cover conversation messages — so `Issues.Capture.dedupe/2` matches normalized
756
  titles exactly. It misses real duplicates rather than swallowing distinct
757
  requests, which is the safe direction, and it is the single place that
758
  changes if issue embeddings ever land.
759
760
---
761
690 762
## Sources
691 763
692 764
- `config/config.exs`, `config/test.exs`, `lib/openagents/application.ex`
lib/openagents/issues.ex modified +32

@@ -243,6 +243,38 @@ defmodule OpenAgents.Issues do

243 243
    Repo.get_by!(Issue, id: id, repository_id: repository_id)
244 244
  end
245 245
246
  @doc """
247
  The oldest open issue in `repository` whose title normalizes to `normalized`.
248
249
  The comparison is exact on the normalized form, never a substring or keyword
250
  match: `maybe_filter_search/2` exists for a person narrowing a list, where a
251
  loose match costs nothing, but a loose match here would silently swallow a
252
  distinct request into an unrelated issue. `OpenAgents.Issues.Capture` is the
253
  only caller and `Capture.normalize_title/1` is the only normalizer; the SQL
254
  below reproduces it so the comparison happens in the index scan rather than
255
  by loading every open issue.
256
257
  Returns `nil` when nothing matches. Pull-request-backed rows are excluded:
258
  they share the number space but a pull request is not the issue a request
259
  should be folded into.
260
  """
261
  @spec open_issue_with_normalized_title(Repository.t(), String.t()) :: Issue.t() | nil
262
  def open_issue_with_normalized_title(%Repository{id: repository_id}, normalized)
263
      when is_binary(normalized) and normalized != "" do
264
    repository_id
265
    |> issue_query(state: "open", type: "issue")
266
    |> where(
267
      [issue: issue],
268
      fragment("btrim(regexp_replace(lower(?), '[^a-z0-9]+', ' ', 'g'))", issue.title) ==
269
        ^normalized
270
    )
271
    |> order_by([issue: issue], asc: issue.number)
272
    |> limit(1)
273
    |> Repo.one()
274
  end
275
276
  def open_issue_with_normalized_title(%Repository{}, _normalized), do: nil
277
246 278
  def get_issue_by_number!(%Repository{id: repository_id}, number) when is_integer(number),
247 279
    do: Repo.get_by!(Issue, repository_id: repository_id, number: number)
248 280
lib/openagents/issues/capture.ex added +328

@@ -0,0 +1,328 @@

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
lib/openagents/tools/issue_capture.ex added +202

@@ -0,0 +1,202 @@

1
defmodule OpenAgents.Tools.IssueCapture do
2
  @moduledoc """
3
  Files one unmet request from a conversation as a scoped forge issue.
4
5
  The behavior lives in `OpenAgents.Issues.Capture`, which
6
  `POST /api/v3/repos/:owner/:repo/issues/capture` also calls, so the tool and
7
  the API operation cannot drift apart.
8
9
  ## Why this one is `:external_effect`
10
11
  A filed issue is public, has a number other people cite, and notifies
12
  everyone watching the repository. Nothing on disk changed, which is exactly
13
  why the honest classification is easy to get wrong; the coder specification
14
  settles it directly — "'open an issue' is an external effect even though
15
  nothing on your disk changed" (`docs/2026-08-23-openagents-coder-cli-spec.md`,
16
  section 7.2). Its ladder puts tracker writes at **ask every time**, so this
17
  tool declares `approval_class: "external_confirmation"` — the class
18
  `OpenAgents.Modules.SurfacePolicy` requires of an external effect, and the
19
  one `open_pull_request` already uses for the same reason — with
20
  `approval_enforcement: "host_receipt"`.
21
22
  `OpenAgents.Modules.SurfacePolicy.approval/2` then demands a current,
23
  explicit, person-signed receipt bound to this exact module, version, and
24
  conversation before the tool runs — and `OpenAgents.Tools.AdmittedCatalog`
25
  applies the same check when it builds the catalog, so on a turn where the
26
  person has not consented the tool is not offered rather than offered and
27
  refused. There is no standing grant and no "always" to record: the receipt is
28
  scoped to one conversation and checked again on every call.
29
30
  ## Authority is the caller's
31
32
  The requesting account's own membership decides where an issue can be filed.
33
  `Capture.authorize/2` reads `OpenAgents.Repositories.writable?/2` for that
34
  account and nothing else, so the agent cannot file somewhere the person could
35
  not have filed by hand, and a caller without a writing role gets
36
  `repository_write_access_required` naming what is missing. The repository is
37
  always an argument, never a default: a request that names no repository is
38
  refused rather than routed to a guess.
39
  """
40
41
  @behaviour OpenAgents.Tools.Tool
42
43
  alias OpenAgents.Accounts.User
44
  alias OpenAgents.Issues.Capture
45
  alias OpenAgents.Modules.Metadata
46
  alias OpenAgents.Repo
47
  alias OpenAgents.Tools.{ExecutionContext, ExecutionResult, Tool}
48
49
  @impl true
50
  def specification do
51
    %Tool{
52
      module_id: "sarah.tool.issue_capture.v1",
53
      name: "capture_issue",
54
      version: 1,
55
      description:
56
        "File the person's feature request or bug report as an issue in a Forge repository " <>
57
          "they can write to, drafted as outcome, current behavior, and acceptance criteria. " <>
58
          "Returns the issue number and URL, and returns the existing issue instead of a " <>
59
          "second one when an open issue already has the same title. " <>
60
          "Do not call this to answer a question, to look something up, or to record a " <>
61
          "preference or a note — filing a public issue is not a way to remember something. " <>
62
          "Do not call it without the person asking for the request to be tracked, and do not " <>
63
          "call it a second time for a request you already filed in this conversation. " <>
64
          "Do not guess the repository: ask which one if the person has not named it.",
65
      input_schema: input_schema(),
66
      output_schema: output_schema(),
67
      side_effect: :external_effect,
68
      required_scope: "browser_conversation",
69
      required_authority: "repository.write",
70
      executor: %{
71
        id: "sarah.forge.issues",
72
        disclosure: "OpenAgents Forge, filing as the signed-in person"
73
      },
74
      maintainer: "OpenAgents",
75
      attribution: ["OpenAgentsInc/openagents.com"],
76
      policy_facets: %{
77
        "privacy" => "signed_browser_owner",
78
        "residency" => "application_process",
79
        "consent" => "external_confirmation"
80
      },
81
      module_metadata:
82
        Metadata.first_party("repository.write", "browser_conversation",
83
          effect: :external_effect,
84
          privacy: "signed_browser_owner",
85
          residency: "application_process",
86
          approval_class: "external_confirmation",
87
          approval_enforcement: "host_receipt"
88
        ),
89
      timeout_ms: 15_000,
90
      maximum_input_bytes: 16_384,
91
      maximum_output_bytes: 8_192,
92
      implementation: __MODULE__,
93
      tags: ["forge", "issue", "tracker", "request", "file"],
94
      # Filing runs under the person's own membership, so an unresolved owner
95
      # can never succeed. The repository itself carries no reach requirement:
96
      # its gate is per-repository write membership, which depends on an
97
      # argument the catalog has not seen (TOOL-005).
98
      reach: [:signed_in_owner]
99
    }
100
  end
101
102
  @impl true
103
  def execute(%{"repository" => repository} = arguments, %ExecutionContext{} = context)
104
      when is_binary(repository) do
105
    with {:ok, actor} <- actor(context),
106
         {:ok, captured} <- Capture.capture(actor, repository, arguments) do
107
      {:ok,
108
       %ExecutionResult{
109
         result: result(captured),
110
         target_receipt_refs: ["forge-issue:#{captured.issue.id}"]
111
       }}
112
    else
113
      {:error, {:invalid_issue, _errors}} -> {:error, :invalid_issue}
114
      {:error, reason} -> {:error, reason}
115
    end
116
  end
117
118
  def execute(_arguments, _context), do: {:error, :invalid_repository}
119
120
  defp actor(%ExecutionContext{owner_user_id: user_id}) when is_binary(user_id) do
121
    case Repo.get(User, user_id) do
122
      %User{status: "active"} = user -> {:ok, user}
123
      _absent -> {:error, :repository_authentication_required}
124
    end
125
  end
126
127
  defp actor(%ExecutionContext{}), do: {:error, :repository_authentication_required}
128
129
  defp result(%{issue: issue, repository: repository, outcome: outcome}) do
130
    path = repository.owner <> "/" <> repository.name
131
132
    %{
133
      "schema" => "openagents.captured_issue.v1",
134
      "repository" => path,
135
      "number" => issue.number,
136
      "title" => issue.title,
137
      "state" => issue.state,
138
      "url" => url(path, issue.number),
139
      "outcome" => Atom.to_string(outcome)
140
    }
141
  end
142
143
  defp url(path, number) do
144
    String.trim_trailing(OpenAgentsWeb.Endpoint.url(), "/") <> "/#{path}/issues/#{number}"
145
  end
146
147
  defp input_schema do
148
    %{
149
      "type" => "object",
150
      "additionalProperties" => false,
151
      "required" => ["repository", "problem"],
152
      "properties" => %{
153
        "repository" => %{
154
          "type" => "string",
155
          "description" =>
156
            "The repository to file in, as owner/name. Required, and never guessed: " <>
157
              "ask the person which repository if they have not said."
158
        },
159
        "problem" => %{
160
          "type" => "string",
161
          "description" =>
162
            "The outcome the person wants, in their own words. Do not add anything they " <>
163
              "did not say, and do not include conversation, prompt, or credential text."
164
        },
165
        "current_behavior" => %{
166
          "type" => "string",
167
          "description" =>
168
            "What happens today, if the person described it. Leave this out rather than " <>
169
              "inventing behavior nobody observed."
170
        },
171
        "acceptance_criteria" => %{
172
          "type" => "array",
173
          "items" => %{"type" => "string"},
174
          "description" =>
175
            "What must be true for the request to be done, if the person said. " <>
176
              "Leave this out rather than inventing criteria."
177
        }
178
      }
179
    }
180
  end
181
182
  defp output_schema do
183
    %{
184
      "type" => "object",
185
      "required" => ["schema", "repository", "number", "url", "outcome"],
186
      "properties" => %{
187
        "schema" => %{"type" => "string"},
188
        "repository" => %{"type" => "string"},
189
        "number" => %{"type" => "integer"},
190
        "title" => %{"type" => "string"},
191
        "state" => %{"type" => "string"},
192
        "url" => %{"type" => "string"},
193
        "outcome" => %{
194
          "type" => "string",
195
          "enum" => ["created", "existing"],
196
          "description" =>
197
            "`created` for a new issue, `existing` when an open issue already matched."
198
        }
199
      }
200
    }
201
  end
202
end
lib/openagents/tools/runner.ex modified +21

@@ -311,6 +311,7 @@ defmodule OpenAgents.Tools.Runner do

311 311
              :publication_receipt_stale,
312 312
              :publication_branch_refused,
313 313
              :pull_requests_disabled,
314
              :repository_write_access_required,
314 315
              :forbidden,
315 316
              :box_quota_reached,
316 317
              :box_not_owned,

@@ -458,6 +459,26 @@ defmodule OpenAgents.Tools.Runner do

458 459
  defp error_message(:repository_not_found),
459 460
    do: "The repository does not exist or you cannot access it."
460 461
462
  # Names the authority rather than the outcome. "You cannot do that" sends the
463
  # person back to the agent; naming the missing role sends them to whoever can
464
  # grant it.
465
  defp error_message(:repository_write_access_required),
466
    do:
467
      "Filing an issue there needs write access to that repository, and this account holds " <>
468
        "no owner, maintainer, or contributor role in it. Ask an owner for one."
469
470
  defp error_message(:blank_problem_statement),
471
    do: "Filing an issue needs the person's request in their own words."
472
473
  defp error_message(:problem_statement_too_long),
474
    do: "The request is too long to file as one issue. Summarize it first."
475
476
  defp error_message(:section_too_long),
477
    do: "One of the drafted issue sections is too long to file."
478
479
  defp error_message(:invalid_issue),
480
    do: "The drafted issue was rejected by the tracker."
481
461 482
  defp error_message(:ambiguous_repository_name),
462 483
    do: "More than one visible repository has that name. Use owner/name."
463 484
lib/openagents_web/api_error.ex modified +7

@@ -43,6 +43,13 @@ defmodule OpenAgentsWeb.ApiError do

43 43
    "unauthenticated" => {401, "Requires authentication"},
44 44
    "forbidden" => {403, "Forbidden"},
45 45
    "agent_participation_forbidden" => {403, "This agent may not participate in this repository"},
46
    # Capturing an issue from chat (#77). A plain `forbidden` would leave the
47
    # caller unable to tell "no such repository" from "no writing role in a
48
    # repository you can already see", and only the second is something they
49
    # can fix by asking an owner. It is only ever returned for a repository the
50
    # caller can already read, so it discloses nothing a 404 was protecting.
51
    "repository_write_access_required" =>
52
      {403, "Writing to this repository needs an owner, maintainer, or contributor role"},
46 53
    "not_found" => {404, "Not Found"},
47 54
    "label_not_on_issue" => {404, "Label does not exist on this issue"},
48 55
    "dependency_not_found" => {404, "Not a prerequisite of this issue"},
lib/openagents_web/api_route_authority.ex modified +1

@@ -330,6 +330,7 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

330 330
      "post /api/v3/forum/topics" => {:required_bearer, :forum, :legacy},
331 331
      "post /api/v3/forum/topics/:topic_id/posts" => {:required_bearer, :forum, :legacy},
332 332
      "post /api/v3/repos/:owner/:repo/issues" => {:required_bearer, :issue, :envelope},
333
      "post /api/v3/repos/:owner/:repo/issues/capture" => {:required_bearer, :issue, :envelope},
333 334
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/comments" =>
334 335
        {:required_bearer, :comment, :envelope},
335 336
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/completion_claim" =>
lib/openagents_web/controllers/issue_controller.ex modified +74

@@ -1,8 +1,10 @@

1 1
defmodule OpenAgentsWeb.IssueController do
2 2
  use OpenAgentsWeb, :controller
3 3
4
  alias OpenAgents.Accounts.User
4 5
  alias OpenAgents.Forge.Assignments
5 6
  alias OpenAgents.Issues
7
  alias OpenAgents.Issues.Capture
6 8
  alias OpenAgents.Issues.CompletionClaims
7 9
  alias OpenAgents.Issues.Evidence
8 10
  alias OpenAgents.Issues.Issue

@@ -170,6 +172,78 @@ defmodule OpenAgentsWeb.IssueController do

170 172
    end
171 173
  end
172 174
175
  @doc """
176
  Captures one problem statement as a drafted, deduplicated issue (#77).
177
178
  The API mirror of the `capture_issue` chat tool. Both call
179
  `OpenAgents.Issues.Capture.capture/3`, so the drafted template, the
180
  authorization decision, and the deduplication rule are one implementation
181
  rather than two that agree today.
182
183
  The status distinguishes the two outcomes without a body key: `201` when the
184
  issue was created, `200` when an open issue already matched and is being
185
  returned instead. A client that retries the same statement therefore sees
186
  `200` and the same number, which is what makes the operation idempotent for
187
  the caller rather than only for the database.
188
189
  Agents are refused: filing under a personal membership is the point, and an
190
  `Agent` principal holds no repository membership to file under.
191
  """
192
  def capture(conn, %{"owner" => owner, "repo" => repo} = params) do
193
    case conn.assigns[:current_user] do
194
      %User{} = actor -> capture_for(conn, actor, owner, repo, params)
195
      _agent_or_absent -> participation_forbidden(conn, conn.assigns[:current_agent])
196
    end
197
  end
198
199
  defp capture_for(conn, actor, owner, repo, params) do
200
    case write_issue(fn -> Capture.capture(actor, "#{owner}/#{repo}", params) end) do
201
      {:ok, %{issue: issue, repository: repository, outcome: outcome}} ->
202
        conn
203
        |> put_status(if(outcome == :created, do: :created, else: :ok))
204
        |> put_extensions_header()
205
        |> render(:show,
206
          issue: issue,
207
          owner: owner,
208
          repo: repo,
209
          dependencies: dependencies(issue),
210
          progress: progress(issue, actor),
211
          work: work(issue, repository, actor),
212
          evidence: evidence(issue, repository, actor),
213
          completion_claims: completion_claims(issue)
214
        )
215
216
      {:error, :repository_not_found} ->
217
        not_found(conn)
218
219
      {:error, :repository_write_access_required} ->
220
        ApiError.refuse(conn, "repository_write_access_required")
221
222
      {:error, {:invalid_issue, errors}} ->
223
        ApiError.validation_failed(conn, errors)
224
225
      {:error, %UnknownReference{} = unresolved} ->
226
        unknown_reference(conn, unresolved)
227
228
      {:error, reason} when is_atom(reason) ->
229
        ApiError.validation_failed(conn, %{"problem" => [capture_message(reason)]})
230
    end
231
  end
232
233
  defp capture_message(:blank_problem_statement),
234
    do: "can't be blank"
235
236
  defp capture_message(:problem_statement_too_long),
237
    do: "is too long to file as one issue"
238
239
  defp capture_message(:section_too_long),
240
    do: "one of the drafted sections is too long to file"
241
242
  defp capture_message(:invalid_repository),
243
    do: "the repository must be an owner/name path"
244
245
  defp capture_message(reason), do: "is invalid (#{reason})"
246
173 247
  # Writing an issue resolves a label, an assignee, and a milestone by name, and
174 248
  # a name this repository does not have is a rejected field rather than a
175 249
  # missing resource. It is caught here rather than beside the repository
lib/openagents_web/router.ex modified +4

@@ -480,6 +480,10 @@ defmodule OpenAgentsWeb.Router do

480 480
    post "/forum/topics", ForumApiController, :create_topic
481 481
    post "/forum/topics/:topic_id/posts", ForumApiController, :create_post
482 482
    post "/repos/:owner/:repo/issues", IssueController, :create
483
    # Drafts, deduplicates, and files one problem statement (#77). It sits
484
    # beside :create rather than replacing it: :create is the GitHub-compatible
485
    # operation and takes a finished issue, this one takes a sentence.
486
    post "/repos/:owner/:repo/issues/capture", IssueController, :capture
483 487
    post "/repos/:owner/:repo/issues/:issue_number/comments", CommentController, :create
484 488
485 489
    post "/repos/:owner/:repo/issues/:issue_number/completion_claim",
test/openagents/issues/capture_test.exs added +283

@@ -0,0 +1,283 @@

1
defmodule OpenAgents.Issues.CaptureTest do
2
  @moduledoc """
3
  Capturing a chat request as an issue (#77).
4
5
  The tool and the API operation are transports over this module, so the rules
6
  that matter — whose authority files the issue, what the public text may say,
7
  and when a repeat becomes the same issue rather than a second one — are
8
  proven once here.
9
  """
10
11
  use OpenAgents.DataCase, async: true
12
13
  import OpenAgents.AccountsFixtures
14
15
  alias OpenAgents.Issues
16
  alias OpenAgents.Issues.Capture
17
  alias OpenAgents.Notifications
18
  alias OpenAgents.Repositories
19
20
  setup do
21
    author = repository_user_fixture("capture-author")
22
    repository = repository_with_member_fixture(author, %{}, "maintainer")
23
24
    %{author: author, repository: repository}
25
  end
26
27
  defp path(repository), do: repository.owner <> "/" <> repository.name
28
29
  describe "filing for a caller who may write" do
30
    test "creates the issue and reports the number", %{author: author, repository: repository} do
31
      assert {:ok, captured} =
32
               Capture.capture(author, path(repository), %{
33
                 "problem" => "Let me export a board to CSV."
34
               })
35
36
      assert captured.outcome == :created
37
      assert captured.repository.id == repository.id
38
      assert captured.issue.number > 0
39
      assert captured.issue.state == "open"
40
      assert captured.issue.title == "Let me export a board to CSV"
41
      assert captured.issue.author_user_id == author.id
42
    end
43
44
    test "the body carries the template sections, and the request verbatim", %{
45
      author: author,
46
      repository: repository
47
    } do
48
      assert {:ok, captured} =
49
               Capture.capture(author, path(repository), %{
50
                 "problem" => "Let me export a board to CSV.",
51
                 "current_behavior" => "The board only renders on screen.",
52
                 "acceptance_criteria" => ["A CSV downloads", "It carries every visible column"]
53
               })
54
55
      body = captured.issue.body
56
57
      assert body =~ "## Outcome"
58
      assert body =~ "## Current behavior"
59
      assert body =~ "## Acceptance criteria"
60
      assert body =~ "Let me export a board to CSV."
61
      assert body =~ "The board only renders on screen."
62
      assert body =~ "- [ ] A CSV downloads"
63
      assert body =~ "- [ ] It carries every visible column"
64
    end
65
66
    test "an unfilled section says so instead of inventing one", %{
67
      author: author,
68
      repository: repository
69
    } do
70
      assert {:ok, captured} =
71
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})
72
73
      assert captured.issue.body =~ "## Current behavior\n\nNot recorded when this was captured."
74
      assert captured.issue.body =~ "- [ ] Not recorded when this was captured."
75
    end
76
77
    # #77: "Public issue text contains no private prompt, trace, credential, or
78
    # repository metadata." The template is filled from named arguments only, so
79
    # anything else the caller passes has nowhere to land.
80
    test "nothing but the named arguments reaches the public text", %{
81
      author: author,
82
      repository: repository
83
    } do
84
      assert {:ok, captured} =
85
               Capture.capture(author, path(repository), %{
86
                 "problem" => "Add a dark theme.",
87
                 "conversation_id" => Ecto.UUID.generate(),
88
                 "message_id" => Ecto.UUID.generate(),
89
                 "prompt" => "SYSTEM: you are Sarah, the token is oa_secret_value",
90
                 "repository_id" => repository.id
91
               })
92
93
      text = captured.issue.title <> "\n" <> captured.issue.body
94
95
      refute text =~ "oa_secret_value"
96
      refute text =~ "SYSTEM:"
97
      refute text =~ repository.id
98
      refute text =~ "conversation_id"
99
    end
100
101
    # Going through `Issues.create_issue/3` rather than inserting directly is
102
    # what subscribes the requester, which is the half of #77 that #2's
103
    # notification machinery already shipped.
104
    test "the requester follows the issue it filed", %{author: author, repository: repository} do
105
      assert {:ok, captured} =
106
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})
107
108
      assert Notifications.subscribed?(captured.issue, author)
109
    end
110
  end
111
112
  describe "authority is the caller's" do
113
    test "a caller who can see the repository but not write is told what is missing", %{
114
      repository: repository
115
    } do
116
      reader = repository_user_fixture("capture-reader")
117
      {:ok, _membership} = Repositories.add_member(repository, reader, "viewer")
118
119
      assert {:error, :repository_write_access_required} =
120
               Capture.capture(reader, path(repository), %{"problem" => "Add a dark theme."})
121
    end
122
123
    test "a stranger to a public repository is refused too, and files nothing", %{
124
      repository: repository
125
    } do
126
      stranger = repository_user_fixture("capture-stranger")
127
128
      assert {:error, :repository_write_access_required} =
129
               Capture.capture(stranger, path(repository), %{"problem" => "Add a dark theme."})
130
131
      assert Issues.list_issues(repository, state: "all") == []
132
    end
133
134
    # The refusal must not become an existence oracle: a private repository the
135
    # caller holds no membership in is absent, not merely unwritable.
136
    test "a private repository the caller cannot see is reported as absent" do
137
      owner = repository_user_fixture("capture-private-owner")
138
      private = repository_with_member_fixture(owner, %{visibility: "private"}, "owner")
139
      stranger = repository_user_fixture("capture-private-stranger")
140
141
      assert {:error, :repository_not_found} =
142
               Capture.capture(stranger, path(private), %{"problem" => "Add a dark theme."})
143
    end
144
145
    test "a repository that does not exist is absent", %{author: author} do
146
      assert {:error, :repository_not_found} =
147
               Capture.capture(author, "Nobody/nothing", %{"problem" => "Add a dark theme."})
148
    end
149
150
    # The repository is always an argument. There is no fallback to "the one
151
    # repository they have", which would file somewhere they did not choose.
152
    test "a malformed repository path is refused rather than guessed", %{author: author} do
153
      assert {:error, :invalid_repository} =
154
               Capture.capture(author, "openagents.com", %{"problem" => "Add a dark theme."})
155
    end
156
  end
157
158
  describe "deduplication" do
159
    test "a repeat returns the open issue instead of a second one", %{
160
      author: author,
161
      repository: repository
162
    } do
163
      assert {:ok, first} =
164
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})
165
166
      assert first.outcome == :created
167
168
      assert {:ok, second} =
169
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})
170
171
      assert second.outcome == :existing
172
      assert second.issue.id == first.issue.id
173
      assert length(Issues.list_issues(repository, state: "all")) == 1
174
    end
175
176
    test "case, punctuation, and spacing do not make a new issue", %{
177
      author: author,
178
      repository: repository
179
    } do
180
      assert {:ok, first} =
181
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})
182
183
      assert {:ok, second} =
184
               Capture.capture(author, path(repository), %{"problem" => "add  a  DARK theme!"})
185
186
      assert second.outcome == :existing
187
      assert second.issue.id == first.issue.id
188
    end
189
190
    # The exact-match rule is a deliberate limitation, so its failure direction
191
    # is part of the contract: it misses real duplicates rather than swallowing
192
    # distinct requests. If issue embeddings ever land, this test changes.
193
    test "a request that merely contains an existing title is its own issue", %{
194
      author: author,
195
      repository: repository
196
    } do
197
      assert {:ok, first} =
198
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})
199
200
      assert {:ok, second} =
201
               Capture.capture(author, path(repository), %{
202
                 "problem" => "Add a dark theme to the mobile app."
203
               })
204
205
      assert second.outcome == :created
206
      refute second.issue.id == first.issue.id
207
    end
208
209
    test "a closed issue does not absorb a fresh request", %{
210
      author: author,
211
      repository: repository
212
    } do
213
      assert {:ok, first} =
214
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})
215
216
      {:ok, _closed} = Issues.update_issue(first.issue, %{"state" => "closed"}, author)
217
218
      assert {:ok, second} =
219
               Capture.capture(author, path(repository), %{"problem" => "Add a dark theme."})
220
221
      assert second.outcome == :created
222
      refute second.issue.id == first.issue.id
223
    end
224
225
    test "an identical request in another repository is its own issue", %{author: author} do
226
      first_repository = repository_with_member_fixture(author, %{}, "owner")
227
      second_repository = repository_with_member_fixture(author, %{}, "owner")
228
229
      assert {:ok, first} =
230
               Capture.capture(author, path(first_repository), %{"problem" => "Add a dark theme."})
231
232
      assert {:ok, second} =
233
               Capture.capture(author, path(second_repository), %{
234
                 "problem" => "Add a dark theme."
235
               })
236
237
      assert first.outcome == :created
238
      assert second.outcome == :created
239
      refute second.issue.id == first.issue.id
240
    end
241
  end
242
243
  describe "the statement itself" do
244
    test "a blank statement is refused before anything is filed", %{
245
      author: author,
246
      repository: repository
247
    } do
248
      assert {:error, :blank_problem_statement} =
249
               Capture.capture(author, path(repository), %{"problem" => "   "})
250
251
      assert {:error, :blank_problem_statement} =
252
               Capture.capture(author, path(repository), %{})
253
254
      assert Issues.list_issues(repository, state: "all") == []
255
    end
256
257
    test "an oversized statement is refused rather than truncated into a title", %{
258
      author: author,
259
      repository: repository
260
    } do
261
      assert {:error, :problem_statement_too_long} =
262
               Capture.capture(author, path(repository), %{
263
                 "problem" => String.duplicate("a", 4_001)
264
               })
265
    end
266
267
    test "a long statement still yields a bounded title", %{
268
      author: author,
269
      repository: repository
270
    } do
271
      statement =
272
        "Let me export a board to CSV so that finance can reconcile the month " <>
273
          "without asking three different people for screenshots of the same view."
274
275
      assert {:ok, captured} =
276
               Capture.capture(author, path(repository), %{"problem" => statement})
277
278
      assert String.length(captured.issue.title) <= 72
279
      # The whole statement survives in the body even though the title is cut.
280
      assert captured.issue.body =~ "three different people"
281
    end
282
  end
283
end
test/openagents/tools/issue_capture_test.exs added +267

@@ -0,0 +1,267 @@

1
defmodule OpenAgents.Tools.IssueCaptureTest do
2
  @moduledoc """
3
  The `capture_issue` tool, driven through `OpenAgents.Tools.Runner` (#77).
4
5
  The domain rules are proven in `OpenAgents.Issues.CaptureTest`. What this
6
  file proves is the part only the tool has: that the ask-every-time gate is
7
  real, that a caller's own membership is what files the issue, and that the
8
  typed refusals a person reads say which authority is missing.
9
  """
10
11
  use OpenAgents.DataCase, async: false
12
13
  import OpenAgents.AccountsFixtures
14
15
  alias OpenAgents.{Accounts, Conversations, Issues, Repositories}
16
  alias OpenAgents.Tools.{AdmittedCatalog, ConversationExecutionContext, Registry, Runner}
17
18
  @modules [OpenAgents.Tools.IssueCapture, OpenAgents.Tools.ModuleDiscover]
19
20
  setup do
21
    {:ok, snapshot} = Registry.build(@modules)
22
    scope = signed_in_scope("issue-capture")
23
    repository = repository_with_member_fixture(scope.user, %{}, "maintainer")
24
25
    %{snapshot: snapshot, scope: scope, repository: repository}
26
  end
27
28
  describe "filing for an authorized caller" do
29
    test "creates the issue and returns its number and URL", context do
30
      assert {:ok, outcome} =
31
               capture(context, %{repository: path(context), problem: "Add CSV export."})
32
33
      assert outcome["status"] == "succeeded"
34
35
      result = outcome["result"]
36
      assert result["schema"] == "openagents.captured_issue.v1"
37
      assert result["outcome"] == "created"
38
      assert result["title"] == "Add CSV export"
39
      assert result["state"] == "open"
40
      assert result["number"] > 0
41
      assert result["repository"] == path(context)
42
      assert result["url"] =~ "/#{path(context)}/issues/#{result["number"]}"
43
44
      issue = Issues.get_issue_by_number!(context.repository, result["number"])
45
      assert issue.author_user_id == context.scope.user.id
46
    end
47
48
    # TOOL-004: an external effect must name what it affected.
49
    test "the outcome names the issue it created", context do
50
      assert {:ok, outcome} =
51
               capture(context, %{repository: path(context), problem: "Add CSV export."})
52
53
      assert [reference] = outcome["target_receipt_refs"]
54
      assert reference =~ "forge-issue:"
55
    end
56
57
    test "a repeat returns the first issue rather than filing a second", context do
58
      assert {:ok, first} =
59
               capture(context, %{repository: path(context), problem: "Add CSV export."})
60
61
      assert {:ok, second} =
62
               capture(context, %{repository: path(context), problem: "Add CSV export."})
63
64
      assert first["result"]["outcome"] == "created"
65
      assert second["result"]["outcome"] == "existing"
66
      assert second["result"]["number"] == first["result"]["number"]
67
      assert length(Issues.list_issues(context.repository, state: "all")) == 1
68
    end
69
  end
70
71
  describe "refusing a caller without write access" do
72
    test "names the missing role instead of failing vaguely", context do
73
      stranger = signed_in_scope("issue-capture-stranger")
74
      other = repository_with_member_fixture(stranger.user, %{}, "owner")
75
      {:ok, _membership} = Repositories.add_member(other, context.scope.user, "viewer")
76
77
      assert {:ok, outcome} =
78
               capture(context, %{
79
                 repository: other.owner <> "/" <> other.name,
80
                 problem: "Add CSV export."
81
               })
82
83
      assert outcome["status"] == "refused"
84
      assert outcome["error"]["code"] == "repository_write_access_required"
85
      assert outcome["error"]["message"] =~ "write access"
86
      assert outcome["error"]["message"] =~ "owner, maintainer, or contributor"
87
      refute outcome["error"]["message"] == "The tool call failed validation or execution."
88
89
      assert Issues.list_issues(other, state: "all") == []
90
    end
91
92
    # A refusal must not double as an existence oracle for a private repository.
93
    test "a repository the caller cannot see is absent, not merely unwritable", context do
94
      stranger = signed_in_scope("issue-capture-private")
95
      private = repository_with_member_fixture(stranger.user, %{visibility: "private"}, "owner")
96
97
      assert {:ok, outcome} =
98
               capture(context, %{
99
                 repository: private.owner <> "/" <> private.name,
100
                 problem: "Add CSV export."
101
               })
102
103
      # `repository_not_found` classifies as `failed` rather than `refused`,
104
      # the same as it does for the read-only repository tools: the caller is
105
      # not being told they lack authority, they are being told there is
106
      # nothing there. Keeping the two apart is the point of the test.
107
      refute outcome["status"] == "succeeded"
108
      assert outcome["error"]["code"] == "repository_not_found"
109
    end
110
111
    test "a conversation that resolves to no account files nothing", %{snapshot: snapshot} do
112
      assert {:ok, outcome} =
113
               Runner.run(
114
                 snapshot,
115
                 call(%{repository: "OpenAgentsInc/openagents.com", problem: "Add CSV export."}),
116
                 unbound_context(snapshot)
117
               )
118
119
      refute outcome["status"] == "succeeded"
120
      assert outcome["error"]["code"] == "repository_authentication_required"
121
    end
122
  end
123
124
  describe "the ask-every-time gate" do
125
    # The whole basis on which a writing tool is admitted to the shipped
126
    # catalog at all (TOOL-006). If this stops holding, `capture_issue` becomes
127
    # a tool the model can file public issues with unasked.
128
    test "without a current receipt the tool refuses", context do
129
      assert {:ok, outcome} =
130
               Runner.run(
131
                 context.snapshot,
132
                 call(%{repository: path(context), problem: "Add CSV export."}),
133
                 consentless_context(context)
134
               )
135
136
      assert outcome["status"] == "refused"
137
      assert outcome["error"]["code"] == "module_approval_required"
138
      assert Issues.list_issues(context.repository, state: "all") == []
139
    end
140
141
    test "without a current receipt the tool is not even offered", context do
142
      offered = offered(context.snapshot, consentless_context(context))
143
144
      refute "capture_issue" in offered
145
      assert "module_discover" in offered
146
    end
147
148
    test "with a current receipt it is offered", context do
149
      assert "capture_issue" in offered(context.snapshot, consenting_context(context))
150
    end
151
152
    # Consent is scoped to one conversation. A receipt minted elsewhere is not
153
    # a standing grant this conversation can spend.
154
    test "a receipt for another conversation does not carry over", context do
155
      elsewhere = %{
156
        consentless_context(context)
157
        | approval_receipts: [receipt("conversation:#{Ecto.UUID.generate()}")]
158
      }
159
160
      assert {:ok, outcome} =
161
               Runner.run(
162
                 context.snapshot,
163
                 call(%{repository: path(context), problem: "Add CSV export."}),
164
                 elsewhere
165
               )
166
167
      assert outcome["status"] == "refused"
168
      assert outcome["error"]["code"] == "module_approval_required"
169
    end
170
  end
171
172
  describe "the declaration" do
173
    test "says it writes, and says when not to call it", %{snapshot: snapshot} do
174
      tool = Map.fetch!(snapshot.tools, "capture_issue")
175
176
      assert tool.side_effect == :external_effect
177
      assert tool.required_authority == "repository.write"
178
      assert tool.reach == [:signed_in_owner]
179
      assert tool.module_metadata["approval_class"] == "external_confirmation"
180
      assert tool.module_metadata["facets"]["approval_enforcement"] == "host_receipt"
181
182
      # Re-admission criterion 5: a description that only names a capability
183
      # makes the model try the tool and read the refusal.
184
      assert tool.description =~ "Do not call"
185
      assert tool.description =~ "Do not guess the repository"
186
187
      # The authority a conversation caller actually holds.
188
      assert MapSet.member?(ConversationExecutionContext.authorities(), tool.required_authority)
189
    end
190
  end
191
192
  defp capture(context, arguments) do
193
    Runner.run(context.snapshot, call(arguments), consenting_context(context))
194
  end
195
196
  defp call(arguments) do
197
    %{
198
      call_id: "call-#{System.unique_integer([:positive])}",
199
      name: "capture_issue",
200
      version: 1,
201
      raw_arguments: JSON.encode!(arguments)
202
    }
203
  end
204
205
  defp path(%{repository: repository}), do: repository.owner <> "/" <> repository.name
206
207
  defp offered(snapshot, context) do
208
    snapshot
209
    |> AdmittedCatalog.provider_definitions(context, "file this request as an issue", top_k: 64)
210
    |> Enum.map(& &1.name)
211
  end
212
213
  defp consentless_context(%{scope: scope, snapshot: snapshot}) do
214
    ConversationExecutionContext.build(%{
215
      surface: "text",
216
      conversation_id: scope.conversation.id,
217
      owner_visitor_id: scope.owner.id,
218
      owner_user_id: scope.owner.user_id,
219
      module_registry_snapshot: snapshot
220
    })
221
  end
222
223
  defp consenting_context(context) do
224
    consentless = consentless_context(context)
225
    %{consentless | approval_receipts: [receipt(consentless.scope_ref)]}
226
  end
227
228
  # What a consent surface mints when the person confirms this one filing:
229
  # bound to the module, the version, and this conversation, and explicit.
230
  defp receipt(scope_ref) do
231
    %{
232
      "schema" => "sarah.module_approval.v1",
233
      "approval_class" => "external_confirmation",
234
      "module_id" => "sarah.tool.issue_capture.v1",
235
      "version" => 1,
236
      "scope_ref" => scope_ref,
237
      "explicit" => true,
238
      "actor_type" => "person",
239
      "receipt_ref" => "issue-capture-consent:#{System.unique_integer([:positive])}"
240
    }
241
  end
242
243
  defp unbound_context(snapshot) do
244
    %OpenAgents.Tools.ExecutionContext{
245
      scope: "browser_conversation",
246
      scope_ref: "conversation:unbound",
247
      authorities: ConversationExecutionContext.authorities(),
248
      approval_receipts: [receipt("conversation:unbound")],
249
      surface: "text",
250
      module_registry_snapshot: snapshot
251
    }
252
  end
253
254
  defp signed_in_scope(login) do
255
    {:ok, user} =
256
      Accounts.upsert_github_user(%{
257
        github_id: System.unique_integer([:positive]),
258
        github_login: "#{login}-#{System.unique_integer([:positive])}",
259
        github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
260
      })
261
262
    {:ok, conversation} = Conversations.ensure_conversation(user)
263
    owner = Conversations.get_conversation_owner!(conversation)
264
265
    %{user: user, owner: owner, conversation: conversation}
266
  end
267
end
test/openagents/tools/reach_test.exs modified +6 -2

@@ -42,9 +42,13 @@ defmodule OpenAgents.Tools.ReachTest do

42 42
43 43
      # `computer_list` deliberately needs no paired Computer: listing zero of
44 44
      # them is how the model learns to tell the person to pair one. Repository
45
      # tools declare nothing because their gate is per-repository membership,
46
      # which depends on an argument the catalog has not seen yet.
45
      # *read* tools declare nothing because their gate is per-repository
46
      # membership, which depends on an argument the catalog has not seen yet.
47
      # `capture_issue` does declare an owner: it files under the person's own
48
      # membership, so no owner means no possible success, whatever repository
49
      # the argument later names.
47 50
      assert declared == %{
51
               "capture_issue" => [:signed_in_owner],
48 52
               "computer_agent" => [:signed_in_owner, :paired_computer],
49 53
               "computer_devin" => [:signed_in_owner, :paired_computer],
50 54
               "computer_list" => [:signed_in_owner],
test/openagents/tools/selector_test.exs modified +10 -1

@@ -11,11 +11,20 @@ defmodule OpenAgents.Tools.SelectorTest do

11 11
  end
12 12
13 13
  describe "select/3 (lexical, embeddings off)" do
14
    # `top_k: 16` rather than 12, because 12 cut a tie in half. `computer_agent`
15
    # (0.778), `computer_devin`, `deep_work`, and `computer_probe` earn their
16
    # places on real terms, but `computer_list` scores 0.333 on the stopwords
17
    # "the", "this", and "use" alone, which puts it in a six-way tie broken by
18
    # name. It held slot 13 only until a tool sorting before "computer_list"
19
    # joined the fixture catalog — `capture_issue` did. Admitting the whole tie
20
    # tier tests what this is meant to test, that the delegation chain is
21
    # reachable in a realistic budget, instead of where the alphabet happens to
22
    # cut.
14 23
    test "a delegation intent surfaces the whole computer delegation chain", %{snapshot: snapshot} do
15 24
      names =
16 25
        snapshot
17 26
        |> Selector.select_tools("use the machine to delegate this coding task to claude",
18
          top_k: 12
27
          top_k: 16
19 28
        )
20 29
        |> Enum.map(& &1.name)
21 30
test/openagents/tools/shipped_catalog_test.exs modified +70 -11

@@ -1,6 +1,6 @@

1 1
defmodule OpenAgents.Tools.ShippedCatalogTest do
2 2
  @moduledoc """
3
  The shipped tool catalog is a closed, read-only set (TOOL-006).
3
  The shipped tool catalog is a closed set, read-only but for consent (TOOL-006).
4 4
5 5
  These assertions read `config/config.exs` directly rather than the ambient
6 6
  `:tools` application environment, because the test environment deliberately

@@ -10,22 +10,35 @@ defmodule OpenAgents.Tools.ShippedCatalogTest do

10 10
11 11
  use ExUnit.Case, async: true
12 12
13
  alias OpenAgents.Modules.SurfacePolicy
13 14
  alias OpenAgents.Tools.{ConversationExecutionContext, Registry}
14 15
15
  # The zero base recorded in `docs/2026-08-23-agent-tools-zero-base.md`.
16
  # Changing this list is a policy change: read section 6 first.
16
  # The zero base recorded in `docs/2026-08-23-agent-tools-zero-base.md`, plus
17
  # the first tool admitted back. Changing this list is a policy change: read
18
  # section 6 first.
17 19
  @shipped [
18 20
    OpenAgents.Tools.ModuleDiscover,
19 21
    OpenAgents.Tools.RepoRead,
20 22
    OpenAgents.Tools.RepoGrep,
21 23
    OpenAgents.Tools.RepoList,
22 24
    OpenAgents.Tools.ConnectedRepositoryRead,
23
    OpenAgents.Tools.ConnectedRepositoryList
25
    OpenAgents.Tools.ConnectedRepositoryList,
26
    OpenAgents.Tools.IssueCapture
24 27
  ]
25 28
26
  # The authorities every conversation caller already holds, and that a
27
  # read-only tool can rely on. Widening this set widens the catalog.
28
  @admitted_authorities MapSet.new(["module.discover", "repository.read"])
29
  # Tools that ship without being read-only. Every one must be gated on a
30
  # current consent receipt, which the effect test below proves rather than
31
  # trusts. This list is the whole of the exception: a tool that writes and is
32
  # not named here fails.
33
  @consent_gated [OpenAgents.Tools.IssueCapture]
34
35
  # The authorities every conversation caller already holds, and that a shipped
36
  # tool can rely on. Widening this set widens the catalog.
37
  @admitted_authorities MapSet.new([
38
                          "module.discover",
39
                          "repository.read",
40
                          "repository.write"
41
                        ])
29 42
30 43
  defp shipped_modules do
31 44
    "config/config.exs"

@@ -37,16 +50,62 @@ defmodule OpenAgents.Tools.ShippedCatalogTest do

37 50
    assert shipped_modules() == @shipped
38 51
  end
39 52
40
  test "every shipped tool is read-only" do
53
  test "every shipped tool is read-only, or is gated on a current consent receipt" do
41 54
    {:ok, snapshot} = Registry.build(shipped_modules())
55
    consent_gated = MapSet.new(@consent_gated)
42 56
43 57
    for {name, tool} <- snapshot.tools do
44
      assert tool.side_effect == :read_only,
45
             "#{name} ships with side effect #{inspect(tool.side_effect)}; " <>
46
               "the shipped catalog admits read-only tools only"
58
      if tool.side_effect == :read_only do
59
        refute MapSet.member?(consent_gated, tool.implementation),
60
               "#{name} is read-only and needs no consent gate; drop it from @consent_gated"
61
      else
62
        assert MapSet.member?(consent_gated, tool.implementation),
63
               "#{name} ships with side effect #{inspect(tool.side_effect)} and is not in " <>
64
                 "@consent_gated. A writing tool is admitted only as a deliberate policy " <>
65
                 "change; read docs/2026-08-23-agent-tools-zero-base.md section 6 first"
66
      end
67
    end
68
  end
69
70
  # The exception is only safe because the gate is real. A tool that writes
71
  # must be one `SurfacePolicy` refuses without an explicit, current,
72
  # person-signed receipt — never one whose metadata quietly opts out through
73
  # the `executor_consent` path that lets a reversible write run unasked.
74
  # Without this, adding a name to @consent_gated would be enough to ship an
75
  # ungated write.
76
  test "a shipped tool that writes actually refuses a caller who has not consented" do
77
    {:ok, snapshot} = Registry.build(shipped_modules())
78
79
    writing =
80
      for {_name, tool} <- snapshot.tools, tool.side_effect != :read_only, do: tool
81
82
    assert length(writing) == length(@consent_gated)
83
84
    for tool <- writing do
85
      {:ok, artifact} = Registry.module_for_tool(snapshot, tool.name, tool.version)
86
87
      assert SurfacePolicy.require_target_receipt?(artifact),
88
             "#{tool.name} writes but claims no affected target"
89
90
      assert {:error, :module_approval_required} =
91
               SurfacePolicy.authorize_execution(artifact, consentless_context()),
92
             "#{tool.name} writes and ran for a caller holding no approval receipt"
47 93
    end
48 94
  end
49 95
96
  defp consentless_context do
97
    %OpenAgents.Tools.ExecutionContext{
98
      scope: "browser_conversation",
99
      scope_ref: "conversation:#{Ecto.UUID.generate()}",
100
      authorities: ConversationExecutionContext.authorities(),
101
      approval_receipts: [],
102
      surface: "text",
103
      conversation_id: Ecto.UUID.generate(),
104
      owner_user_id: Ecto.UUID.generate(),
105
      owner_visitor_id: Ecto.UUID.generate()
106
    }
107
  end
108
50 109
  test "every shipped tool needs only an authority a conversation caller holds" do
51 110
    {:ok, snapshot} = Registry.build(shipped_modules())
52 111
    conversation_authorities = ConversationExecutionContext.authorities()
test/openagents_web/controllers/issue_capture_controller_test.exs added +142

@@ -0,0 +1,142 @@

1
defmodule OpenAgentsWeb.IssueCaptureControllerTest do
2
  @moduledoc """
3
  `POST /api/v3/repos/:owner/:repo/issues/capture` (#77).
4
5
  The API operation and the `capture_issue` chat tool are two transports over
6
  `OpenAgents.Issues.Capture`, so what this file proves is that the transport
7
  does not lose anything: the same draft, the same authorization decision, and
8
  the same deduplication answer reach a client that never opens a chat.
9
  """
10
11
  use OpenAgentsWeb.ConnCase
12
13
  import OpenAgents.AccountsFixtures
14
15
  alias OpenAgents.{Issues, Repositories}
16
17
  describe "capturing a request" do
18
    setup %{conn: conn} do
19
      {:ok, conn: put_forge_api_token(conn, "issue-capture", repository())}
20
    end
21
22
    test "POST .../issues/capture files a drafted issue and answers 201", %{conn: conn} do
23
      conn =
24
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/capture", %{
25
          problem: "Let me export a board to CSV.",
26
          current_behavior: "The board only renders on screen.",
27
          acceptance_criteria: ["A CSV downloads"]
28
        })
29
30
      assert %{"number" => number, "title" => title, "body" => body, "state" => "open"} =
31
               json_response(conn, 201)
32
33
      assert title == "Let me export a board to CSV"
34
      assert body =~ "## Outcome"
35
      assert body =~ "## Current behavior"
36
      assert body =~ "## Acceptance criteria"
37
      assert body =~ "- [ ] A CSV downloads"
38
39
      assert Issues.get_issue_by_number!(repository(), number).title == title
40
    end
41
42
    # Retrying is the normal client behavior, and #77 requires it return the
43
    # issue already filed. The status is what carries the difference, so a
44
    # client can tell the two apart without a body key of ours.
45
    test "a repeat answers 200 with the same issue", %{conn: conn} do
46
      created =
47
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/capture", %{
48
          problem: "Let me export a board to CSV."
49
        })
50
51
      assert %{"number" => number} = json_response(created, 201)
52
53
      repeated =
54
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/capture", %{
55
          problem: "let me export a board to CSV!"
56
        })
57
58
      assert %{"number" => ^number} = json_response(repeated, 200)
59
      assert length(Issues.list_issues(repository(), state: "all")) == 1
60
    end
61
62
    test "a blank statement is a validation failure, not a filed issue", %{conn: conn} do
63
      conn =
64
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/capture", %{problem: "  "})
65
66
      assert %{"code" => "validation_failed", "errors" => errors} = json_response(conn, 422)
67
      assert errors["problem"] == ["can't be blank"]
68
      assert Issues.list_issues(repository(), state: "all") == []
69
    end
70
  end
71
72
  describe "authorization is the caller's" do
73
    test "a reader who cannot write is told which role is missing", %{conn: conn} do
74
      reader = github_user("api-token-issue-capture-reader")
75
      {:ok, _membership} = Repositories.add_member(repository(), reader, "viewer")
76
      conn = put_forge_api_token(conn, "issue-capture-reader")
77
78
      conn =
79
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/capture", %{
80
          problem: "Let me export a board to CSV."
81
        })
82
83
      assert %{"code" => "repository_write_access_required", "message" => message} =
84
               json_response(conn, 403)
85
86
      assert message =~ "owner, maintainer, or contributor"
87
      assert Issues.list_issues(repository(), state: "all") == []
88
    end
89
90
    # The refusal above is only safe to give for a repository the caller can
91
    # already read. One they cannot read stays a 404.
92
    test "a private repository the caller cannot see stays a 404", %{conn: conn} do
93
      owner = github_user("issue-capture-private-owner")
94
      private = repository_with_member_fixture(owner, %{visibility: "private"}, "owner")
95
96
      conn = put_forge_api_token(conn, "issue-capture-private-stranger")
97
98
      conn =
99
        post(conn, ~p"/api/v3/repos/#{private.owner}/#{private.name}/issues/capture", %{
100
          problem: "Let me export a board to CSV."
101
        })
102
103
      assert json_response(conn, 404)
104
    end
105
106
    test "an anonymous caller is refused before the controller runs", %{conn: conn} do
107
      conn =
108
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/capture", %{
109
          problem: "Let me export a board to CSV."
110
        })
111
112
      assert json_response(conn, 401)
113
      assert Issues.list_issues(repository(), state: "all") == []
114
    end
115
116
    # Filing under a personal membership is the point of the operation, and an
117
    # agent principal holds none, so it is refused rather than silently filed
118
    # under nobody.
119
    test "an agent credential cannot capture", %{conn: conn} do
120
      {:ok, _agent, credential} =
121
        OpenAgents.Agents.register(%{
122
          handle: "issue-capture-agent",
123
          display_name: "Issue capture agent",
124
          registration_ip: "192.0.2.77"
125
        })
126
127
      conn =
128
        conn
129
        |> put_req_header("authorization", "Bearer #{credential}")
130
        |> post(~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/capture", %{
131
          problem: "Let me export a board to CSV."
132
        })
133
134
      assert json_response(conn, 403)
135
      assert Issues.list_issues(repository(), state: "all") == []
136
    end
137
  end
138
139
  defp repository do
140
    Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
141
  end
142
end

This page updates live while a promote is in flight · changelog