Record the repository on a thread

7b8f93942f8f · AtlantisPleb · · parent 7023ed4ea42a

Record the repository on a thread

POST /api/v3/threads accepts an optional repository field: the owner/name
string the opener composes, trimmed, non-blank when present, capped at
200 bytes, with no foreign key and no format rule beyond that, because a
thread may concern a repository the forge does not host. The field comes
back in every thread view, GET /api/v3/threads?repository= narrows the
listing by exact match, the thread list shows it on the shell row, and
the extension manifest documents the parameter. This is what lets
`openagents coder --resume` filter its picker structurally instead of
parsing back the objective sentence the CLI itself composed (issue #210).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <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.

pushed
by user · WAL seq 307 · 2026-08-24T20:39:21.651022Z

Changed files

  • modified lib/openagents/threads.ex
  • modified lib/openagents/threads/thread.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified lib/openagents_web/live/thread_index_live.ex
  • added priv/repo/migrations/20260824203139_add_repository_to_threads.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs
  • modified test/openagents_web/live/thread_index_live_test.exs

Diff

8 files changed, +210 -4

lib/openagents/threads.ex modified +14 -1

@@ -136,6 +136,7 @@ defmodule OpenAgents.Threads do

136 136
137 137
    attributes = %{
138 138
      objective: objective,
139
      repository: Keyword.get(options, :repository),
139 140
      model: Keyword.get(options, :model) || Models.default_id(),
140 141
      reasoning_effort:
141 142
        OpenRouter.reasoning_effort(Keyword.get(options, :reasoning, @default_reasoning)),

@@ -191,7 +192,13 @@ defmodule OpenAgents.Threads do

191 192
192 193
  def get_for_user(_user, _thread_id), do: nil
193 194
194
  @doc "The account's threads, newest first, bounded."
195
  @doc """
196
  The account's threads, newest first, bounded.
197
198
  `:repository` narrows the listing to threads recorded against exactly that
199
  repository string — an exact match on a bounded field the opener wrote, not a
200
  search. A resume picker filters here rather than parsing objectives.
201
  """
195 202
  @spec list_for_user(User.t(), keyword()) :: [Thread.t()]
196 203
  def list_for_user(%User{id: user_id}, options \\ []) do
197 204
    limit = options |> Keyword.get(:limit, @maximum_listed) |> min(@maximum_listed) |> max(1)

@@ -203,9 +210,15 @@ defmodule OpenAgents.Threads do

203 210
      order_by: [desc: t.inserted_at, desc: t.id],
204 211
      limit: ^limit
205 212
    )
213
    |> in_repository(Keyword.get(options, :repository))
206 214
    |> Repo.all()
207 215
  end
208 216
217
  defp in_repository(query, repository) when is_binary(repository),
218
    do: from(t in query, where: t.repository == ^repository)
219
220
  defp in_repository(query, _absent), do: query
221
209 222
  @doc """
210 223
  The thread's transcript, oldest first, bounded.
211 224
lib/openagents/threads/thread.ex modified +10 -1

@@ -30,10 +30,12 @@ defmodule OpenAgents.Threads.Thread do

30 30
  @permission_profiles ~w(read_only workspace_write)
31 31
  @reasoning_efforts ~w(none minimal low medium high max)
32 32
  @objective_bytes 32_768
33
  @repository_bytes 200
33 34
34 35
  schema "threads" do
35 36
    belongs_to :owner_visitor, Visitor
36 37
    field :objective, :string, redact: true
38
    field :repository, :string
37 39
    field :status, :string, default: "open"
38 40
    field :model, :string
39 41
    field :reasoning_effort, :string

@@ -65,10 +67,15 @@ defmodule OpenAgents.Threads.Thread do

65 67
  The immutable capture at open time. `owner_visitor_id`, `status`,
66 68
  `generation`, and `started_at` are set by the context, never cast from a
67 69
  caller.
70
71
  `repository` is optional and deliberately unvalidated against the forge's
72
  repository table: a thread may concern a repository the forge does not host,
73
  so the field records the opener's `owner/name` string, bounded, with no
74
  foreign key and no format rule beyond non-blank.
68 75
  """
69 76
  def open_changeset(attributes, owner_visitor_id, now) do
70 77
    %__MODULE__{}
71
    |> cast(attributes, [:objective, :model, :reasoning_effort, :permission_profile])
78
    |> cast(attributes, [:objective, :model, :reasoning_effort, :permission_profile, :repository])
72 79
    |> put_change(:owner_visitor_id, owner_visitor_id)
73 80
    |> put_change(:status, "open")
74 81
    |> put_change(:generation, 0)

@@ -76,11 +83,13 @@ defmodule OpenAgents.Threads.Thread do

76 83
    |> validate_required([:objective, :model, :reasoning_effort, :permission_profile])
77 84
    |> validate_length(:objective, min: 1, max: @objective_bytes, count: :bytes)
78 85
    |> validate_length(:model, min: 1, max: 200)
86
    |> validate_length(:repository, min: 1, max: @repository_bytes, count: :bytes)
79 87
    |> validate_inclusion(:reasoning_effort, @reasoning_efforts)
80 88
    |> validate_inclusion(:permission_profile, @permission_profiles)
81 89
    |> foreign_key_constraint(:owner_visitor_id)
82 90
    |> check_constraint(:status, name: :threads_status_check)
83 91
    |> check_constraint(:objective, name: :threads_objective_bound_check)
92
    |> check_constraint(:repository, name: :threads_repository_bound_check)
84 93
    |> check_constraint(:reasoning_effort, name: :threads_reasoning_effort_check)
85 94
    |> check_constraint(:permission_profile, name: :threads_permission_profile_check)
86 95
  end
lib/openagents_web/controllers/api_extension_controller.ex modified +13

@@ -436,6 +436,19 @@ defmodule OpenAgentsWeb.ApiExtensionController do

436 436
            "What this body of work is for. Required, non-blank, and capped " <>
437 437
              "at 32 KB."
438 438
        },
439
        "repository" => %{
440
          "endpoint" => "POST /api/v3/threads",
441
          "type" => "string",
442
          "description" =>
443
            "The repository the work concerns, as the opener names it " <>
444
              "(owner/name). Optional, trimmed, non-blank when present, and " <>
445
              "capped at 200 bytes; not validated against the forge's " <>
446
              "repositories, because a thread may concern a repository the " <>
447
              "forge does not host. Returned in every thread view, and " <>
448
              "GET /api/v3/threads?repository= filters the listing by exact " <>
449
              "match, so a resume picker filters structurally instead of " <>
450
              "parsing the objective back."
451
        },
439 452
        "model" => %{
440 453
          "endpoint" => "POST /api/v3/threads",
441 454
          "type" => "string",
lib/openagents_web/controllers/thread_controller.ex modified +43 -2

@@ -40,8 +40,9 @@ defmodule OpenAgentsWeb.ThreadController do

40 40
41 41
  def create(conn, params) do
42 42
    with {:ok, objective} <- objective(params),
43
         {:ok, repository} <- repository(params),
43 44
         {:ok, options} <- execution_shape(params) do
44
      open(conn, objective, options)
45
      open(conn, objective, options ++ repository)
45 46
    else
46 47
      {:refused, field, message} -> ApiError.validation_failed(conn, %{field => [message]})
47 48
      {:unavailable, model_id} -> unavailable_model(conn, model_id)

@@ -59,7 +60,8 @@ defmodule OpenAgentsWeb.ThreadController do

59 60
    user = conn.assigns.current_user
60 61
    _reaped = Threads.reap_expired(user)
61 62
62
    threads = Threads.list_for_user(user, listing_options(params))
63
    threads =
64
      Threads.list_for_user(user, params |> listing_options() |> repository_filter(params))
63 65
64 66
    conn
65 67
    |> put_extension_header()

@@ -366,6 +368,23 @@ defmodule OpenAgentsWeb.ThreadController do

366 368
    end
367 369
  end
368 370
371
  # An exact match on the recorded string, so `?repository=` narrows the
372
  # listing to the threads opened against that repository. A blank filter is
373
  # no filter: nothing records a blank repository, and an empty listing would
374
  # read as an account with no threads.
375
  defp repository_filter(options, params) do
376
    case Map.get(params, "repository") do
377
      value when is_binary(value) ->
378
        case String.trim(value) do
379
          "" -> options
380
          repository -> Keyword.put(options, :repository, repository)
381
        end
382
383
      _absent ->
384
        options
385
    end
386
  end
387
369 388
  defp event_parameters(params) do
370 389
    with {:ok, event_type} <- event_type(params),
371 390
         {:ok, payload} <- payload(params) do

@@ -453,6 +472,27 @@ defmodule OpenAgentsWeb.ThreadController do

453 472
    {:refused, "objective", "A thread requires an objective: what this body of work is for."}
454 473
  end
455 474
475
  # Optional, trimmed, non-blank when present. No format rule and no lookup
476
  # against the forge's repository table: a thread may concern a repository the
477
  # forge does not host, so the field records the opener's `owner/name` string
478
  # as given. The bound is the changeset's (issue #210).
479
  defp repository(%{"repository" => repository}) when is_binary(repository) do
480
    case String.trim(repository) do
481
      "" ->
482
        {:refused, "repository",
483
         "The repository names where the work runs and cannot be blank. Omit it instead."}
484
485
      trimmed ->
486
        {:ok, [repository: trimmed]}
487
    end
488
  end
489
490
  defp repository(%{"repository" => repository}) do
491
    {:refused, "repository", "#{inspect(repository)} is not a string."}
492
  end
493
494
  defp repository(_params), do: {:ok, []}
495
456 496
  defp execution_shape(params) do
457 497
    with {:ok, model} <- admitted(params, "model", Models.ids(), Models.default_id()),
458 498
         :ok <- serving(model),

@@ -542,6 +582,7 @@ defmodule OpenAgentsWeb.ThreadController do

542 582
      "id" => thread.id,
543 583
      "status" => thread.status,
544 584
      "objective" => thread.objective,
585
      "repository" => thread.repository,
545 586
      "reasoning_effort" => thread.reasoning_effort,
546 587
      "permission_profile" => thread.permission_profile,
547 588
      "generation" => thread.generation,
lib/openagents_web/live/thread_index_live.ex modified +7

@@ -60,6 +60,13 @@ defmodule OpenAgentsWeb.ThreadIndexLive do

60 60
            >
61 61
              {title(thread.objective)}
62 62
            </.link>
63
            <p
64
              :if={thread.repository}
65
              id={"thread-repository-#{thread.id}"}
66
              class="mt-0.5 font-mono text-xs text-muted-foreground"
67
            >
68
              {thread.repository}
69
            </p>
63 70
          </:col>
64 71
          <:col :let={{_id, thread}} label="Status">
65 72
            <.badge variant={status_variant(thread.status)}>{thread.status}</.badge>
priv/repo/migrations/20260824203139_add_repository_to_threads.exs added +23

@@ -0,0 +1,23 @@

1
defmodule OpenAgents.Repo.Migrations.AddRepositoryToThreads do
2
  @moduledoc """
3
  Record the repository a thread concerns, when its opener names one.
4
5
  Nullable and bounded, with no foreign key: a thread may concern a repository
6
  the forge does not host, so the column records the opener's `owner/name`
7
  string rather than referencing the repositories table. `openagents coder
8
  --resume` filters its picker on this field instead of parsing the objective
9
  sentence the CLI itself composed (issue #210).
10
  """
11
12
  use Ecto.Migration
13
14
  def change do
15
    alter table(:threads) do
16
      add :repository, :text
17
    end
18
19
    create constraint(:threads, :threads_repository_bound_check,
20
             check: "repository IS NULL OR octet_length(repository) BETWEEN 1 AND 200"
21
           )
22
  end
23
end
test/openagents_web/controllers/thread_controller_test.exs modified +84

@@ -76,6 +76,59 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

76 76
      assert thread["permission_profile"] == "workspace_write"
77 77
    end
78 78
79
    test "records the repository the opener names and returns it", %{conn: conn} do
80
      thread =
81
        conn
82
        |> put_chat_api_token("thread-repository")
83
        |> post(~p"/api/v3/threads", %{
84
          "objective" => "openagents coder in OpenAgentsInc/openagents.com on main",
85
          "repository" => "OpenAgentsInc/openagents.com"
86
        })
87
        |> json_response(201)
88
        |> Map.fetch!("thread")
89
90
      assert thread["repository"] == "OpenAgentsInc/openagents.com"
91
      # No foreign key and no format rule: a thread may concern a repository
92
      # the forge does not host, so the recorded string is the opener's own.
93
      assert Repo.get!(OpenAgents.Threads.Thread, thread["id"]).repository ==
94
               "OpenAgentsInc/openagents.com"
95
    end
96
97
    test "a thread without a repository records none and reports null", %{conn: conn} do
98
      thread =
99
        conn
100
        |> put_chat_api_token("thread-no-repository")
101
        |> post(~p"/api/v3/threads", %{"objective" => "No repository named."})
102
        |> json_response(201)
103
        |> Map.fetch!("thread")
104
105
      assert Map.fetch!(thread, "repository") == nil
106
    end
107
108
    test "a blank repository is refused rather than recorded as noise", %{conn: conn} do
109
      body =
110
        conn
111
        |> put_chat_api_token("thread-blank-repository")
112
        |> post(~p"/api/v3/threads", %{"objective" => "Blank it.", "repository" => "   "})
113
        |> json_response(422)
114
115
      assert body["code"] == "validation_failed"
116
      assert Map.has_key?(body["errors"], "repository")
117
    end
118
119
    test "a repository over the bound is refused", %{conn: conn} do
120
      body =
121
        conn
122
        |> put_chat_api_token("thread-long-repository")
123
        |> post(~p"/api/v3/threads", %{
124
          "objective" => "Bound it.",
125
          "repository" => String.duplicate("a", 201)
126
        })
127
        |> json_response(422)
128
129
      assert Map.has_key?(body["errors"], "repository")
130
    end
131
79 132
    test "a caller may open a thread on another routed model", %{conn: conn} do
80 133
      body =
81 134
        conn

@@ -838,6 +891,37 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

838 891
      assert Enum.map(body["threads"], & &1["objective"]) == ["newer", "older"]
839 892
    end
840 893
894
    test "?repository= narrows the listing to that repository, exactly", %{conn: conn} do
895
      authenticated = put_chat_api_token(conn, "thread-repository-filter")
896
897
      for {objective, repository} <- [
898
            {"here", "OpenAgentsInc/openagents.com"},
899
            {"elsewhere", "OpenAgentsInc/openagents"},
900
            {"nowhere", nil}
901
          ] do
902
        authenticated
903
        |> post(
904
          ~p"/api/v3/threads",
905
          %{"objective" => objective}
906
          |> Map.merge(if repository, do: %{"repository" => repository}, else: %{})
907
        )
908
        |> json_response(201)
909
      end
910
911
      body =
912
        authenticated
913
        |> get(~p"/api/v3/threads?repository=OpenAgentsInc/openagents.com")
914
        |> json_response(200)
915
916
      # An exact match on the recorded field, so a resume picker filters
917
      # structurally instead of parsing the objective sentence back.
918
      assert Enum.map(body["threads"], & &1["objective"]) == ["here"]
919
      assert Enum.map(body["threads"], & &1["repository"]) == ["OpenAgentsInc/openagents.com"]
920
921
      unfiltered = authenticated |> get(~p"/api/v3/threads") |> json_response(200)
922
      assert length(unfiltered["threads"]) == 3
923
    end
924
841 925
    test "does not list another account's threads", %{conn: conn} do
842 926
      put_chat_api_token(conn, "thread-mine")
843 927
      |> post(~p"/api/v3/threads", %{"objective" => "mine"})
test/openagents_web/live/thread_index_live_test.exs modified +16

@@ -32,6 +32,22 @@ defmodule OpenAgentsWeb.ThreadIndexLiveTest do

32 32
    assert view |> element("#threads-#{thread.id}") |> render() =~ ">2<"
33 33
  end
34 34
35
  test "a row names its repository when the thread records one", %{conn: conn} do
36
    owner = github_user("thread-index-repository")
37
38
    {:ok, named} =
39
      Threads.open(owner, "Coder session", repository: "OpenAgentsInc/openagents.com")
40
41
    {:ok, bare} = Threads.open(owner, "No repository")
42
43
    {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads")
44
45
    assert view |> element("#thread-repository-#{named.id}") |> render() =~
46
             "OpenAgentsInc/openagents.com"
47
48
    refute has_element?(view, "#thread-repository-#{bare.id}")
49
  end
50
35 51
  test "an account with no threads sees the empty state", %{conn: conn} do
36 52
    owner = github_user("thread-index-empty")
37 53

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