Allow projects to track cross-repository issues

dd6058c0b2b7 · AtlantisPleb · 2026-08-22T10:46:49-05:00 · parent e358675bf399

Allow projects to track cross-repository issues

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 94 · 2026-08-22T15:47:02.909382Z

Changed files

  • modified docs/github-api-issues-projects-assessment.md
  • modified docs/openagents-cli/api.md
  • modified lib/openagents/project_items.ex
  • modified lib/openagents/project_items/project_item.ex
  • modified lib/openagents/projects.ex
  • modified lib/openagents_web/controllers/project_controller.ex
  • modified lib/openagents_web/controllers/project_json.ex
  • modified lib/openagents_web/live/project_show_live.ex
  • modified lib/openagents_web/live/project_workspace_live.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260822153929_allow_cross_repository_project_items.exs
  • modified test/openagents/repositories_test.exs
  • modified test/openagents_web/controllers/project_controller_test.exs

Diff

13 files changed, +243 -29

docs/github-api-issues-projects-assessment.md modified +8 -1

@@ -71,12 +71,19 @@ These are current measured behaviors:

71 71
  writable membership for the PAT principal.
72 72
- Resource reads and mutations include repository ownership in their database
73 73
  query. Composite foreign keys reject cross-repository comments, label and
74
  assignee links, milestones, and project items.
74
  assignee links, and milestones. A project item stores separate project and
75
  source-issue repository identities, so one board can include a readable
76
  issue from another repository without weakening project write authority.
75 77
- Issue and milestone numbers are repository-local. Project numbers are also
76 78
  repository-local for the repository-shaped LiveView surface.
77 79
- Project list, show, item, update-item, and field actions resolve the repository
78 80
  from the route. Public repositories allow anonymous reads. Private reads and
79 81
  every write require membership in that repository.
82
- Item creation accepts either the legacy repository-local `issue_number` or
83
  an `issue` object with `owner`, `repo`, and `number`. Cross-repository adds
84
  require write access to the project repository and read access to the source
85
  repository. Item lists omit source issues that the current viewer cannot
86
  read.
80 87
- Assignee reads return active repository members with writable roles, and
81 88
  issue assignment accepts only those members.
82 89
docs/openagents-cli/api.md modified +13

@@ -160,6 +160,19 @@ printf '%s' '{"issue_number":11,"values":{"Status":"To Do"}}' | \

160 160
  repos/OWNER/REPOSITORY/projectsV2/PROJECT_NUMBER/items
161 161
```
162 162
163
To add an issue from another repository, identify its repository explicitly.
164
You must be able to write the project and read the source issue repository:
165
166
```sh
167
printf '%s' '{"issue":{"owner":"SOURCE_OWNER","repo":"SOURCE_REPOSITORY","number":37},"values":{"Status":"To Do"}}' | \
168
  openagents api -X POST --input - \
169
  repos/PROJECT_OWNER/PROJECT_REPOSITORY/projectsV2/PROJECT_NUMBER/items
170
```
171
172
Item responses include the source issue's `owner`, `repo`, `number`, `url`,
173
and `html_url`. The legacy `issue_number` form continues to select an issue
174
from the project repository.
175
163 176
Update the stored values for an item:
164 177
165 178
```sh
lib/openagents/project_items.ex modified +5 -1

@@ -58,7 +58,11 @@ defmodule OpenAgents.ProjectItems do

58 58
    repository_id = repository_id_for(repository, Map.get(attrs, "project_id"))
59 59
60 60
    %ProjectItem{}
61
    |> ProjectItem.changeset(Map.put(attrs, "repository_id", repository_id))
61
    |> ProjectItem.changeset(
62
      attrs
63
      |> Map.put("repository_id", repository_id)
64
      |> Map.put_new("issue_repository_id", repository.id)
65
    )
62 66
    |> Repo.insert()
63 67
  end
64 68
lib/openagents/project_items/project_item.ex modified +6 -4

@@ -7,8 +7,9 @@ defmodule OpenAgents.ProjectItems.ProjectItem do

7 7
  schema "project_items" do
8 8
    field :values, :map
9 9
    field :project_id, :id
10
    field :issue_id, :id
11 10
    belongs_to :repository, Repository, type: :binary_id
11
    belongs_to :issue_repository, Repository, type: :binary_id
12
    belongs_to :issue, OpenAgents.Issues.Issue
12 13
13 14
    timestamps(type: :utc_datetime)
14 15
  end

@@ -16,12 +17,13 @@ defmodule OpenAgents.ProjectItems.ProjectItem do

16 17
  @doc false
17 18
  def changeset(project_item, attrs) do
18 19
    project_item
19
    |> cast(attrs, [:values, :project_id, :issue_id, :repository_id])
20
    |> validate_required([:project_id, :issue_id, :repository_id])
20
    |> cast(attrs, [:values, :project_id, :issue_id, :repository_id, :issue_repository_id])
21
    |> validate_required([:project_id, :issue_id, :repository_id, :issue_repository_id])
22
    |> unique_constraint([:project_id, :issue_id])
21 23
    |> foreign_key_constraint(:repository_id)
22 24
    |> foreign_key_constraint(:project_id)
23 25
    |> foreign_key_constraint(:issue_id)
24 26
    |> foreign_key_constraint(:project_id, name: :project_items_project_repository_fkey)
25
    |> foreign_key_constraint(:issue_id, name: :project_items_issue_repository_fkey)
27
    |> foreign_key_constraint(:issue_id, name: :project_items_issue_source_repository_fkey)
26 28
  end
27 29
end
lib/openagents/projects.ex modified +44 -5

@@ -216,18 +216,48 @@ defmodule OpenAgents.Projects do

216 216
  end
217 217
218 218
  def list_project_items(%Project{id: project_id, repository_id: repository_id}) do
219
    ProjectItem
220
    |> where(project_id: ^project_id, repository_id: ^repository_id)
219
    project_items_query(project_id, repository_id)
220
    |> order_by(asc: :id)
221
    |> preload(issue: :repository)
222
    |> Repo.all()
223
  end
224
225
  def list_visible_project_items(
226
        %Project{id: project_id, repository_id: repository_id},
227
        user
228
      ) do
229
    readable =
230
      from(repository in Repositories.readable_by(Repository, user), select: repository.id)
231
232
    project_items_query(project_id, repository_id)
233
    |> where([item], item.issue_repository_id in subquery(readable))
221 234
    |> order_by(asc: :id)
235
    |> preload(issue: :repository)
222 236
    |> Repo.all()
223 237
  end
224 238
225 239
  def get_project_item!(%Project{id: project_id, repository_id: repository_id}, id) do
226
    Repo.get_by!(ProjectItem,
240
    ProjectItem
241
    |> Repo.get_by!(
227 242
      id: id,
228 243
      project_id: project_id,
229 244
      repository_id: repository_id
230 245
    )
246
    |> Repo.preload(issue: :repository)
247
  end
248
249
  def get_visible_project_item!(
250
        %Project{id: project_id, repository_id: repository_id},
251
        id,
252
        user
253
      ) do
254
    readable =
255
      from(repository in Repositories.readable_by(Repository, user), select: repository.id)
256
257
    project_items_query(project_id, repository_id)
258
    |> where([item], item.id == ^id and item.issue_repository_id in subquery(readable))
259
    |> preload(issue: :repository)
260
    |> Repo.one!()
231 261
  end
232 262
233 263
  def create_project_item(attrs, project, actor \\ nil)

@@ -236,6 +266,7 @@ defmodule OpenAgents.Projects do

236 266
      when is_nil(actor) or is_struct(actor, User) do
237 267
    attrs = to_string_map(attrs)
238 268
    values = Map.get(attrs, "values", %{})
269
    issue_repository_id = Map.get(attrs, "issue_repository_id", project.repository_id)
239 270
240 271
    result =
241 272
      case Map.get(attrs, "issue_number") do

@@ -244,6 +275,7 @@ defmodule OpenAgents.Projects do

244 275
          |> ProjectItem.changeset(%{
245 276
            "project_id" => project.id,
246 277
            "repository_id" => project.repository_id,
278
            "issue_repository_id" => issue_repository_id,
247 279
            "values" => values
248 280
          })
249 281
          |> Ecto.Changeset.apply_action(:insert)

@@ -251,7 +283,7 @@ defmodule OpenAgents.Projects do

251 283
        issue_number ->
252 284
          issue =
253 285
            Repo.get_by!(Issue,
254
              repository_id: project.repository_id,
286
              repository_id: issue_repository_id,
255 287
              number: issue_number
256 288
            )
257 289

@@ -260,6 +292,7 @@ defmodule OpenAgents.Projects do

260 292
            "project_id" => project.id,
261 293
            "issue_id" => issue.id,
262 294
            "repository_id" => project.repository_id,
295
            "issue_repository_id" => issue_repository_id,
263 296
            "values" => values
264 297
          })
265 298
          |> Repo.insert()

@@ -272,7 +305,7 @@ defmodule OpenAgents.Projects do

272 305
          "has_issue" => match?(%ProjectItem{issue_id: id} when id != nil, item)
273 306
        })
274 307
275
        {:ok, item}
308
        {:ok, Repo.preload(item, issue: :repository)}
276 309
277 310
      result ->
278 311
        result

@@ -312,6 +345,12 @@ defmodule OpenAgents.Projects do

312 345
    end
313 346
  end
314 347
348
  defp project_items_query(project_id, repository_id) do
349
    from(item in ProjectItem,
350
      where: item.project_id == ^project_id and item.repository_id == ^repository_id
351
    )
352
  end
353
315 354
  defp number_conflict?(changeset) do
316 355
    Enum.any?(changeset.errors, fn {_field, {_message, options}} ->
317 356
      options[:constraint_name] == "projects_repository_id_number_index"
lib/openagents_web/controllers/project_controller.ex modified +34 -5

@@ -50,7 +50,10 @@ defmodule OpenAgentsWeb.ProjectController do

50 50
      }) do
51 51
    repository = visible_repository!(conn, owner, repo)
52 52
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
53
    render(conn, :items, items: Projects.list_project_items(project))
53
54
    render(conn, :items,
55
      items: Projects.list_visible_project_items(project, conn.assigns[:current_user])
56
    )
54 57
  rescue
55 58
    Ecto.NoResultsError -> not_found(conn)
56 59
  end

@@ -66,12 +69,15 @@ defmodule OpenAgentsWeb.ProjectController do

66 69
    repository = writable_repository!(conn, owner, repo)
67 70
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
68 71
69
    case cast_number(params["issue_number"]) do
72
    case issue_reference(conn, repository, params) do
70 73
      :error ->
71 74
        unprocessable(conn, %{issue_number: ["is invalid"]})
72 75
73
      {:ok, issue_number} ->
74
        params = Map.put(params, "issue_number", issue_number)
76
      {:ok, source_repository, issue_number} ->
77
        params =
78
          params
79
          |> Map.put("issue_number", issue_number)
80
          |> Map.put("issue_repository_id", source_repository.id)
75 81
76 82
        case Projects.create_project_item(params, project, conn.assigns.current_user) do
77 83
          {:ok, item} ->

@@ -100,7 +106,13 @@ defmodule OpenAgentsWeb.ProjectController do

100 106
      ) do
101 107
    repository = writable_repository!(conn, owner, repo)
102 108
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
103
    item = Projects.get_project_item!(project, parse_id!(item_id))
109
110
    item =
111
      Projects.get_visible_project_item!(
112
        project,
113
        parse_id!(item_id),
114
        conn.assigns.current_user
115
      )
104 116
105 117
    if is_map(Map.get(params, "values", %{})) do
106 118
      case Projects.update_project_item(item, params) do

@@ -188,6 +200,23 @@ defmodule OpenAgentsWeb.ProjectController do

188 200
189 201
  defp cast_number(_other), do: :error
190 202
203
  defp issue_reference(conn, _project_repository, %{"issue" => issue}) when is_map(issue) do
204
    with owner when is_binary(owner) <- issue["owner"],
205
         repo when is_binary(repo) <- issue["repo"],
206
         {:ok, number} <- cast_number(issue["number"]) do
207
      {:ok, visible_repository!(conn, owner, repo), number}
208
    else
209
      _invalid -> :error
210
    end
211
  end
212
213
  defp issue_reference(_conn, project_repository, params) do
214
    case cast_number(params["issue_number"]) do
215
      {:ok, number} -> {:ok, project_repository, number}
216
      :error -> :error
217
    end
218
  end
219
191 220
  defp unprocessable(conn, errors) do
192 221
    conn
193 222
    |> put_status(:unprocessable_entity)
lib/openagents_web/controllers/project_json.ex modified +12

@@ -34,9 +34,21 @@ defmodule OpenAgentsWeb.ProjectJSON do

34 34
  end
35 35
36 36
  defp item_json(item) do
37
    issue = item.issue
38
    repository = issue.repository
39
    base_url = OpenAgentsWeb.Endpoint.url()
40
37 41
    %{
38 42
      id: item.id,
39 43
      issue_id: item.issue_id,
44
      issue: %{
45
        owner: repository.owner,
46
        repo: repository.name,
47
        number: issue.number,
48
        url:
49
          "#{base_url}/api/v3/repos/#{repository.owner}/#{repository.name}/issues/#{issue.number}",
50
        html_url: "#{base_url}/#{repository.owner}/#{repository.name}/issues/#{issue.number}"
51
      },
40 52
      values: item.values
41 53
    }
42 54
  end
lib/openagents_web/live/project_show_live.ex modified +9 -7

@@ -14,7 +14,7 @@ defmodule OpenAgentsWeb.ProjectShowLive do

14 14
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
15 15
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
16 16
    project = Projects.get_project_by_number!(repository, String.to_integer(number))
17
    items = project_items(repository, project)
17
    items = project_items(project, socket.assigns.current_user)
18 18
    issue_options = issue_options(repository)
19 19
20 20
    {:ok,

@@ -47,7 +47,7 @@ defmodule OpenAgentsWeb.ProjectShowLive do

47 47
      {:ok, _item} ->
48 48
        {:noreply,
49 49
         socket
50
         |> assign(:items, project_items(socket.assigns.repository, project))
50
         |> assign(:items, project_items(project, socket.assigns.current_user))
51 51
         |> assign(:form, to_form(ProjectItem.changeset(%ProjectItem{}, %{}), as: "item"))
52 52
         |> put_flash(:info, "Issue added to project")}
53 53

@@ -56,10 +56,10 @@ defmodule OpenAgentsWeb.ProjectShowLive do

56 56
    end
57 57
  end
58 58
59
  defp project_items(repository, project) do
60
    Projects.list_project_items(project)
59
  defp project_items(project, user) do
60
    Projects.list_visible_project_items(project, user)
61 61
    |> Enum.map(fn item ->
62
      issue = Issues.get_issue!(repository, item.issue_id)
62
      issue = item.issue
63 63
      status = get_in(item.values, ["Status"]) || "To Do"
64 64
      Map.merge(item, %{issue: issue, status: status})
65 65
    end)

@@ -127,14 +127,16 @@ defmodule OpenAgentsWeb.ProjectShowLive do

127 127
              <%= for item <- @items, item.status == status do %>
128 128
                <article class="card !m-0 !p-3">
129 129
                  <.link
130
                    navigate={~p"/#{@owner}/#{@repo}/issues/#{item.issue.number}"}
130
                    navigate={
131
                      ~p"/#{item.issue.repository.owner}/#{item.issue.repository.name}/issues/#{item.issue.number}"
132
                    }
131 133
                    class="btn px-0 text-sm font-semibold"
132 134
                    data-variant="link"
133 135
                  >
134 136
                    {item.issue.title}
135 137
                  </.link>
136 138
                  <span class="text-xs text-muted-foreground block">
137
                    #{item.issue.number}
139
                    {item.issue.repository.owner}/{item.issue.repository.name}#{item.issue.number}
138 140
                  </span>
139 141
                  <div class="flex flex-wrap gap-1 mt-2">
140 142
                    <%= for label <- item.issue.labels || [] do %>
lib/openagents_web/live/project_workspace_live.ex modified +5 -3

@@ -70,11 +70,13 @@ defmodule OpenAgentsWeb.ProjectWorkspaceLive do

70 70
71 71
  # Zero debounce means tests: refresh synchronously on the change message so
72 72
  # assertions need no waiting.
73
  defp schedule_refresh(socket) when @refresh_debounce_ms == 0 do
74
    load(socket)
73
  defp schedule_refresh(socket) do
74
    if @refresh_debounce_ms == 0,
75
      do: load(socket),
76
      else: schedule_debounced_refresh(socket)
75 77
  end
76 78
77
  defp schedule_refresh(socket) do
79
  defp schedule_debounced_refresh(socket) do
78 80
    case socket.assigns.refresh_timer_ref do
79 81
      nil ->
80 82
        assign(
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -228,7 +228,8 @@

228 228
    20260821082652,
229 229
    20260822082657,
230 230
    20260822120326,
231
    20260822132511
231
    20260822132511,
232
    20260822153929
232 233
  ],
233 234
  "required_tables": [
234 235
    "users",
priv/repo/migrations/20260822153929_allow_cross_repository_project_items.exs added +45

@@ -0,0 +1,45 @@

1
defmodule OpenAgents.Repo.Migrations.AllowCrossRepositoryProjectItems do
2
  use Ecto.Migration
3
4
  def up do
5
    alter table(:project_items) do
6
      add :issue_repository_id,
7
          references(:repositories, type: :binary_id, on_delete: :delete_all)
8
    end
9
10
    execute("UPDATE project_items SET issue_repository_id = repository_id")
11
    execute("ALTER TABLE project_items ALTER COLUMN issue_repository_id SET NOT NULL")
12
    execute("ALTER TABLE project_items DROP CONSTRAINT project_items_issue_repository_fkey")
13
    create index(:project_items, [:issue_repository_id])
14
    create unique_index(:project_items, [:project_id, :issue_id])
15
16
    execute("""
17
    ALTER TABLE project_items
18
      ADD CONSTRAINT project_items_issue_source_repository_fkey
19
      FOREIGN KEY (issue_id, issue_repository_id)
20
      REFERENCES issues (id, repository_id)
21
      ON DELETE RESTRICT
22
    """)
23
  end
24
25
  def down do
26
    execute("DELETE FROM project_items WHERE issue_repository_id <> repository_id")
27
28
    execute(
29
      "ALTER TABLE project_items DROP CONSTRAINT project_items_issue_source_repository_fkey"
30
    )
31
32
    drop unique_index(:project_items, [:project_id, :issue_id])
33
    drop index(:project_items, [:issue_repository_id])
34
35
    execute("""
36
    ALTER TABLE project_items
37
      ADD CONSTRAINT project_items_issue_repository_fkey
38
      FOREIGN KEY (issue_id, repository_id)
39
      REFERENCES issues (id, repository_id)
40
      ON DELETE RESTRICT
41
    """)
42
43
    alter table(:project_items), do: remove(:issue_repository_id)
44
  end
45
end
test/openagents/repositories_test.exs modified +3 -2

@@ -122,7 +122,7 @@ defmodule OpenAgents.RepositoriesTest do

122 122
    end
123 123
  end
124 124
125
  test "project-item and comment constraints reject cross-repository parents", %{
125
  test "project-item and comment constraints reject mismatched repository identities", %{
126 126
    initial: initial,
127 127
    second: second,
128 128
    user: user

@@ -136,7 +136,8 @@ defmodule OpenAgents.RepositoriesTest do

136 136
             |> ProjectItem.changeset(%{
137 137
               project_id: project.id,
138 138
               issue_id: second_issue.id,
139
               repository_id: initial.id
139
               repository_id: initial.id,
140
               issue_repository_id: initial.id
140 141
             })
141 142
             |> Repo.insert()
142 143
test/openagents_web/controllers/project_controller_test.exs modified +57

@@ -175,6 +175,63 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

175 175
      assert issue_id == issue.id
176 176
    end
177 177
178
    test "POST .../items adds a readable issue from another repository", %{
179
      conn: conn,
180
      project: project
181
    } do
182
      source =
183
        repository_fixture(%{owner: "SourceOrg", name: "source-api", visibility: "public"})
184
185
      {:ok, issue} = Issues.create_issue(source, %{title: "Cross-repository work"})
186
187
      conn =
188
        post(
189
          conn,
190
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
191
          %{issue: %{owner: "SourceOrg", repo: "source-api", number: issue.number}}
192
        )
193
194
      assert %{
195
               "items" => [
196
                 %{
197
                   "issue_id" => issue_id,
198
                   "issue" => %{
199
                     "owner" => "SourceOrg",
200
                     "repo" => "source-api",
201
                     "number" => number,
202
                     "html_url" => html_url
203
                   }
204
                 }
205
               ]
206
             } = json_response(conn, 201)
207
208
      assert issue_id == issue.id
209
      assert number == issue.number
210
      assert html_url =~ "/SourceOrg/source-api/issues/#{issue.number}"
211
      assert [%{issue_repository_id: source_id}] = Projects.list_project_items(project)
212
      assert source_id == source.id
213
    end
214
215
    test "POST .../items hides an unreadable source repository", %{
216
      conn: conn,
217
      project: project
218
    } do
219
      source =
220
        repository_fixture(%{owner: "SecretOrg", name: "secret-api", visibility: "private"})
221
222
      {:ok, issue} = Issues.create_issue(source, %{title: "Private work"})
223
224
      conn =
225
        post(
226
          conn,
227
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items",
228
          %{issue: %{owner: "SecretOrg", repo: "secret-api", number: issue.number}}
229
        )
230
231
      assert json_response(conn, 404) == %{"message" => "Not Found"}
232
      assert Projects.list_project_items(project) == []
233
    end
234
178 235
    test "POST .../projectsV2/:project_number/items stores field values", %{
179 236
      conn: conn,
180 237
      project: project,

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