Model issue prerequisites so agents see unblocked work

e11928b08f7c · Devin AI · · parent 40415a01718d

Model issue prerequisites so agents see unblocked work

An issue can now wait on other issues in the same repository. A dependency
edge table records the prerequisite, and blocked is derived from the
prerequisites' own state, so closing the last open one unblocks the issue
with no second write.

Issue responses carry blocked, blocked_by, and blocks under the openagents
namespace, the issue list filters on blocked=true|false, and GET /api/v3
describes the extension. An unknown number, a self reference, and an edge
that would close a cycle each fail the whole batch.

Closes #100

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>
Closes
#100

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 .agents/skills/openagents-work-management/SKILL.md
  • modified lib/openagents/issues.ex
  • added lib/openagents/issues/issue_dependency.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/api_extension_controller.ex
  • modified lib/openagents_web/controllers/issue_controller.ex
  • added lib/openagents_web/controllers/issue_dependency_controller.ex
  • modified lib/openagents_web/controllers/issue_json.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/docs/rest-api.md
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823040635_create_issue_dependencies.exs
  • added test/openagents/issue_dependencies_test.exs
  • added test/openagents_web/controllers/api_extension_controller_test.exs
  • modified test/openagents_web/controllers/issue_controller_test.exs
  • added test/openagents_web/controllers/issue_dependency_controller_test.exs

Diff

17 files changed, +1150 -8

.agents/skills/openagents-work-management/SKILL.md modified +30

@@ -82,6 +82,36 @@ openagents api -X POST -f body="The fix is available in staging." \

82 82
  repos/OWNER/REPO/issues/41/comments
83 83
```
84 84
85
## Prerequisites
86
87
An issue can wait on other issues in the same repository.
88
89
| Operation | Method | Path |
90
|---|---|---|
91
| Read the graph | `GET` | `repos/OWNER/REPO/issues/NUMBER/dependencies` |
92
| Record prerequisites | `POST` | `repos/OWNER/REPO/issues/NUMBER/dependencies` |
93
| Remove one | `DELETE` | `repos/OWNER/REPO/issues/NUMBER/dependencies/BLOCKER_NUMBER` |
94
95
Record that issue 42 waits on issues 9 and 12:
96
97
```sh
98
printf '%s' '{"blocked_by":[9,12]}' | \
99
  openagents api -X POST --input - \
100
  repos/OWNER/REPO/issues/42/dependencies
101
```
102
103
Pick up work that nothing blocks:
104
105
```sh
106
openagents api "repos/OWNER/REPO/issues?state=open&blocked=false"
107
```
108
109
Every issue response also carries `openagents.blocked`, `openagents.blocked_by`,
110
and `openagents.blocks`. `blocked` is derived from the prerequisites' state, so
111
closing the last open prerequisite unblocks the issue with no second write. An
112
unknown number, a self reference, and an edge that would close a cycle each
113
return `422`, and none of the batch is recorded.
114
85 115
## Projects
86 116
87 117
There are no named `project` commands. Construct every Projects V2 route manually under `repos/OWNER/REPO/projectsV2`.
lib/openagents/issues.ex modified +235 -3

@@ -5,7 +5,7 @@ defmodule OpenAgents.Issues do

5 5
6 6
  alias OpenAgents.Accounts.User
7 7
  alias OpenAgents.Analytics
8
  alias OpenAgents.Issues.{Comment, Issue}
8
  alias OpenAgents.Issues.{Comment, Issue, IssueDependency}
9 9
  alias OpenAgents.Labels
10 10
  alias OpenAgents.Labels.Label
11 11
  alias OpenAgents.Milestones

@@ -31,8 +31,8 @@ defmodule OpenAgents.Issues do

31 31
  One page of the filtered issue list, with the unpaginated total.
32 32
33 33
  Supported options: `:state`, `:label`, `:assignee`, `:milestone`, `:q`,
34
  and `:page`. Filters compose; counts and pages always agree because they
35
  read the same query.
34
  `:blocked`, and `:page`. Filters compose; counts and pages always agree
35
  because they read the same query.
36 36
  """
37 37
  def list_issues_page(%Repository{} = repository, opts) when is_list(opts) do
38 38
    page = max(parse_page(opts[:page]), 1)

@@ -131,6 +131,7 @@ defmodule OpenAgents.Issues do

131 131
    |> maybe_filter_assignee(Keyword.get(opts, :assignee))
132 132
    |> maybe_filter_milestone(Keyword.get(opts, :milestone))
133 133
    |> maybe_filter_search(Keyword.get(opts, :q))
134
    |> maybe_filter_blocked(Keyword.get(opts, :blocked))
134 135
  end
135 136
136 137
  def get_issue!(%Repository{id: repository_id}, id) do

@@ -309,6 +310,217 @@ defmodule OpenAgents.Issues do

309 310
    update_issue(issue, %{"assignees" => assignees})
310 311
  end
311 312
313
  @doc """
314
  The prerequisite edges touching `issues`, keyed by issue id.
315
316
  One query serves a whole page, so rendering a list never walks the graph once
317
  per row. Each entry carries what the issue waits on, what waits on it, and
318
  whether any prerequisite is still open.
319
  """
320
  def dependency_graph(issues) when is_list(issues) do
321
    ids = Enum.map(issues, & &1.id)
322
    base = Map.new(ids, &{&1, %{blocked_by: [], blocks: []}})
323
324
    from(dependency in IssueDependency,
325
      join: blocked in Issue,
326
      on: blocked.id == dependency.issue_id,
327
      join: blocker in Issue,
328
      on: blocker.id == dependency.blocked_by_issue_id,
329
      where: dependency.issue_id in ^ids or dependency.blocked_by_issue_id in ^ids,
330
      select: %{
331
        blocked_id: blocked.id,
332
        blocker_id: blocker.id,
333
        blocked_summary: %{number: blocked.number, title: blocked.title, state: blocked.state},
334
        blocker_summary: %{number: blocker.number, title: blocker.title, state: blocker.state}
335
      }
336
    )
337
    |> Repo.all()
338
    |> Enum.reduce(base, fn edge, graph ->
339
      graph
340
      |> collect_edge(edge.blocked_id, :blocked_by, edge.blocker_summary)
341
      |> collect_edge(edge.blocker_id, :blocks, edge.blocked_summary)
342
    end)
343
    |> Map.new(fn {id, entry} -> {id, derive_blocked(entry)} end)
344
  end
345
346
  @doc "The prerequisite edges of one issue, in the shape `dependency_graph/1` returns."
347
  def dependencies(%Issue{} = issue), do: [issue] |> dependency_graph() |> Map.fetch!(issue.id)
348
349
  defp collect_edge(graph, issue_id, key, summary) do
350
    case Map.fetch(graph, issue_id) do
351
      {:ok, entry} -> Map.put(graph, issue_id, Map.update!(entry, key, &[summary | &1]))
352
      :error -> graph
353
    end
354
  end
355
356
  # An issue is blocked while a prerequisite is still open, so the flag is read
357
  # from the prerequisites already loaded rather than stored on the issue.
358
  defp derive_blocked(%{blocked_by: blocked_by, blocks: blocks}) do
359
    blocked_by = Enum.sort_by(blocked_by, & &1.number)
360
361
    %{
362
      blocked_by: blocked_by,
363
      blocks: Enum.sort_by(blocks, & &1.number),
364
      blocked: Enum.any?(blocked_by, &(&1.state == "open"))
365
    }
366
  end
367
368
  @doc """
369
  Records the issues numbered `numbers` as prerequisites of `issue`.
370
371
  All of them or none: the numbers are validated and inserted in one
372
  transaction, so a request naming one unknown issue records nothing. Adding a
373
  prerequisite that is already recorded succeeds without a second row.
374
375
  Returns `:ok`, or `{:error, reason}` where reason is
376
  `{:invalid_number, value}`, `{:self_reference, number}`,
377
  `{:missing_issue, number}`, or `{:cycle, numbers}`. The cycle numbers name
378
  the path the edge would close, starting and ending at `issue`.
379
  """
380
  def add_dependencies(%Issue{} = issue, numbers, actor \\ nil)
381
      when is_list(numbers) and (is_nil(actor) or is_struct(actor, User)) do
382
    Repo.transaction(fn ->
383
      Enum.each(numbers, fn number ->
384
        case add_dependency(issue, number, actor) do
385
          :ok -> :ok
386
          {:error, reason} -> Repo.rollback(reason)
387
        end
388
      end)
389
    end)
390
    |> case do
391
      {:ok, _result} ->
392
        Repositories.broadcast_issues(issue.repository_id)
393
        :ok
394
395
      {:error, reason} ->
396
        {:error, reason}
397
    end
398
  end
399
400
  defp add_dependency(issue, number, actor) do
401
    with {:ok, number} <- normalize_issue_number(number),
402
         :ok <- reject_self_reference(issue, number),
403
         {:ok, blocker} <- fetch_blocker(issue, number),
404
         :ok <- reject_cycle(issue, blocker) do
405
      insert_dependency(issue, blocker, actor)
406
    end
407
  end
408
409
  defp normalize_issue_number(number) when is_integer(number) and number > 0, do: {:ok, number}
410
411
  defp normalize_issue_number(number) when is_binary(number) do
412
    case Integer.parse(number) do
413
      {parsed, ""} -> normalize_issue_number(parsed)
414
      _other -> {:error, {:invalid_number, number}}
415
    end
416
  end
417
418
  defp normalize_issue_number(number), do: {:error, {:invalid_number, number}}
419
420
  defp reject_self_reference(%Issue{number: number}, number),
421
    do: {:error, {:self_reference, number}}
422
423
  defp reject_self_reference(_issue, _number), do: :ok
424
425
  defp fetch_blocker(issue, number) do
426
    case Repo.get_by(Issue, repository_id: issue.repository_id, number: number) do
427
      %Issue{} = blocker -> {:ok, blocker}
428
      nil -> {:error, {:missing_issue, number}}
429
    end
430
  end
431
432
  # The new edge closes a cycle exactly when the prerequisite already waits on
433
  # the issue it would block. A backlog whose graph can contain a cycle cannot
434
  # be scheduled, so the walk runs before the insert rather than at read time.
435
  defp reject_cycle(issue, blocker) do
436
    case dependency_path(blocker.id, issue.id) do
437
      nil -> :ok
438
      path -> {:error, {:cycle, [issue.number | issue_numbers(path)]}}
439
    end
440
  end
441
442
  defp dependency_path(from_id, target_id),
443
    do: walk_dependencies([[from_id]], MapSet.new([from_id]), target_id)
444
445
  defp walk_dependencies([], _visited, _target_id), do: nil
446
447
  defp walk_dependencies([path | queue], visited, target_id) do
448
    next_ids = blocker_ids(hd(path))
449
450
    if target_id in next_ids do
451
      Enum.reverse([target_id | path])
452
    else
453
      unvisited = Enum.reject(next_ids, &MapSet.member?(visited, &1))
454
455
      walk_dependencies(
456
        queue ++ Enum.map(unvisited, &[&1 | path]),
457
        Enum.into(unvisited, visited),
458
        target_id
459
      )
460
    end
461
  end
462
463
  defp blocker_ids(issue_id) do
464
    Repo.all(
465
      from dependency in IssueDependency,
466
        where: dependency.issue_id == ^issue_id,
467
        select: dependency.blocked_by_issue_id
468
    )
469
  end
470
471
  defp issue_numbers(ids) do
472
    numbers =
473
      from(issue in Issue, where: issue.id in ^ids, select: {issue.id, issue.number})
474
      |> Repo.all()
475
      |> Map.new()
476
477
    Enum.flat_map(ids, fn id ->
478
      case Map.fetch(numbers, id) do
479
        {:ok, number} -> [number]
480
        :error -> []
481
      end
482
    end)
483
  end
484
485
  defp insert_dependency(issue, blocker, actor) do
486
    %IssueDependency{}
487
    |> IssueDependency.changeset(%{
488
      "repository_id" => issue.repository_id,
489
      "issue_id" => issue.id,
490
      "blocked_by_issue_id" => blocker.id,
491
      "created_by_user_id" => actor && actor.id
492
    })
493
    |> Repo.insert(on_conflict: :nothing, conflict_target: [:issue_id, :blocked_by_issue_id])
494
    |> case do
495
      {:ok, _dependency} -> :ok
496
      {:error, changeset} -> {:error, {:invalid_dependency, changeset}}
497
    end
498
  end
499
500
  @doc """
501
  Removes the prerequisite numbered `number` from `issue`.
502
503
  Returns `{:error, {:missing_dependency, number}}` when the edge is not
504
  recorded, because a silent no-op hides the mismatch from the caller.
505
  """
506
  def remove_dependency(%Issue{} = issue, number) do
507
    with {:ok, number} <- normalize_issue_number(number),
508
         {:ok, blocker} <- fetch_blocker(issue, number) do
509
      from(dependency in IssueDependency,
510
        where: dependency.issue_id == ^issue.id and dependency.blocked_by_issue_id == ^blocker.id
511
      )
512
      |> Repo.delete_all()
513
      |> case do
514
        {0, _returned} ->
515
          {:error, {:missing_dependency, number}}
516
517
        {_count, _returned} ->
518
          Repositories.broadcast_issues(issue.repository_id)
519
          :ok
520
      end
521
    end
522
  end
523
312 524
  def set_milestone(%Issue{} = issue, nil) do
313 525
    update_issue(issue, %{"milestone" => nil})
314 526
  end

@@ -705,6 +917,26 @@ defmodule OpenAgents.Issues do

705 917
    )
706 918
  end
707 919
920
  # Blocked is a property of the prerequisite's current state, so the filter
921
  # asks the graph rather than a column: an issue is blocked while any issue it
922
  # is blocked by is still open. Closing the last prerequisite moves the issue
923
  # into `blocked=false` with no second write.
924
  defp maybe_filter_blocked(query, nil), do: query
925
926
  defp maybe_filter_blocked(query, true),
927
    do: where(query, [], exists(open_blocker_query()))
928
929
  defp maybe_filter_blocked(query, false),
930
    do: where(query, [], not exists(open_blocker_query()))
931
932
  defp open_blocker_query do
933
    from dependency in IssueDependency,
934
      join: blocker in Issue,
935
      on: blocker.id == dependency.blocked_by_issue_id,
936
      where: dependency.issue_id == parent_as(:issue).id and blocker.state == "open",
937
      select: 1
938
  end
939
708 940
  defp maybe_filter_search(query, nil), do: query
709 941
  defp maybe_filter_search(query, ""), do: query
710 942
lib/openagents/issues/issue_dependency.ex added +40

@@ -0,0 +1,40 @@

1
defmodule OpenAgents.Issues.IssueDependency do
2
  @moduledoc """
3
  One prerequisite edge: `issue` cannot start until `blocked_by_issue` closes.
4
5
  The edge is the whole record. Whether an issue is blocked is never stored,
6
  because a stored flag goes stale the moment a prerequisite closes. Read it
7
  from the prerequisite's own state instead.
8
  """
9
10
  use Ecto.Schema
11
  import Ecto.Changeset
12
13
  alias OpenAgents.Accounts.User
14
  alias OpenAgents.Issues.Issue
15
  alias OpenAgents.Repositories.Repository
16
17
  @primary_key {:id, :binary_id, autogenerate: true}
18
  schema "issue_dependencies" do
19
    belongs_to :repository, Repository, type: :binary_id
20
    belongs_to :issue, Issue
21
    belongs_to :blocked_by_issue, Issue
22
    belongs_to :created_by_user, User, type: :binary_id
23
    timestamps(type: :utc_datetime)
24
  end
25
26
  @doc false
27
  def changeset(dependency, attrs) do
28
    dependency
29
    |> cast(attrs, [:repository_id, :issue_id, :blocked_by_issue_id, :created_by_user_id])
30
    |> validate_required([:repository_id, :issue_id, :blocked_by_issue_id])
31
    |> check_constraint(:blocked_by_issue_id,
32
      name: :issue_dependencies_no_self_reference,
33
      message: "cannot depend on itself"
34
    )
35
    |> unique_constraint([:issue_id, :blocked_by_issue_id])
36
    |> foreign_key_constraint(:repository_id)
37
    |> foreign_key_constraint(:issue_id)
38
    |> foreign_key_constraint(:blocked_by_issue_id)
39
  end
40
end
lib/openagents_web/api_route_authority.ex modified +6

@@ -49,6 +49,8 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

49 49
      "get /api/v3/repos/:owner/:repo/milestones/:milestone_number" => :anonymous,
50 50
      "get /api/v3/repos/:owner/:repo/assignees" => :anonymous,
51 51
      "get /api/v3/repos/:owner/:repo/assignees/:assignee" => :anonymous,
52
      # Anonymous by design: the extension index is a public API description.
53
      "get /api/v3" => :anonymous,
52 54
      # Anonymous by design: device authorization bootstraps credentials.
53 55
      "post /api/v3/device/authorizations" => :anonymous,
54 56
      "post /api/v3/device/authorizations/token" => :anonymous,

@@ -59,6 +61,7 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

59 61
      "get /api/v3/repos/:owner/:repo" => :optional_bearer,
60 62
      "get /api/v3/repos/:owner/:repo/issues" => :optional_bearer,
61 63
      "get /api/v3/repos/:owner/:repo/issues/:issue_number" => :optional_bearer,
64
      "get /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies" => :optional_bearer,
62 65
      "get /api/v3/repos/:owner/:repo/pulls" => :optional_bearer,
63 66
      "get /api/v3/repos/:owner/:repo/pulls/:pull_number" => :optional_bearer,
64 67
      "get /api/v3/repos/:owner/:repo/projectsV2" => :optional_bearer,

@@ -96,6 +99,9 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

96 99
      "get /api/v3/forum/claims" => :required_bearer,
97 100
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/assignees" => :required_bearer,
98 101
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/comments" => :required_bearer,
102
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies" => :required_bearer,
103
      "delete /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies/:blocked_by_number" =>
104
        :required_bearer,
99 105
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/labels" => :required_bearer,
100 106
      "post /api/v3/repos/:owner/:repo/labels" => :required_bearer,
101 107
      "post /api/v3/repos/:owner/:repo/milestones" => :required_bearer,
lib/openagents_web/controllers/api_extension_controller.ex added +60

@@ -0,0 +1,60 @@

1
defmodule OpenAgentsWeb.ApiExtensionController do
2
  @moduledoc """
3
  The index of OpenAgents extension fields this deployment serves.
4
5
  A field is expected to be read only after it appears here, so an agent can
6
  discover the OpenAgents-specific parts of the API mechanically instead of
7
  reading prose. Responses that carry an extension also name it in the
8
  `x-openagents-extensions` response header.
9
  """
10
11
  use OpenAgentsWeb, :controller
12
13
  @issue_reference %{
14
    "type" => "object",
15
    "properties" => %{
16
      "number" => %{"type" => "integer"},
17
      "title" => %{"type" => "string"},
18
      "state" => %{"type" => "string", "enum" => ["open", "closed"]}
19
    }
20
  }
21
22
  @extensions %{
23
    "issue.openagents" => %{
24
      "version" => "2026-08-23",
25
      "description" => "OpenAgents-specific issue fields, namespaced away from the GitHub shape.",
26
      "fields" => %{
27
        "blocked" => %{
28
          "type" => "boolean",
29
          "description" => "True while any issue in blocked_by is still open."
30
        },
31
        "blocked_by" => %{
32
          "type" => "array",
33
          "items" => @issue_reference,
34
          "description" => "Issues that must close before this issue can start."
35
        },
36
        "blocks" => %{
37
          "type" => "array",
38
          "items" => @issue_reference,
39
          "description" => "Issues that wait on this issue."
40
        }
41
      },
42
      "filters" => %{
43
        "blocked" => %{
44
          "endpoint" => "GET /api/v3/repos/{owner}/{repo}/issues",
45
          "type" => "boolean",
46
          "description" => "Lists issues that do or do not have an open prerequisite."
47
        }
48
      },
49
      "endpoints" => [
50
        "GET /api/v3/repos/{owner}/{repo}/issues/{issue_number}/dependencies",
51
        "POST /api/v3/repos/{owner}/{repo}/issues/{issue_number}/dependencies",
52
        "DELETE /api/v3/repos/{owner}/{repo}/issues/{issue_number}/dependencies/{blocked_by_number}"
53
      ]
54
    }
55
  }
56
57
  def show(conn, _params) do
58
    json(conn, %{"api_version" => "v3", "extensions" => @extensions})
59
  end
60
end
lib/openagents_web/controllers/issue_controller.ex modified +43 -4

@@ -11,10 +11,13 @@ defmodule OpenAgentsWeb.IssueController do

11 11
    with :ok <- validate_index_params(params),
12 12
         {issues, total} <-
13 13
           Issues.list_issues_page(repository, index_options(params)) do
14
      render(conn, :index,
14
      conn
15
      |> put_extensions_header()
16
      |> render(:index,
15 17
        issues: issues,
16 18
        owner: owner,
17 19
        repo: repo,
20
        dependencies: Issues.dependency_graph(issues),
18 21
        pagination: %{
19 22
          page: Issues.parse_page(params["page"]),
20 23
          per_page: Issues.per_page(),

@@ -43,6 +46,9 @@ defmodule OpenAgentsWeb.IssueController do

43 46
      Map.has_key?(params, "page") and not valid_page?(params["page"]) ->
44 47
        {:error, :page, "must be a positive integer"}
45 48
49
      Map.has_key?(params, "blocked") and blocked_filter(params["blocked"]) == :invalid ->
50
        {:error, :blocked, "must be true or false"}
51
46 52
      true ->
47 53
        :ok
48 54
    end

@@ -66,10 +72,21 @@ defmodule OpenAgentsWeb.IssueController do

66 72
      assignee: params["assignee"],
67 73
      milestone: params["milestone"],
68 74
      q: params["q"],
75
      blocked: blocked_filter(params["blocked"]),
69 76
      page: params["page"]
70 77
    ]
71 78
  end
72 79
80
  # An agent asking "what can I start right now?" sends `blocked=false`. The
81
  # answer is derived from prerequisite state, so no value other than the two
82
  # booleans has a meaning to guess at.
83
  defp blocked_filter(nil), do: nil
84
  defp blocked_filter("true"), do: true
85
  defp blocked_filter("false"), do: false
86
  defp blocked_filter(true), do: true
87
  defp blocked_filter(false), do: false
88
  defp blocked_filter(_value), do: :invalid
89
73 90
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
74 91
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
75 92

@@ -77,7 +94,13 @@ defmodule OpenAgentsWeb.IssueController do

77 94
      {:ok, %Issue{} = issue} ->
78 95
        conn
79 96
        |> put_status(:created)
80
        |> render(:show, issue: issue, owner: owner, repo: repo)
97
        |> put_extensions_header()
98
        |> render(:show,
99
          issue: issue,
100
          owner: owner,
101
          repo: repo,
102
          dependencies: dependencies(issue)
103
        )
81 104
82 105
      {:error, %Ecto.Changeset{} = changeset} ->
83 106
        conn

@@ -101,7 +124,9 @@ defmodule OpenAgentsWeb.IssueController do

101 124
        OpenAgentsWeb.ControllerHelpers.integer_param!(issue_number)
102 125
      )
103 126
104
    render(conn, :show, issue: issue, owner: owner, repo: repo)
127
    conn
128
    |> put_extensions_header()
129
    |> render(:show, issue: issue, owner: owner, repo: repo, dependencies: dependencies(issue))
105 130
  rescue
106 131
    Ecto.NoResultsError ->
107 132
      conn

@@ -127,7 +152,14 @@ defmodule OpenAgentsWeb.IssueController do

127 152
128 153
    case Issues.update_issue(issue, params, conn.assigns.current_user) do
129 154
      {:ok, %Issue{} = issue} ->
130
        render(conn, :show, issue: issue, owner: owner, repo: repo)
155
        conn
156
        |> put_extensions_header()
157
        |> render(:show,
158
          issue: issue,
159
          owner: owner,
160
          repo: repo,
161
          dependencies: dependencies(issue)
162
        )
131 163
132 164
      {:error, %Ecto.Changeset{} = changeset} ->
133 165
        conn

@@ -141,6 +173,13 @@ defmodule OpenAgentsWeb.IssueController do

141 173
      |> json(%{message: "Not Found"})
142 174
  end
143 175
176
  defp dependencies(%Issue{} = issue), do: Issues.dependency_graph([issue])
177
178
  # The extension namespace is discoverable from the response itself, so a
179
  # client never has to infer which OpenAgents fields this deployment sends.
180
  defp put_extensions_header(conn),
181
    do: put_resp_header(conn, "x-openagents-extensions", "issue.openagents")
182
144 183
  defp not_found(conn) do
145 184
    conn |> put_status(:not_found) |> json(%{message: "Not Found"})
146 185
  end
lib/openagents_web/controllers/issue_dependency_controller.ex added +116

@@ -0,0 +1,116 @@

1
defmodule OpenAgentsWeb.IssueDependencyController do
2
  @moduledoc """
3
  Prerequisite edges between the issues of one repository.
4
5
  The response is the same object the issue extension carries, so a client that
6
  reads `issue.openagents` and a client that reads this endpoint agree without
7
  translating between two shapes.
8
  """
9
10
  use OpenAgentsWeb, :controller
11
12
  alias OpenAgents.Issues
13
  alias OpenAgents.Repositories
14
  alias OpenAgentsWeb.ControllerHelpers
15
16
  def index(conn, %{"owner" => owner, "repo" => repo, "issue_number" => issue_number}) do
17
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
18
19
    issue =
20
      Issues.get_issue_by_number!(repository, ControllerHelpers.integer_param!(issue_number))
21
22
    json(conn, Issues.dependencies(issue))
23
  rescue
24
    Ecto.NoResultsError -> not_found(conn)
25
  end
26
27
  def create(
28
        conn,
29
        %{"owner" => owner, "repo" => repo, "issue_number" => issue_number} = params
30
      ) do
31
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
32
33
    issue =
34
      Issues.get_issue_by_number!(repository, ControllerHelpers.integer_param!(issue_number))
35
36
    case params["blocked_by"] do
37
      numbers when is_list(numbers) ->
38
        add_dependencies(conn, issue, numbers)
39
40
      _other ->
41
        unprocessable(conn, "must be a list of issue numbers in this repository")
42
    end
43
  rescue
44
    Ecto.NoResultsError -> not_found(conn)
45
  end
46
47
  def delete(conn, %{
48
        "owner" => owner,
49
        "repo" => repo,
50
        "issue_number" => issue_number,
51
        "blocked_by_number" => blocked_by_number
52
      }) do
53
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
54
55
    issue =
56
      Issues.get_issue_by_number!(repository, ControllerHelpers.integer_param!(issue_number))
57
58
    case Issues.remove_dependency(issue, blocked_by_number) do
59
      :ok ->
60
        json(conn, Issues.dependencies(issue))
61
62
      {:error, {:missing_dependency, number}} ->
63
        message(conn, :not_found, "Issue ##{number} is not a prerequisite of this issue")
64
65
      {:error, {:missing_issue, number}} ->
66
        message(conn, :not_found, "Issue ##{number} does not exist in this repository")
67
68
      {:error, {:invalid_number, _value}} ->
69
        not_found(conn)
70
    end
71
  rescue
72
    Ecto.NoResultsError -> not_found(conn)
73
  end
74
75
  defp add_dependencies(conn, issue, numbers) do
76
    case Issues.add_dependencies(issue, numbers, conn.assigns.current_user) do
77
      :ok ->
78
        conn |> put_status(:created) |> json(Issues.dependencies(issue))
79
80
      {:error, reason} ->
81
        unprocessable(conn, error_message(reason))
82
    end
83
  end
84
85
  defp error_message({:invalid_number, value}),
86
    do: "#{inspect(value)} is not an issue number"
87
88
  defp error_message({:self_reference, number}),
89
    do: "Issue ##{number} cannot be a prerequisite of itself"
90
91
  defp error_message({:missing_issue, number}),
92
    do: "Issue ##{number} does not exist in this repository"
93
94
  defp error_message({:cycle, numbers}),
95
    do: "Would create a dependency cycle: #{Enum.map_join(numbers, " -> ", &"##{&1}")}"
96
97
  defp error_message({:invalid_dependency, changeset}) do
98
    changeset
99
    |> Ecto.Changeset.traverse_errors(fn {message, _options} -> message end)
100
    |> Enum.map_join("; ", fn {field, messages} ->
101
      "#{field} #{Enum.join(messages, ", ")}"
102
    end)
103
  end
104
105
  defp unprocessable(conn, message) do
106
    conn
107
    |> put_status(:unprocessable_entity)
108
    |> json(%{errors: %{blocked_by: [message]}})
109
  end
110
111
  defp message(conn, status, message) do
112
    conn |> put_status(status) |> json(%{message: message})
113
  end
114
115
  defp not_found(conn), do: message(conn, :not_found, "Not Found")
116
end
lib/openagents_web/controllers/issue_json.ex modified +24

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

1 1
defmodule OpenAgentsWeb.IssueJSON do
2 2
  @moduledoc """
3 3
  Renders GitHub-compatible issue JSON.
4
5
  GitHub-shaped keys keep their exact shape. OpenAgents-specific fields live
6
  under one `openagents` object, so a GitHub client sees an additional object
7
  and nothing else. The object appears only when the caller supplies the
8
  `:dependencies` graph, because an absent graph is not the same fact as an
9
  issue with no prerequisites.
4 10
  """
5 11
6 12
  def render("index.json", %{issues: issues, pagination: pagination} = assigns) do

@@ -48,6 +54,24 @@ defmodule OpenAgentsWeb.IssueJSON do

48 54
      html_url: "#{url_base}/#{owner}/#{repo}/issues/#{issue.number}",
49 55
      url: "#{url_base}/api/v3/repos/#{owner}/#{repo}/issues/#{issue.number}"
50 56
    }
57
    |> put_extension(issue, assigns)
58
  end
59
60
  defp put_extension(json, issue, assigns) do
61
    case Map.get(assigns, :dependencies) do
62
      nil -> json
63
      graph -> Map.put(json, :openagents, extension_json(graph, issue))
64
    end
65
  end
66
67
  defp extension_json(graph, issue) do
68
    dependencies = Map.get(graph, issue.id, %{blocked: false, blocked_by: [], blocks: []})
69
70
    %{
71
      blocked: dependencies.blocked,
72
      blocked_by: dependencies.blocked_by,
73
      blocks: dependencies.blocks
74
    }
51 75
  end
52 76
53 77
  defp total_pages(0, _per_page), do: 1
lib/openagents_web/route_authority.ex modified +4

@@ -60,6 +60,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

60 60
  @optional_forge_read_paths [
61 61
    "/api/v3/repos/:owner/:repo/issues",
62 62
    "/api/v3/repos/:owner/:repo/issues/:issue_number",
63
    "/api/v3/repos/:owner/:repo/issues/:issue_number/dependencies",
63 64
    "/api/v3/repos/:owner/:repo/pulls",
64 65
    "/api/v3/repos/:owner/:repo/pulls/:pull_number",
65 66
    "/api/v3/repos/:owner/:repo/projectsV2",

@@ -180,6 +181,9 @@ defmodule OpenAgentsWeb.RouteAuthority do

180 181
       when verb in [:get, :head],
181 182
       do: declaration(:public_read, "anonymous", "published:api-contract", false)
182 183
184
  defp policy(%{path: "/api/v3", verb: verb}) when verb in [:get, :head],
185
    do: declaration(:public_read, "anonymous", "published:api-extensions", false)
186
183 187
  defp policy(%{path: "/controller/pairings", verb: :post}),
184 188
    do: declaration(:machine, "unpaired machine", "machine:pairing:create", true)
185 189
lib/openagents_web/router.ex modified +16

@@ -262,6 +262,8 @@ defmodule OpenAgentsWeb.Router do

262 262
  scope "/api/v3", OpenAgentsWeb do
263 263
    pipe_through :api
264 264
265
    get "/", ApiExtensionController, :show
266
265 267
    post "/device/authorizations", DeviceAuthorizationController, :create
266 268
    post "/device/authorizations/token", DeviceAuthorizationController, :token
267 269

@@ -300,6 +302,11 @@ defmodule OpenAgentsWeb.Router do

300 302
    get "/repos/:owner/:repo", RepositoryController, :show
301 303
    get "/repos/:owner/:repo/issues", IssueController, :index
302 304
    get "/repos/:owner/:repo/issues/:issue_number", IssueController, :show
305
306
    get "/repos/:owner/:repo/issues/:issue_number/dependencies",
307
        IssueDependencyController,
308
        :index
309
303 310
    get "/repos/:owner/:repo/pulls", PullRequestController, :index
304 311
    get "/repos/:owner/:repo/pulls/:pull_number", PullRequestController, :show
305 312
    get "/repos/:owner/:repo/projectsV2", ProjectController, :index

@@ -335,6 +342,15 @@ defmodule OpenAgentsWeb.Router do

335 342
    put "/repos/:owner/:repo/issues/comments/:id", CommentController, :update
336 343
    patch "/repos/:owner/:repo/issues/comments/:id", CommentController, :update
337 344
    delete "/repos/:owner/:repo/issues/comments/:id", CommentController, :delete
345
346
    post "/repos/:owner/:repo/issues/:issue_number/dependencies",
347
         IssueDependencyController,
348
         :create
349
350
    delete "/repos/:owner/:repo/issues/:issue_number/dependencies/:blocked_by_number",
351
           IssueDependencyController,
352
           :delete
353
338 354
    post "/repos/:owner/:repo/issues/:issue_number/labels", IssueLabelController, :create
339 355
    delete "/repos/:owner/:repo/issues/:issue_number/labels/:name", IssueLabelController, :delete
340 356
    post "/repos/:owner/:repo/issues/:issue_number/assignees", IssueAssigneeController, :create
priv/docs/rest-api.md modified +43

@@ -62,6 +62,49 @@ DELETE /api/v3/repos/:owner/:repo/issues/comments/:id

62 62
List responses use named envelopes. For example, the issue list returns an
63 63
object with an `issues` array.
64 64
65
## Issue prerequisites
66
67
An issue can wait on other issues in the same repository.
68
69
```text
70
GET    /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies
71
POST   /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies
72
DELETE /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies/:blocked_by_number
73
```
74
75
`POST` takes the issue numbers this issue waits on and returns the resulting
76
graph:
77
78
```json
79
{ "blocked_by": [9, 12] }
80
```
81
82
```json
83
{
84
  "blocked": true,
85
  "blocked_by": [{ "number": 9, "title": "Deliver the work system", "state": "open" }],
86
  "blocks": []
87
}
88
```
89
90
Every issue response carries the same object under `openagents`, and a response
91
that carries it names the namespace in the `x-openagents-extensions` header.
92
`GET /api/v3` lists the extension fields this deployment serves.
93
94
`blocked` is derived from the prerequisites' own state, so closing the last open
95
prerequisite unblocks the issue with no second write. The issue list filters on
96
it, which answers "what can an agent start right now":
97
98
```text
99
GET /api/v3/repos/:owner/:repo/issues?blocked=false
100
GET /api/v3/repos/:owner/:repo/issues?blocked=true
101
```
102
103
Prerequisites stay inside one repository. An unknown number, a self reference,
104
and an edge that would close a cycle each return `422 Unprocessable Entity`,
105
and none of the batch is recorded. Reading the graph needs the same access as
106
reading the issue. Recording or removing an edge needs repository write access.
107
65 108
## Labels
66 109
67 110
```text
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -235,7 +235,8 @@

235 235
    20260823010819,
236 236
    20260823013135,
237 237
    20260823021021,
238
    20260823034851
238
    20260823034851,
239
    20260823040635
239 240
  ],
240 241
  "required_tables": [
241 242
    "users",
priv/repo/migrations/20260823040635_create_issue_dependencies.exs added +30

@@ -0,0 +1,30 @@

1
defmodule OpenAgents.Repo.Migrations.CreateIssueDependencies do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:issue_dependencies, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
8
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
9
        null: false
10
11
      add :issue_id, references(:issues, on_delete: :delete_all), null: false
12
      add :blocked_by_issue_id, references(:issues, on_delete: :delete_all), null: false
13
      add :created_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
14
      timestamps(type: :utc_datetime)
15
    end
16
17
    # One edge per ordered pair, so recording the same prerequisite twice is a
18
    # no-op rather than a duplicate the graph has to deduplicate on every read.
19
    create unique_index(:issue_dependencies, [:issue_id, :blocked_by_issue_id])
20
21
    # Both directions are read equally often: an issue asks what blocks it, and
22
    # a closing issue asks what it releases.
23
    create index(:issue_dependencies, [:blocked_by_issue_id])
24
    create index(:issue_dependencies, [:repository_id])
25
26
    create constraint(:issue_dependencies, :issue_dependencies_no_self_reference,
27
             check: "issue_id <> blocked_by_issue_id"
28
           )
29
  end
30
end
test/openagents/issue_dependencies_test.exs added +208

@@ -0,0 +1,208 @@

1
defmodule OpenAgents.IssueDependenciesTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.Issues
5
6
  setup do
7
    {:ok, repository: repository_fixture()}
8
  end
9
10
  describe "add_dependencies/3" do
11
    test "records a prerequisite in both directions", %{repository: repository} do
12
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Aim agents at the backlog"})
13
      {:ok, blocker} = Issues.create_issue(repository, %{title: "Deliver the work system"})
14
15
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
16
17
      assert %{blocked: true, blocked_by: [%{number: blocker_number, state: "open"}], blocks: []} =
18
               Issues.dependencies(blocked)
19
20
      assert blocker_number == blocker.number
21
22
      assert %{blocked: false, blocked_by: [], blocks: [%{number: blocked_number}]} =
23
               Issues.dependencies(blocker)
24
25
      assert blocked_number == blocked.number
26
    end
27
28
    test "recording the same prerequisite twice leaves one edge", %{repository: repository} do
29
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Second"})
30
      {:ok, blocker} = Issues.create_issue(repository, %{title: "First"})
31
32
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
33
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
34
35
      assert %{blocked_by: [_one]} = Issues.dependencies(blocked)
36
    end
37
38
    test "one unknown number records none of the batch", %{repository: repository} do
39
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Blocked"})
40
      {:ok, blocker} = Issues.create_issue(repository, %{title: "Blocker"})
41
42
      assert {:error, {:missing_issue, 4_242}} =
43
               Issues.add_dependencies(blocked, [blocker.number, 4_242])
44
45
      assert %{blocked: false, blocked_by: []} = Issues.dependencies(blocked)
46
    end
47
48
    test "an issue in another repository is not a prerequisite here", %{repository: repository} do
49
      other_repository = repository_fixture()
50
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Local"})
51
      {:ok, _first} = Issues.create_issue(other_repository, %{title: "First elsewhere"})
52
      {:ok, elsewhere} = Issues.create_issue(other_repository, %{title: "Elsewhere"})
53
54
      refute elsewhere.number == blocked.number
55
56
      assert {:error, {:missing_issue, number}} =
57
               Issues.add_dependencies(blocked, [elsewhere.number])
58
59
      assert number == elsewhere.number
60
    end
61
62
    test "an issue cannot be its own prerequisite", %{repository: repository} do
63
      {:ok, issue} = Issues.create_issue(repository, %{title: "Alone"})
64
65
      assert {:error, {:self_reference, number}} =
66
               Issues.add_dependencies(issue, [issue.number])
67
68
      assert number == issue.number
69
    end
70
71
    test "a value that is not an issue number is refused", %{repository: repository} do
72
      {:ok, issue} = Issues.create_issue(repository, %{title: "Alone"})
73
74
      assert {:error, {:invalid_number, "soon"}} = Issues.add_dependencies(issue, ["soon"])
75
    end
76
77
    test "an edge that would close a cycle is refused", %{repository: repository} do
78
      {:ok, first} = Issues.create_issue(repository, %{title: "First"})
79
      {:ok, second} = Issues.create_issue(repository, %{title: "Second"})
80
      {:ok, third} = Issues.create_issue(repository, %{title: "Third"})
81
82
      assert :ok = Issues.add_dependencies(second, [first.number])
83
      assert :ok = Issues.add_dependencies(third, [second.number])
84
85
      assert {:error, {:cycle, path}} = Issues.add_dependencies(first, [third.number])
86
      assert path == [first.number, third.number, second.number, first.number]
87
88
      assert %{blocked_by: []} = Issues.dependencies(first)
89
    end
90
  end
91
92
  describe "derived blocked state" do
93
    test "closing the last prerequisite unblocks the issue", %{repository: repository} do
94
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Waiting"})
95
      {:ok, blocker} = Issues.create_issue(repository, %{title: "Prerequisite"})
96
97
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
98
      assert %{blocked: true} = Issues.dependencies(blocked)
99
100
      {:ok, blocker} = Issues.update_issue(blocker, %{"state" => "closed"})
101
      assert %{blocked: false, blocked_by: [%{state: "closed"}]} = Issues.dependencies(blocked)
102
103
      {:ok, _reopened} = Issues.update_issue(blocker, %{"state" => "open"})
104
      assert %{blocked: true} = Issues.dependencies(blocked)
105
    end
106
107
    test "one open prerequisite among closed ones still blocks", %{repository: repository} do
108
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Waiting"})
109
      {:ok, done} = Issues.create_issue(repository, %{title: "Done", state: "closed"})
110
      {:ok, pending} = Issues.create_issue(repository, %{title: "Pending"})
111
112
      assert :ok = Issues.add_dependencies(blocked, [done.number, pending.number])
113
      assert %{blocked: true, blocked_by: [_first, _second]} = Issues.dependencies(blocked)
114
    end
115
  end
116
117
  describe "remove_dependency/2" do
118
    test "removes one edge and leaves the rest", %{repository: repository} do
119
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Waiting"})
120
      {:ok, first} = Issues.create_issue(repository, %{title: "First"})
121
      {:ok, second} = Issues.create_issue(repository, %{title: "Second"})
122
123
      assert :ok = Issues.add_dependencies(blocked, [first.number, second.number])
124
      assert :ok = Issues.remove_dependency(blocked, first.number)
125
126
      assert %{blocked_by: [%{number: remaining}]} = Issues.dependencies(blocked)
127
      assert remaining == second.number
128
    end
129
130
    test "removing an edge that is not recorded reports the mismatch", %{
131
      repository: repository
132
    } do
133
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Waiting"})
134
      {:ok, other} = Issues.create_issue(repository, %{title: "Unrelated"})
135
136
      assert {:error, {:missing_dependency, number}} =
137
               Issues.remove_dependency(blocked, other.number)
138
139
      assert number == other.number
140
    end
141
  end
142
143
  describe "list_issues_page/2 with the blocked filter" do
144
    test "blocked and unblocked partition the repository", %{repository: repository} do
145
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Waiting"})
146
      {:ok, blocker} = Issues.create_issue(repository, %{title: "Prerequisite"})
147
      {:ok, free} = Issues.create_issue(repository, %{title: "Ready"})
148
149
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
150
151
      assert {[%{id: id}], 1} = Issues.list_issues_page(repository, blocked: true)
152
      assert id == blocked.id
153
154
      {unblocked, total} = Issues.list_issues_page(repository, blocked: false)
155
      assert total == 2
156
      assert Enum.map(unblocked, & &1.id) |> Enum.sort() == Enum.sort([blocker.id, free.id])
157
    end
158
159
    test "closing the prerequisite moves the issue between the two lists", %{
160
      repository: repository
161
    } do
162
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Waiting"})
163
      {:ok, blocker} = Issues.create_issue(repository, %{title: "Prerequisite"})
164
165
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
166
      assert {_issues, 1} = Issues.list_issues_page(repository, blocked: true)
167
168
      {:ok, _closed} = Issues.update_issue(blocker, %{"state" => "closed"})
169
170
      assert {[], 0} = Issues.list_issues_page(repository, blocked: true)
171
      assert {[%{id: id}], 1} = Issues.list_issues_page(repository, blocked: false)
172
      assert id == blocked.id
173
    end
174
175
    test "the filter composes with the state filter", %{repository: repository} do
176
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Waiting"})
177
      {:ok, blocker} = Issues.create_issue(repository, %{title: "Prerequisite"})
178
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
179
180
      {:ok, _closed_blocked} = Issues.update_issue(blocked, %{"state" => "closed"})
181
182
      assert {[], 0} = Issues.list_issues_page(repository, blocked: true, state: "open")
183
      assert {[_issue], 1} = Issues.list_issues_page(repository, blocked: true, state: "closed")
184
    end
185
  end
186
187
  describe "dependency_graph/1" do
188
    test "reads a whole page of edges without walking the graph per row", %{
189
      repository: repository
190
    } do
191
      {:ok, blocked} = Issues.create_issue(repository, %{title: "Waiting"})
192
      {:ok, blocker} = Issues.create_issue(repository, %{title: "Prerequisite"})
193
      {:ok, unrelated} = Issues.create_issue(repository, %{title: "Unrelated"})
194
195
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
196
197
      graph = Issues.dependency_graph([blocked, blocker, unrelated])
198
199
      assert %{blocked: true, blocked_by: [_one], blocks: []} = graph[blocked.id]
200
      assert %{blocked: false, blocked_by: [], blocks: [_one]} = graph[blocker.id]
201
      assert %{blocked: false, blocked_by: [], blocks: []} = graph[unrelated.id]
202
    end
203
204
    test "an empty list of issues reads no edges", %{repository: _repository} do
205
      assert Issues.dependency_graph([]) == %{}
206
    end
207
  end
208
end
test/openagents_web/controllers/api_extension_controller_test.exs added +24

@@ -0,0 +1,24 @@

1
defmodule OpenAgentsWeb.ApiExtensionControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  test "GET /api/v3 lists the issue extension fields an agent can rely on", %{conn: conn} do
5
    conn = get(conn, ~p"/api/v3")
6
7
    assert %{
8
             "api_version" => "v3",
9
             "extensions" => %{
10
               "issue.openagents" => %{
11
                 "version" => _version,
12
                 "fields" => fields,
13
                 "filters" => %{"blocked" => _filter},
14
                 "endpoints" => endpoints
15
               }
16
             }
17
           } = json_response(conn, 200)
18
19
    assert %{"blocked" => %{"type" => "boolean"}} = fields
20
    assert %{"blocked_by" => %{"type" => "array"}} = fields
21
    assert %{"blocks" => %{"type" => "array"}} = fields
22
    assert Enum.any?(endpoints, &String.contains?(&1, "/dependencies"))
23
  end
24
end
test/openagents_web/controllers/issue_controller_test.exs modified +97

@@ -251,4 +251,101 @@ defmodule OpenAgentsWeb.IssueControllerTest do

251 251
      assert message =~ "positive integer"
252 252
    end
253 253
  end
254
255
  describe "the openagents issue extension" do
256
    test "show carries the dependency graph and names the extension", %{conn: conn} do
257
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
258
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
259
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
260
261
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{blocked.number}")
262
263
      assert %{
264
               "openagents" => %{
265
                 "blocked" => true,
266
                 "blocked_by" => [%{"number" => number, "state" => "open"}],
267
                 "blocks" => []
268
               }
269
             } = json_response(conn, 200)
270
271
      assert number == blocker.number
272
      assert get_resp_header(conn, "x-openagents-extensions") == ["issue.openagents"]
273
    end
274
275
    test "index carries the graph for every issue on the page", %{conn: conn} do
276
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
277
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
278
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
279
280
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues")
281
282
      assert %{"issues" => issues} = json_response(conn, 200)
283
      by_number = Map.new(issues, &{&1["number"], &1["openagents"]})
284
285
      assert %{"blocked" => true, "blocked_by" => [_one]} = by_number[blocked.number]
286
      assert %{"blocked" => false, "blocks" => [_one]} = by_number[blocker.number]
287
    end
288
289
    test "an issue without prerequisites reports an empty graph", %{conn: conn} do
290
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Ready"})
291
292
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")
293
294
      assert %{"openagents" => %{"blocked" => false, "blocked_by" => [], "blocks" => []}} =
295
               json_response(conn, 200)
296
    end
297
  end
298
299
  describe "the blocked filter" do
300
    test "index lists only the issues waiting on an open prerequisite", %{conn: conn} do
301
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
302
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
303
      {:ok, _ready} = Issues.create_issue(repository(), %{title: "Ready"})
304
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
305
306
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues?blocked=true")
307
308
      assert %{"issues" => [issue], "pagination" => %{"total" => 1}} = json_response(conn, 200)
309
      assert issue["title"] == "Waiting"
310
    end
311
312
    test "index lists the issues an agent can start now", %{conn: conn} do
313
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
314
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
315
      {:ok, _ready} = Issues.create_issue(repository(), %{title: "Ready"})
316
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
317
318
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues?blocked=false")
319
320
      assert %{"issues" => issues, "pagination" => %{"total" => 2}} = json_response(conn, 200)
321
      assert Enum.map(issues, & &1["title"]) |> Enum.sort() == ["Prerequisite", "Ready"]
322
    end
323
324
    test "closing the prerequisite moves the issue to the unblocked list", %{conn: conn} do
325
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
326
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
327
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
328
329
      patch(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{blocker.number}", %{
330
        state: "closed"
331
      })
332
333
      blocked_conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues?blocked=true")
334
      assert %{"issues" => [], "pagination" => %{"total" => 0}} = json_response(blocked_conn, 200)
335
336
      ready_conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues?blocked=false")
337
338
      assert %{"issues" => [issue], "pagination" => %{"total" => 1}} =
339
               json_response(ready_conn, 200)
340
341
      assert issue["title"] == "Waiting"
342
    end
343
344
    test "index rejects a blocked value that is not a boolean", %{conn: conn} do
345
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues?blocked=maybe")
346
347
      assert %{"errors" => %{"blocked" => [message]}} = json_response(conn, 422)
348
      assert message =~ "true or false"
349
    end
350
  end
254 351
end
test/openagents_web/controllers/issue_dependency_controller_test.exs added +172

@@ -0,0 +1,172 @@

1
defmodule OpenAgentsWeb.IssueDependencyControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  alias OpenAgents.Issues
5
  alias OpenAgents.Repositories
6
7
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "dependencies", repository())}
8
9
  describe "create" do
10
    test "POST records prerequisites and answers with the resulting graph", %{conn: conn} do
11
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Aim agents at the backlog"})
12
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Deliver the work system"})
13
14
      conn = post(conn, dependencies_path(blocked), %{blocked_by: [blocker.number]})
15
16
      assert %{
17
               "blocked" => true,
18
               "blocked_by" => [
19
                 %{
20
                   "number" => number,
21
                   "title" => "Deliver the work system",
22
                   "state" => "open"
23
                 }
24
               ],
25
               "blocks" => []
26
             } = json_response(conn, 201)
27
28
      assert number == blocker.number
29
    end
30
31
    test "POST refuses a prerequisite that does not exist", %{conn: conn} do
32
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
33
34
      conn = post(conn, dependencies_path(blocked), %{blocked_by: [999_999]})
35
36
      assert %{"errors" => %{"blocked_by" => [message]}} = json_response(conn, 422)
37
      assert message =~ "does not exist in this repository"
38
    end
39
40
    test "POST refuses a self reference", %{conn: conn} do
41
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Alone"})
42
43
      conn = post(conn, dependencies_path(issue), %{blocked_by: [issue.number]})
44
45
      assert %{"errors" => %{"blocked_by" => [message]}} = json_response(conn, 422)
46
      assert message =~ "cannot be a prerequisite of itself"
47
    end
48
49
    test "POST refuses an edge that would close a cycle", %{conn: conn} do
50
      {:ok, first} = Issues.create_issue(repository(), %{title: "First"})
51
      {:ok, second} = Issues.create_issue(repository(), %{title: "Second"})
52
      assert :ok = Issues.add_dependencies(second, [first.number])
53
54
      conn = post(conn, dependencies_path(first), %{blocked_by: [second.number]})
55
56
      assert %{"errors" => %{"blocked_by" => [message]}} = json_response(conn, 422)
57
      assert message =~ "Would create a dependency cycle"
58
    end
59
60
    test "POST refuses a body without a list of numbers", %{conn: conn} do
61
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Waiting"})
62
63
      conn = post(conn, dependencies_path(issue), %{blocked_by: "12"})
64
65
      assert %{"errors" => %{"blocked_by" => [message]}} = json_response(conn, 422)
66
      assert message =~ "must be a list of issue numbers"
67
    end
68
69
    test "POST answers 404 for an issue that does not exist", %{conn: conn} do
70
      conn =
71
        post(
72
          conn,
73
          "/api/v3/repos/OpenAgentsInc/openagents.com/issues/999999/dependencies",
74
          %{blocked_by: []}
75
        )
76
77
      assert json_response(conn, 404)
78
    end
79
  end
80
81
  describe "index" do
82
    test "GET reads both directions of the graph", %{conn: conn} do
83
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
84
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
85
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
86
87
      assert %{"blocked" => true, "blocked_by" => [_one], "blocks" => []} =
88
               get(conn, dependencies_path(blocked)) |> json_response(200)
89
90
      assert %{"blocked" => false, "blocked_by" => [], "blocks" => [_one]} =
91
               get(conn, dependencies_path(blocker)) |> json_response(200)
92
    end
93
94
    test "GET reports an issue with no prerequisites as unblocked", %{conn: conn} do
95
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Ready"})
96
97
      assert %{"blocked" => false, "blocked_by" => [], "blocks" => []} =
98
               get(conn, dependencies_path(issue)) |> json_response(200)
99
    end
100
  end
101
102
  describe "delete" do
103
    test "DELETE removes one prerequisite", %{conn: conn} do
104
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
105
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
106
      assert :ok = Issues.add_dependencies(blocked, [blocker.number])
107
108
      conn = delete(conn, dependencies_path(blocked) <> "/#{blocker.number}")
109
110
      assert %{"blocked" => false, "blocked_by" => []} = json_response(conn, 200)
111
    end
112
113
    test "DELETE answers 404 when the edge is not recorded", %{conn: conn} do
114
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
115
      {:ok, other} = Issues.create_issue(repository(), %{title: "Unrelated"})
116
117
      conn = delete(conn, dependencies_path(blocked) <> "/#{other.number}")
118
119
      assert %{"message" => message} = json_response(conn, 404)
120
      assert message =~ "is not a prerequisite of this issue"
121
    end
122
  end
123
124
  describe "authorization" do
125
    test "an anonymous visitor cannot read the graph of a private repository" do
126
      private_repository = repository_fixture(%{visibility: "private"})
127
      {:ok, issue} = Issues.create_issue(private_repository, %{title: "Private"})
128
129
      path =
130
        "/api/v3/repos/#{private_repository.owner}/#{private_repository.name}" <>
131
          "/issues/#{issue.number}/dependencies"
132
133
      assert get(build_conn(), path) |> json_response(404)
134
135
      member_conn = put_forge_api_token(build_conn(), "private-graph", private_repository)
136
      assert %{"blocked" => false} = get(member_conn, path) |> json_response(200)
137
    end
138
139
    test "a bearer without write access cannot record a prerequisite" do
140
      other_repository = repository_fixture()
141
      {:ok, blocked} = Issues.create_issue(other_repository, %{title: "Waiting"})
142
      {:ok, blocker} = Issues.create_issue(other_repository, %{title: "Prerequisite"})
143
144
      conn = put_forge_api_token(build_conn(), "dependency-nonmember")
145
146
      path =
147
        "/api/v3/repos/#{other_repository.owner}/#{other_repository.name}" <>
148
          "/issues/#{blocked.number}/dependencies"
149
150
      assert post(conn, path, %{blocked_by: [blocker.number]}) |> json_response(404)
151
      assert %{blocked_by: []} = Issues.dependencies(blocked)
152
    end
153
154
    test "an anonymous visitor cannot record a prerequisite" do
155
      {:ok, blocked} = Issues.create_issue(repository(), %{title: "Waiting"})
156
      {:ok, blocker} = Issues.create_issue(repository(), %{title: "Prerequisite"})
157
158
      conn = post(build_conn(), dependencies_path(blocked), %{blocked_by: [blocker.number]})
159
160
      assert conn.status in [401, 404]
161
      assert %{blocked_by: []} = Issues.dependencies(blocked)
162
    end
163
  end
164
165
  defp dependencies_path(issue) do
166
    "/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/dependencies"
167
  end
168
169
  defp repository do
170
    Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
171
  end
172
end

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