Add repository pull request core

befdc373d6f6 · AtlantisPleb · · parent 360f9c95fc4a

Add repository pull request core

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

  • added docs/pull-requests.md
  • added lib/openagents/pull_requests.ex
  • added lib/openagents/pull_requests/pull_request.ex
  • modified lib/openagents/repositories.ex
  • modified lib/openagents/repositories/repository.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/pull_request_controller.ex
  • added lib/openagents_web/controllers/pull_request_json.ex
  • modified lib/openagents_web/controllers/repository_controller.ex
  • modified lib/openagents_web/controllers/repository_json.ex
  • modified lib/openagents_web/live/code_repo_live.ex
  • added lib/openagents_web/live/pull_request_index_live.ex
  • added lib/openagents_web/live/pull_request_show_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • added priv/repo/migrations/20260823013135_create_pull_requests.exs
  • added test/openagents/pull_requests_test.exs
  • added test/openagents_web/controllers/pull_request_controller_test.exs
  • modified test/openagents_web/live/code_live_test.exs
  • added test/openagents_web/live/pull_request_live_test.exs

Diff

20 files changed, +1047 -0

docs/pull-requests.md added +48

@@ -0,0 +1,48 @@

1
# Pull requests
2
3
OpenAgents pull requests connect an issue to a proposed change between two hosted repository branches. The issue supplies the pull request number, title, body, author, comments, and open or closed state.
4
5
## Repository setting
6
7
New repositories allow pull requests by default. `OpenAgentsInc/openagents.com` starts with pull requests disabled because changes to the production application use its controlled publication workflow.
8
9
A repository owner can change this setting in the repository's **Pull requests** panel or with the repository API:
10
11
```http
12
PATCH /api/v3/repos/{owner}/{repo}
13
Authorization: Bearer {forge-write-token}
14
Content-Type: application/json
15
16
{"pull_requests_enabled": true}
17
```
18
19
Maintainers, contributors, viewers, and unauthenticated callers cannot change this setting.
20
21
## API
22
23
Use these endpoints to work with pull requests:
24
25
- `GET /api/v3/repos/{owner}/{repo}/pulls`
26
- `GET /api/v3/repos/{owner}/{repo}/pulls/{pull_number}`
27
- `POST /api/v3/repos/{owner}/{repo}/pulls`
28
- `PATCH /api/v3/repos/{owner}/{repo}/pulls/{pull_number}`
29
30
Create a pull request with a title, optional body, source repository, source ref, and optional base ref:
31
32
```json
33
{
34
  "title": "Update the deployment guide",
35
  "body": "Explains the new receipt fields.",
36
  "head_repository": "octocat/openagents-fork",
37
  "head": "docs/deployment-guide",
38
  "base": "main"
39
}
40
```
41
42
The source repository and both refs must exist on the forge. The caller must be able to write to the source repository and participate in issues on the target repository. OpenAgents rejects a second open pull request with the same source repository, source ref, target repository, and base ref.
43
44
Merging remains a separate publication operation. Creating or closing a pull request does not update a forge ref.
45
46
## Browser views
47
48
Open `/{owner}/{repo}/pulls` to list a repository's pull requests. Select a pull request to open `/{owner}/{repo}/pulls/{pull_number}` and review its source and target refs, description, and state.
lib/openagents/pull_requests.ex added +120

@@ -0,0 +1,120 @@

1
defmodule OpenAgents.PullRequests do
2
  @moduledoc "Repository-scoped pull requests backed by issues."
3
  import Ecto.Query, warn: false
4
5
  alias OpenAgents.Accounts.User
6
  alias OpenAgents.Forge.Browse
7
  alias OpenAgents.Issues
8
  alias OpenAgents.PullRequests.PullRequest
9
  alias OpenAgents.Repo
10
  alias OpenAgents.Repositories
11
  alias OpenAgents.Repositories.Repository
12
13
  def list(%Repository{id: id}) do
14
    Repo.all(
15
      from pr in PullRequest,
16
        where: pr.repository_id == ^id,
17
        preload: [:issue, :head_repository],
18
        order_by: [desc: pr.inserted_at]
19
    )
20
  end
21
22
  def get_by_number!(%Repository{id: id}, number) do
23
    Repo.one!(
24
      from pr in PullRequest,
25
        join: issue in assoc(pr, :issue),
26
        where: pr.repository_id == ^id and issue.number == ^number,
27
        preload: [:issue, :head_repository]
28
    )
29
  end
30
31
  def create(%Repository{pull_requests_enabled: false}, _attrs, _actor),
32
    do: {:error, :pull_requests_disabled}
33
34
  def create(%Repository{} = target, attrs, %User{} = actor) do
35
    Repo.transaction(fn ->
36
      with true <- Repositories.issue_participant?(target, actor),
37
           {:ok, source} <- source_repository(attrs, actor),
38
           {:ok, head_ref} <- required(attrs, "head"),
39
           {:ok, base_ref} <- optional(attrs, "base", target.default_branch),
40
           {:ok, head_sha} <- resolve(source, head_ref),
41
           {:ok, base_sha} <- resolve(target, base_ref),
42
           {:ok, issue} <- Issues.create_issue(target, attrs, actor),
43
           {:ok, pr} <- insert(target, source, issue, head_ref, head_sha, base_ref, base_sha) do
44
        Repo.preload(pr, [:issue, :head_repository])
45
      else
46
        false -> Repo.rollback(:forbidden)
47
        {:error, reason} -> Repo.rollback(reason)
48
      end
49
    end)
50
  end
51
52
  def update(%PullRequest{} = pr, attrs, %User{} = actor) do
53
    pr = Repo.preload(pr, [:issue, :repository])
54
    role = Repositories.membership_role(pr.repository, actor)
55
56
    if pr.issue.author_user_id == actor.id or role in ~w(owner maintainer) do
57
      Repo.transaction(fn ->
58
        with {:ok, issue} <-
59
               Issues.update_issue(
60
                 pr.issue,
61
                 Map.take(attrs, ["title", "body", "state"]),
62
                 actor
63
               ),
64
             {:ok, updated} <-
65
               pr |> PullRequest.changeset(%{state: issue.state}) |> Repo.update() do
66
          %{updated | issue: issue}
67
        else
68
          {:error, reason} -> Repo.rollback(reason)
69
        end
70
      end)
71
    else
72
      {:error, :forbidden}
73
    end
74
  end
75
76
  defp source_repository(attrs, actor) do
77
    case Map.get(attrs, "head_repository") do
78
      value when is_binary(value) ->
79
        case String.split(value, "/", parts: 2) do
80
          [owner, name] -> {:ok, Repositories.get_writable_by_path!(owner, name, actor)}
81
          _ -> {:error, :invalid_head_repository}
82
        end
83
84
      _ ->
85
        {:error, :invalid_head_repository}
86
    end
87
  rescue
88
    Ecto.NoResultsError -> {:error, :invalid_head_repository}
89
  end
90
91
  defp resolve(repository, ref) do
92
    case Browse.resolve_commit(repository, ref) do
93
      {:ok, sha} -> {:ok, sha}
94
      _ -> {:error, :invalid_ref}
95
    end
96
  end
97
98
  defp required(attrs, key), do: optional(attrs, key, nil)
99
100
  defp optional(attrs, key, default) do
101
    case Map.get(attrs, key, default) do
102
      value when is_binary(value) and value != "" -> {:ok, value}
103
      _ -> {:error, :invalid_ref}
104
    end
105
  end
106
107
  defp insert(target, source, issue, head_ref, head_sha, base_ref, base_sha) do
108
    %PullRequest{}
109
    |> PullRequest.changeset(%{
110
      repository_id: target.id,
111
      issue_id: issue.id,
112
      head_repository_id: source.id,
113
      head_ref: head_ref,
114
      head_sha: head_sha,
115
      base_ref: base_ref,
116
      base_sha: base_sha
117
    })
118
    |> Repo.insert()
119
  end
120
end
lib/openagents/pull_requests/pull_request.ex added +70

@@ -0,0 +1,70 @@

1
defmodule OpenAgents.PullRequests.PullRequest do
2
  @moduledoc false
3
  use Ecto.Schema
4
  import Ecto.Changeset
5
6
  @primary_key {:id, :binary_id, autogenerate: true}
7
  @foreign_key_type :binary_id
8
9
  schema "pull_requests" do
10
    belongs_to :repository, OpenAgents.Repositories.Repository
11
    belongs_to :issue, OpenAgents.Issues.Issue, type: :id
12
    belongs_to :head_repository, OpenAgents.Repositories.Repository
13
    field :head_ref, :string
14
    field :head_sha, :string
15
    field :base_ref, :string
16
    field :base_sha, :string
17
    field :state, :string, default: "open"
18
    field :merged_at, :utc_datetime_usec
19
    belongs_to :merged_by_user, OpenAgents.Accounts.User
20
    field :merge_commit_sha, :string
21
    timestamps(type: :utc_datetime_usec)
22
  end
23
24
  def changeset(pull_request, attrs) do
25
    pull_request
26
    |> cast(attrs, [
27
      :head_ref,
28
      :head_sha,
29
      :base_ref,
30
      :base_sha,
31
      :state
32
    ])
33
    |> put_programmatic_change(attrs, :repository_id)
34
    |> put_programmatic_change(attrs, :issue_id)
35
    |> put_programmatic_change(attrs, :head_repository_id)
36
    |> validate_required([
37
      :repository_id,
38
      :issue_id,
39
      :head_repository_id,
40
      :head_ref,
41
      :head_sha,
42
      :base_ref,
43
      :base_sha
44
    ])
45
    |> validate_length(:head_ref, min: 1, max: 255)
46
    |> validate_length(:base_ref, min: 1, max: 255)
47
    |> validate_inclusion(:state, ~w(open closed))
48
    |> unique_constraint(:issue_id)
49
    |> unique_constraint([:repository_id, :head_repository_id, :head_ref, :base_ref],
50
      name: :pull_requests_one_open_head_base_index
51
    )
52
    |> foreign_key_constraint(:repository_id)
53
    |> foreign_key_constraint(:issue_id)
54
    |> foreign_key_constraint(:head_repository_id)
55
  end
56
57
  defp put_programmatic_change(changeset, attrs, field) do
58
    case Map.fetch(attrs, field) do
59
      {:ok, value} -> put_change(changeset, field, value)
60
      :error -> put_string_key_change(changeset, attrs, field)
61
    end
62
  end
63
64
  defp put_string_key_change(changeset, attrs, field) do
65
    case Map.fetch(attrs, Atom.to_string(field)) do
66
      {:ok, value} -> put_change(changeset, field, value)
67
      :error -> changeset
68
    end
69
  end
70
end
lib/openagents/repositories.ex modified +19

@@ -116,6 +116,25 @@ defmodule OpenAgents.Repositories do

116 116
    )
117 117
  end
118 118
119
  @doc "Updates whether an owner allows new pull requests for a repository."
120
  def update_pull_request_setting(owner, name, %User{} = actor, enabled)
121
      when is_boolean(enabled) do
122
    repository = get_visible_by_path!(owner, name, actor)
123
124
    if owner?(repository, actor) do
125
      repository
126
      |> Repository.changeset(%{pull_requests_enabled: enabled})
127
      |> Repo.update()
128
    else
129
      {:error, :forbidden}
130
    end
131
  rescue
132
    Ecto.NoResultsError -> {:error, :not_found}
133
  end
134
135
  def update_pull_request_setting(_owner, _name, _actor, _enabled),
136
    do: {:error, :invalid_pull_request_setting}
137
119 138
  def create_repository(attrs) do
120 139
    owner = fetch_attr!(attrs, :owner)
121 140
lib/openagents/repositories/repository.ex modified +2

@@ -16,6 +16,7 @@ defmodule OpenAgents.Repositories.Repository do

16 16
    field :visibility, :string, default: "private"
17 17
    field :default_branch, :string, default: "main"
18 18
    field :description, :string
19
    field :pull_requests_enabled, :boolean, default: true
19 20
    field :lifecycle_state, :string, default: "provisioning"
20 21
    field :provisioning_kind, :string, default: "empty"
21 22
    field :provision_error_code, :string

@@ -40,6 +41,7 @@ defmodule OpenAgents.Repositories.Repository do

40 41
      :visibility,
41 42
      :default_branch,
42 43
      :description,
44
      :pull_requests_enabled,
43 45
      :lifecycle_state,
44 46
      :provisioning_kind,
45 47
      :provision_error_code,
lib/openagents_web/api_route_authority.ex modified +5

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

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

@@ -76,6 +78,8 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

76 78
      "get /api/v3/user" => :required_bearer,
77 79
      "get /api/v3/user/repos" => :required_bearer,
78 80
      "patch /api/v3/repos/:owner/:repo/issues/:issue_number" => :required_bearer,
81
      "patch /api/v3/repos/:owner/:repo" => :required_bearer,
82
      "patch /api/v3/repos/:owner/:repo/pulls/:pull_number" => :required_bearer,
79 83
      "patch /api/v3/repos/:owner/:repo/issues/comments/:id" => :required_bearer,
80 84
      "patch /api/v3/repos/:owner/:repo/labels/:name" => :required_bearer,
81 85
      "patch /api/v3/repos/:owner/:repo/milestones/:milestone_number" => :required_bearer,

@@ -84,6 +88,7 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

84 88
      "post /api/v3/orgs/:org/repos" => :required_bearer,
85 89
      "post /api/v3/orgs/:org/repos/imports" => :required_bearer,
86 90
      "post /api/v3/repos/:owner/:repo/issues" => :required_bearer,
91
      "post /api/v3/repos/:owner/:repo/pulls" => :required_bearer,
87 92
      # pipe_through :forge_write_api — forum writes and identity claims.
88 93
      "post /api/v3/forum/topics" => :required_bearer,
89 94
      "post /api/v3/forum/topics/:topic_id/posts" => :required_bearer,
lib/openagents_web/controllers/pull_request_controller.ex added +86

@@ -0,0 +1,86 @@

1
defmodule OpenAgentsWeb.PullRequestController do
2
  use OpenAgentsWeb, :controller
3
4
  alias OpenAgents.PullRequests
5
  alias OpenAgents.Repositories
6
7
  def index(conn, %{"owner" => owner, "repo" => repo}) do
8
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
9
    render(conn, :index, pull_requests: PullRequests.list(repository), owner: owner, repo: repo)
10
  rescue
11
    Ecto.NoResultsError -> not_found(conn)
12
  end
13
14
  def show(conn, %{"owner" => owner, "repo" => repo, "pull_number" => number}) do
15
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
16
17
    pull_request =
18
      PullRequests.get_by_number!(
19
        repository,
20
        OpenAgentsWeb.ControllerHelpers.integer_param!(number)
21
      )
22
23
    render(conn, :show, pull_request: pull_request, owner: owner, repo: repo)
24
  rescue
25
    Ecto.NoResultsError -> not_found(conn)
26
  end
27
28
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
29
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns.current_user)
30
31
    case PullRequests.create(repository, params, conn.assigns.current_user) do
32
      {:ok, pull_request} ->
33
        conn
34
        |> put_status(:created)
35
        |> render(:show, pull_request: pull_request, owner: owner, repo: repo)
36
37
      {:error, :pull_requests_disabled} ->
38
        error(conn, :conflict, "Pull requests are disabled for this repository.")
39
40
      {:error, :forbidden} ->
41
        error(conn, :forbidden, "You cannot open a pull request for this repository.")
42
43
      {:error, reason} when reason in [:invalid_ref, :invalid_head_repository] ->
44
        error(conn, :unprocessable_entity, "The head repository or ref is invalid.")
45
46
      {:error, %Ecto.Changeset{} = changeset} ->
47
        conn
48
        |> put_status(:unprocessable_entity)
49
        |> json(%{
50
          errors: Ecto.Changeset.traverse_errors(changeset, fn {message, _} -> message end)
51
        })
52
    end
53
  rescue
54
    Ecto.NoResultsError -> not_found(conn)
55
  end
56
57
  def update(conn, %{"owner" => owner, "repo" => repo, "pull_number" => number} = params) do
58
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns.current_user)
59
60
    pull_request =
61
      PullRequests.get_by_number!(
62
        repository,
63
        OpenAgentsWeb.ControllerHelpers.integer_param!(number)
64
      )
65
66
    case PullRequests.update(pull_request, params, conn.assigns.current_user) do
67
      {:ok, updated} ->
68
        render(conn, :show, pull_request: updated, owner: owner, repo: repo)
69
70
      {:error, :forbidden} ->
71
        error(conn, :forbidden, "You cannot update this pull request.")
72
73
      {:error, %Ecto.Changeset{} = changeset} ->
74
        conn
75
        |> put_status(:unprocessable_entity)
76
        |> json(%{
77
          errors: Ecto.Changeset.traverse_errors(changeset, fn {message, _} -> message end)
78
        })
79
    end
80
  rescue
81
    Ecto.NoResultsError -> not_found(conn)
82
  end
83
84
  defp not_found(conn), do: error(conn, :not_found, "Not Found")
85
  defp error(conn, status, message), do: conn |> put_status(status) |> json(%{message: message})
86
end
lib/openagents_web/controllers/pull_request_json.ex added +35

@@ -0,0 +1,35 @@

1
defmodule OpenAgentsWeb.PullRequestJSON do
2
  @moduledoc "Renders pull requests in a GitHub-compatible shape."
3
4
  def render("index.json", %{pull_requests: pull_requests} = assigns),
5
    do: Enum.map(pull_requests, &pull_request(&1, assigns))
6
7
  def render("show.json", %{pull_request: pull_request} = assigns),
8
    do: pull_request(pull_request, assigns)
9
10
  defp pull_request(pr, assigns) do
11
    base_url = String.trim_trailing(OpenAgentsWeb.Endpoint.url(), "/")
12
    owner = assigns.owner
13
    repo = assigns.repo
14
15
    %{
16
      id: pr.id,
17
      number: pr.issue.number,
18
      title: pr.issue.title,
19
      body: pr.issue.body,
20
      state: pr.issue.state,
21
      user: pr.issue.user,
22
      merged: not is_nil(pr.merged_at),
23
      head: %{
24
        ref: pr.head_ref,
25
        sha: pr.head_sha,
26
        repo: %{full_name: "#{pr.head_repository.owner}/#{pr.head_repository.name}"}
27
      },
28
      base: %{ref: pr.base_ref, sha: pr.base_sha, repo: %{full_name: "#{owner}/#{repo}"}},
29
      created_at: pr.inserted_at,
30
      updated_at: pr.updated_at,
31
      html_url: "#{base_url}/#{owner}/#{repo}/pulls/#{pr.issue.number}",
32
      url: "#{base_url}/api/v3/repos/#{owner}/#{repo}/pulls/#{pr.issue.number}"
33
    }
34
  end
35
end
lib/openagents_web/controllers/repository_controller.ex modified +33

@@ -67,6 +67,27 @@ defmodule OpenAgentsWeb.RepositoryController do

67 67
    Ecto.NoResultsError -> render_error(conn, :not_found)
68 68
  end
69 69
70
  def update(conn, %{
71
        "owner" => owner,
72
        "repo" => name,
73
        "pull_requests_enabled" => enabled
74
      }) do
75
    case Repositories.update_pull_request_setting(
76
           owner,
77
           name,
78
           conn.assigns.current_user,
79
           enabled
80
         ) do
81
      {:ok, repository} ->
82
        json(conn, projection(repository, conn.assigns.current_user, base_url(conn)))
83
84
      {:error, reason} ->
85
        render_error(conn, reason)
86
    end
87
  end
88
89
  def update(conn, _params), do: render_error(conn, :invalid_pull_request_setting)
90
70 91
  def delete(conn, %{"owner" => owner, "repo" => name}) do
71 92
    case Repositories.delete_owned_repository(owner, name, conn.assigns.current_user,
72 93
           surface: "api"

@@ -216,6 +237,18 @@ defmodule OpenAgentsWeb.RepositoryController do

216 237
  defp render_error(conn, :not_found),
217 238
    do: error(conn, :not_found, "not_found", "Repository not found")
218 239
240
  defp render_error(conn, :forbidden),
241
    do: error(conn, :forbidden, "forbidden", "Only a repository owner can update settings")
242
243
  defp render_error(conn, :invalid_pull_request_setting),
244
    do:
245
      error(
246
        conn,
247
        :unprocessable_entity,
248
        "invalid_pull_request_setting",
249
        "pull_requests_enabled must be a boolean"
250
      )
251
219 252
  defp render_error(conn, :invalid_pagination),
220 253
    do: error(conn, :unprocessable_entity, "invalid_pagination", "Pagination input is invalid")
221 254
lib/openagents_web/controllers/repository_json.ex modified +1

@@ -19,6 +19,7 @@ defmodule OpenAgentsWeb.RepositoryJSON do

19 19
      "visibility" => repository.visibility,
20 20
      "description" => repository.description,
21 21
      "default_branch" => repository.default_branch,
22
      "pull_requests_enabled" => repository.pull_requests_enabled,
22 23
      "lifecycle_state" => repository.lifecycle_state,
23 24
      "provision_error_code" => repository.provision_error_code,
24 25
      "clone_url" => base_url <> "/#{owner}/#{repository.name}.git",
lib/openagents_web/live/code_repo_live.ex modified +58

@@ -63,6 +63,10 @@ defmodule OpenAgentsWeb.CodeRepoLive do

63 63
     |> assign(:og, OG.meta(OG.repo_card_for(repository)))
64 64
     |> assign(:clone_url, RepositoryAccess.clone_url(repository))
65 65
     |> assign(:delete_allowed?, delete_allowed?)
66
     |> assign(
67
       :pull_request_settings_form,
68
       pull_request_settings_form(repository.pull_requests_enabled)
69
     )
66 70
     |> assign(:delete_error, nil)
67 71
     |> assign(
68 72
       :delete_form,

@@ -132,6 +136,32 @@ defmodule OpenAgentsWeb.CodeRepoLive do

132 136
    end
133 137
  end
134 138
139
  def handle_event(
140
        "update_pull_request_setting",
141
        %{"repository" => %{"pull_requests_enabled" => enabled}},
142
        socket
143
      ) do
144
    enabled = enabled == "true"
145
146
    case Repositories.update_pull_request_setting(
147
           socket.assigns.owner,
148
           socket.assigns.repo,
149
           socket.assigns.current_user,
150
           enabled
151
         ) do
152
      {:ok, repository} ->
153
        {:noreply,
154
         socket
155
         |> assign(:repository, repository)
156
         |> assign(:pull_request_settings_form, pull_request_settings_form(enabled))
157
         |> put_flash(:info, "Pull request settings updated.")}
158
159
      {:error, _reason} ->
160
        {:noreply,
161
         put_flash(socket, :error, "OpenAgents could not update pull request settings.")}
162
    end
163
  end
164
135 165
  defp short(sha), do: String.slice(sha, 0, 12)
136 166
137 167
  # The tab carries a count only when there is something to count, the way the

@@ -163,6 +193,10 @@ defmodule OpenAgentsWeb.CodeRepoLive do

163 193
164 194
  defp initial(_name), do: "?"
165 195
196
  defp pull_request_settings_form(enabled) do
197
    to_form(%{"pull_requests_enabled" => enabled}, as: :repository)
198
  end
199
166 200
  @impl true
167 201
  def render(assigns) do
168 202
    ~H"""

@@ -300,6 +334,30 @@ defmodule OpenAgentsWeb.CodeRepoLive do

300 334
            </ol>
301 335
          </.card>
302 336
337
          <.card :if={@delete_allowed?} id="repository-pull-request-settings">
338
            <header>
339
              <h2>Pull requests</h2>
340
            </header>
341
            <p class="text-sm text-muted-foreground">
342
              Allow contributors to propose changes from another hosted repository and branch.
343
            </p>
344
            <.form
345
              for={@pull_request_settings_form}
346
              id="repository-pull-request-settings-form"
347
              phx-submit="update_pull_request_setting"
348
              class="space-y-3"
349
            >
350
              <.input
351
                field={@pull_request_settings_form[:pull_requests_enabled]}
352
                type="checkbox"
353
                label="Allow new pull requests"
354
              />
355
              <.button id="repository-pull-request-settings-submit" type="submit">
356
                Save pull request settings
357
              </.button>
358
            </.form>
359
          </.card>
360
303 361
          <.card
304 362
            :if={@delete_allowed?}
305 363
            id="repository-danger-zone"
lib/openagents_web/live/pull_request_index_live.ex added +89

@@ -0,0 +1,89 @@

1
defmodule OpenAgentsWeb.PullRequestIndexLive do
2
  @moduledoc "Lists pull requests for a repository."
3
  use OpenAgentsWeb, :live_view
4
5
  alias OpenAgents.PullRequests
6
  alias OpenAgents.Repositories
7
8
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
9
    repository = visible_repository!(owner, repo, socket.assigns.current_user)
10
    pull_requests = PullRequests.list(repository)
11
12
    {:ok,
13
     socket
14
     |> assign(:current_scope, socket.assigns[:current_scope])
15
     |> assign(:owner, owner)
16
     |> assign(:repo, repo)
17
     |> assign(:repository, repository)
18
     |> assign(:pull_requests_empty?, pull_requests == [])
19
     |> stream(:pull_requests, pull_requests)}
20
  end
21
22
  def render(assigns) do
23
    ~H"""
24
    <Layouts.app
25
      flash={@flash}
26
      sidebar_sections={assigns[:sidebar_sections]}
27
      current_scope={@current_scope}
28
      title="Pull requests"
29
      full_width
30
    >
31
      <main id="pull-request-index" class="app-shell code-shell">
32
        <.repo_view
33
          owner={@owner}
34
          repo={@repo}
35
          visibility={if @repository.visibility == "public", do: :public, else: :private}
36
        >
37
          <:tabs>
38
            <.repo_tabs>
39
              <:tab icon="code" navigate={~p"/#{@owner}/#{@repo}"}>Code</:tab>
40
              <:tab icon="empty-circle" navigate={~p"/#{@owner}/#{@repo}/issues"}>Issues</:tab>
41
              <:tab icon="pull-request-open" navigate={~p"/#{@owner}/#{@repo}/pulls"} current>
42
                Pull requests
43
              </:tab>
44
              <:tab icon="cube" navigate={~p"/#{@owner}/#{@repo}/projects"}>Projects</:tab>
45
            </.repo_tabs>
46
          </:tabs>
47
48
          <section class="mx-auto w-full max-w-5xl px-4 py-8">
49
            <div id="pull-requests" phx-update="stream" class="space-y-3">
50
              <.empty
51
                :if={@pull_requests_empty?}
52
                id="pull-requests-empty"
53
                title="No pull requests"
54
              >
55
                No pull requests are open or closed in this repository.
56
              </.empty>
57
              <.link
58
                :for={{id, pull_request} <- @streams.pull_requests}
59
                id={id}
60
                navigate={~p"/#{@owner}/#{@repo}/pulls/#{pull_request.issue.number}"}
61
                class="block rounded-xl border border-border bg-card p-4 transition-colors hover:bg-muted/50"
62
              >
63
                <div class="flex items-start justify-between gap-4">
64
                  <div>
65
                    <h2 class="font-semibold text-foreground">{pull_request.issue.title}</h2>
66
                    <p class="mt-1 text-sm text-muted-foreground">
67
                      #{pull_request.issue.number} from {pull_request.head_repository.owner}/{pull_request.head_repository.name}:{pull_request.head_ref} into {pull_request.base_ref}
68
                    </p>
69
                  </div>
70
                  <.badge variant={if(pull_request.state == "open", do: :success, else: :secondary)}>
71
                    {pull_request.state}
72
                  </.badge>
73
                </div>
74
              </.link>
75
            </div>
76
          </section>
77
        </.repo_view>
78
      </main>
79
    </Layouts.app>
80
    """
81
  end
82
83
  defp visible_repository!(owner, repo, user) do
84
    Repositories.get_visible_by_path!(owner, repo, user)
85
  rescue
86
    Ecto.NoResultsError ->
87
      raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
88
  end
89
end
lib/openagents_web/live/pull_request_show_live.ex added +73

@@ -0,0 +1,73 @@

1
defmodule OpenAgentsWeb.PullRequestShowLive do
2
  @moduledoc "Shows one repository pull request."
3
  use OpenAgentsWeb, :live_view
4
5
  alias OpenAgents.PullRequests
6
  alias OpenAgents.Repositories
7
8
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
9
    repository = visible_repository!(owner, repo, socket.assigns.current_user)
10
    pull_request = PullRequests.get_by_number!(repository, String.to_integer(number))
11
12
    {:ok,
13
     socket
14
     |> assign(:current_scope, socket.assigns[:current_scope])
15
     |> assign(:owner, owner)
16
     |> assign(:repo, repo)
17
     |> assign(:repository, repository)
18
     |> assign(:pull_request, pull_request)}
19
  end
20
21
  def render(assigns) do
22
    ~H"""
23
    <Layouts.app
24
      flash={@flash}
25
      sidebar_sections={assigns[:sidebar_sections]}
26
      current_scope={@current_scope}
27
      title={@pull_request.issue.title}
28
      full_width
29
    >
30
      <main id="pull-request-show" class="app-shell code-shell">
31
        <.repo_view
32
          owner={@owner}
33
          repo={@repo}
34
          visibility={if @repository.visibility == "public", do: :public, else: :private}
35
        >
36
          <:tabs>
37
            <.repo_tabs>
38
              <:tab icon="code" navigate={~p"/#{@owner}/#{@repo}"}>Code</:tab>
39
              <:tab icon="empty-circle" navigate={~p"/#{@owner}/#{@repo}/issues"}>Issues</:tab>
40
              <:tab icon="pull-request-open" navigate={~p"/#{@owner}/#{@repo}/pulls"} current>
41
                Pull requests
42
              </:tab>
43
              <:tab icon="cube" navigate={~p"/#{@owner}/#{@repo}/projects"}>Projects</:tab>
44
            </.repo_tabs>
45
          </:tabs>
46
47
          <article class="mx-auto w-full max-w-5xl px-4 py-8">
48
            <div class="flex flex-wrap items-center gap-3">
49
              <h1 class="text-2xl font-semibold text-foreground">{@pull_request.issue.title}</h1>
50
              <.badge variant={if(@pull_request.state == "open", do: :success, else: :secondary)}>
51
                {@pull_request.state}
52
              </.badge>
53
            </div>
54
            <p class="mt-3 text-sm text-muted-foreground">
55
              #{@pull_request.issue.number} proposes {@pull_request.head_repository.owner}/{@pull_request.head_repository.name}:{@pull_request.head_ref} into {@owner}/{@repo}:{@pull_request.base_ref}.
56
            </p>
57
            <div class="mt-8 rounded-xl border border-border bg-card p-6 whitespace-pre-wrap text-foreground">
58
              {@pull_request.issue.body || "No description provided."}
59
            </div>
60
          </article>
61
        </.repo_view>
62
      </main>
63
    </Layouts.app>
64
    """
65
  end
66
67
  defp visible_repository!(owner, repo, user) do
68
    Repositories.get_visible_by_path!(owner, repo, user)
69
  rescue
70
    Ecto.NoResultsError ->
71
      raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
72
  end
73
end
lib/openagents_web/route_authority.ex modified +2

@@ -60,6 +60,8 @@ 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/pulls",
64
    "/api/v3/repos/:owner/:repo/pulls/:pull_number",
63 65
    "/api/v3/repos/:owner/:repo/projectsV2",
64 66
    "/api/v3/repos/:owner/:repo/projectsV2/:project_number",
65 67
    "/api/v3/repos/:owner/:repo/projectsV2/:project_number/items",
lib/openagents_web/router.ex modified +7

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

189 189
190 190
    live_session :forge_issues,
191 191
      on_mount: [{OpenAgentsWeb.UserAuth, :mount_current_user}] do
192
      live "/:owner/:repo/pulls/:number", PullRequestShowLive, :show
193
      live "/:owner/:repo/pulls", PullRequestIndexLive, :index
192 194
      live "/:owner/:repo/issues/:number", IssueShowLive, :show
193 195
      live "/:owner/:repo/issues", IssueIndexLive, :index
194 196
    end

@@ -298,6 +300,8 @@ defmodule OpenAgentsWeb.Router do

298 300
    get "/repos/:owner/:repo", RepositoryController, :show
299 301
    get "/repos/:owner/:repo/issues", IssueController, :index
300 302
    get "/repos/:owner/:repo/issues/:issue_number", IssueController, :show
303
    get "/repos/:owner/:repo/pulls", PullRequestController, :index
304
    get "/repos/:owner/:repo/pulls/:pull_number", PullRequestController, :show
301 305
    get "/repos/:owner/:repo/projectsV2", ProjectController, :index
302 306
    get "/repos/:owner/:repo/projectsV2/:project_number", ProjectController, :show
303 307
    get "/repos/:owner/:repo/projectsV2/:project_number/items", ProjectController, :items

@@ -317,6 +321,7 @@ defmodule OpenAgentsWeb.Router do

317 321
    post "/user/repos", RepositoryController, :create_user
318 322
    post "/orgs/:org/repos", RepositoryController, :create_organization
319 323
    delete "/repos/:owner/:repo", RepositoryController, :delete
324
    patch "/repos/:owner/:repo", RepositoryController, :update
320 325
    post "/user/repos/imports", RepositoryImportController, :create_user
321 326
    post "/orgs/:org/repos/imports", RepositoryImportController, :create_organization
322 327
    get "/repository-imports/:id", RepositoryImportController, :show

@@ -324,6 +329,8 @@ defmodule OpenAgentsWeb.Router do

324 329
    post "/repos/:owner/:repo/issues", IssueController, :create
325 330
    put "/repos/:owner/:repo/issues/:issue_number", IssueController, :update
326 331
    patch "/repos/:owner/:repo/issues/:issue_number", IssueController, :update
332
    post "/repos/:owner/:repo/pulls", PullRequestController, :create
333
    patch "/repos/:owner/:repo/pulls/:pull_number", PullRequestController, :update
327 334
    post "/repos/:owner/:repo/issues/:issue_number/comments", CommentController, :create
328 335
    put "/repos/:owner/:repo/issues/comments/:id", CommentController, :update
329 336
    patch "/repos/:owner/:repo/issues/comments/:id", CommentController, :update
priv/repo/migrations/20260823013135_create_pull_requests.exs added +51

@@ -0,0 +1,51 @@

1
defmodule OpenAgents.Repo.Migrations.CreatePullRequests do
2
  use Ecto.Migration
3
4
  def change do
5
    alter table(:repositories) do
6
      add :pull_requests_enabled, :boolean, null: false, default: true
7
    end
8
9
    execute(
10
      "UPDATE repositories SET pull_requests_enabled = FALSE WHERE owner_key = 'openagentsinc' AND name_key = 'openagents.com'",
11
      "UPDATE repositories SET pull_requests_enabled = TRUE WHERE owner_key = 'openagentsinc' AND name_key = 'openagents.com'"
12
    )
13
14
    create table(:pull_requests, primary_key: false) do
15
      add :id, :binary_id, primary_key: true
16
17
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
18
        null: false
19
20
      add :issue_id, references(:issues, on_delete: :delete_all), null: false
21
22
      add :head_repository_id, references(:repositories, type: :binary_id, on_delete: :restrict),
23
        null: false
24
25
      add :head_ref, :string, null: false
26
      add :head_sha, :string, null: false
27
      add :base_ref, :string, null: false
28
      add :base_sha, :string, null: false
29
      add :state, :string, null: false, default: "open"
30
      add :merged_at, :utc_datetime_usec
31
      add :merged_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
32
      add :merge_commit_sha, :string
33
      timestamps(type: :utc_datetime_usec)
34
    end
35
36
    create unique_index(:pull_requests, [:issue_id])
37
    create index(:pull_requests, [:repository_id])
38
    create index(:pull_requests, [:head_repository_id])
39
40
    create unique_index(
41
             :pull_requests,
42
             [:repository_id, :head_repository_id, :head_ref, :base_ref],
43
             where: "state = 'open'",
44
             name: :pull_requests_one_open_head_base_index
45
           )
46
47
    create constraint(:pull_requests, :pull_requests_state_check,
48
             check: "state IN ('open', 'closed')"
49
           )
50
  end
51
end
test/openagents/pull_requests_test.exs added +81

@@ -0,0 +1,81 @@

1
defmodule OpenAgents.PullRequestsTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.PullRequests
5
  alias OpenAgents.PullRequests.PullRequest
6
  alias OpenAgents.Repo
7
  alias OpenAgents.Repositories
8
9
  import OpenAgents.AccountsFixtures
10
  import OpenAgents.IssuesFixtures
11
12
  test "new repositories allow pull requests by default" do
13
    assert repository_fixture().pull_requests_enabled
14
  end
15
16
  test "the canonical repository has pull requests disabled" do
17
    repository = Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
18
    refute repository.pull_requests_enabled
19
  end
20
21
  test "a disabled repository refuses a new pull request before creating its issue" do
22
    user = repository_user_fixture("pull-request-author")
23
    repository = repository_fixture(%{pull_requests_enabled: false})
24
25
    assert {:error, :pull_requests_disabled} = PullRequests.create(repository, %{}, user)
26
  end
27
28
  test "only an owner can update the repository pull request setting" do
29
    owner = repository_user_fixture("pull-request-owner")
30
    maintainer = repository_user_fixture("pull-request-maintainer")
31
    repository = repository_with_member_fixture(owner)
32
    {:ok, _membership} = Repositories.add_member(repository, maintainer, "maintainer")
33
34
    assert {:ok, updated} =
35
             Repositories.update_pull_request_setting(
36
               repository.owner,
37
               repository.name,
38
               owner,
39
               false
40
             )
41
42
    refute updated.pull_requests_enabled
43
44
    assert {:error, :forbidden} =
45
             Repositories.update_pull_request_setting(
46
               repository.owner,
47
               repository.name,
48
               maintainer,
49
               true
50
             )
51
  end
52
53
  test "an open head and base pair can have only one pull request" do
54
    target = repository_fixture()
55
    source = repository_fixture()
56
    first_issue = issue_fixture(target, %{title: "First pull request"})
57
    second_issue = issue_fixture(target, %{title: "Second pull request"})
58
59
    attrs = %{
60
      repository_id: target.id,
61
      head_repository_id: source.id,
62
      head_ref: "feature",
63
      head_sha: String.duplicate("a", 40),
64
      base_ref: "main",
65
      base_sha: String.duplicate("b", 40),
66
      state: "open"
67
    }
68
69
    assert {:ok, _pull_request} =
70
             %PullRequest{}
71
             |> PullRequest.changeset(Map.put(attrs, :issue_id, first_issue.id))
72
             |> Repo.insert()
73
74
    assert {:error, changeset} =
75
             %PullRequest{}
76
             |> PullRequest.changeset(Map.put(attrs, :issue_id, second_issue.id))
77
             |> Repo.insert()
78
79
    assert "has already been taken" in errors_on(changeset).repository_id
80
  end
81
end
test/openagents_web/controllers/pull_request_controller_test.exs added +178

@@ -0,0 +1,178 @@

1
defmodule OpenAgentsWeb.PullRequestControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  alias OpenAgents.Forge.Repos
5
  alias OpenAgents.Repositories
6
7
  setup do
8
    base =
9
      Path.join(
10
        System.tmp_dir!(),
11
        "pull-request-controller-#{System.unique_integer([:positive])}"
12
      )
13
14
    previous_data = Application.get_env(:openagents, :forge_data_dir)
15
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
16
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
17
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
18
19
    on_exit(fn ->
20
      restore_env(:forge_data_dir, previous_data)
21
      restore_env(:forge_wal_dir, previous_wal)
22
      File.rm_rf(base)
23
    end)
24
25
    :ok
26
  end
27
28
  test "POST, GET, and PATCH /api/v3/repos/:owner/:repo/pulls manage a pull request", %{
29
    conn: conn
30
  } do
31
    target = repository_fixture()
32
    source = repository_fixture()
33
    seed_repository(target)
34
    seed_repository(source)
35
36
    user = github_user("api-token-pull-request-lifecycle")
37
    {:ok, _membership} = Repositories.add_member(source, user, "owner")
38
    conn = put_forge_api_token(conn, "pull-request-lifecycle", target)
39
40
    create_conn =
41
      post(conn, "/api/v3/repos/#{target.owner}/#{target.name}/pulls", %{
42
        title: "Add pull request support",
43
        body: "Issue-backed pull request",
44
        head_repository: "#{source.owner}/#{source.name}",
45
        head: "main",
46
        base: "main"
47
      })
48
49
    assert %{
50
             "number" => number,
51
             "title" => "Add pull request support",
52
             "state" => "open",
53
             "head" => %{"ref" => "main"},
54
             "base" => %{"ref" => "main"}
55
           } = json_response(create_conn, 201)
56
57
    show_conn = get(conn, "/api/v3/repos/#{target.owner}/#{target.name}/pulls/#{number}")
58
59
    assert %{"number" => ^number, "title" => "Add pull request support"} =
60
             json_response(show_conn, 200)
61
62
    update_conn =
63
      patch(conn, "/api/v3/repos/#{target.owner}/#{target.name}/pulls/#{number}", %{
64
        title: "Ship pull request support",
65
        state: "closed"
66
      })
67
68
    assert %{
69
             "number" => ^number,
70
             "title" => "Ship pull request support",
71
             "state" => "closed"
72
           } = json_response(update_conn, 200)
73
  end
74
75
  test "POST /api/v3/repos/:owner/:repo/pulls rejects a disabled repository", %{conn: conn} do
76
    target = repository_fixture(%{pull_requests_enabled: false})
77
    conn = put_forge_api_token(conn, "pull-request-disabled", target)
78
79
    conn =
80
      post(conn, "/api/v3/repos/#{target.owner}/#{target.name}/pulls", %{
81
        title: "Cannot open",
82
        head_repository: "#{target.owner}/#{target.name}",
83
        head: "main"
84
      })
85
86
    assert %{"message" => "Pull requests are disabled for this repository."} =
87
             json_response(conn, 409)
88
  end
89
90
  test "POST /api/v3/repos/:owner/:repo/pulls requires a bearer token", %{conn: conn} do
91
    repository = repository_fixture()
92
93
    conn =
94
      post(conn, "/api/v3/repos/#{repository.owner}/#{repository.name}/pulls", %{
95
        title: "Unauthenticated pull request"
96
      })
97
98
    assert %{"error" => "invalid_api_token"} = json_response(conn, 401)
99
  end
100
101
  test "GET /api/v3/repos/:owner/:repo/pulls lists pull requests", %{conn: conn} do
102
    repository = repository_fixture()
103
104
    conn =
105
      get(conn, "/api/v3/repos/#{repository.owner}/#{repository.name}/pulls")
106
107
    assert [] == json_response(conn, 200)
108
  end
109
110
  test "PATCH /api/v3/repos/:owner/:repo updates the owner pull request setting", %{conn: conn} do
111
    repository = repository_fixture()
112
    conn = put_forge_api_token(conn, "pull-request-settings", repository)
113
114
    conn =
115
      patch(conn, "/api/v3/repos/#{repository.owner}/#{repository.name}", %{
116
        pull_requests_enabled: false
117
      })
118
119
    assert %{"pull_requests_enabled" => false} = json_response(conn, 200)
120
  end
121
122
  test "PATCH /api/v3/repos/:owner/:repo requires an owner", %{conn: conn} do
123
    repository = repository_fixture()
124
    conn = put_forge_api_token(conn, "pull-request-nonowner")
125
126
    conn =
127
      patch(conn, "/api/v3/repos/#{repository.owner}/#{repository.name}", %{
128
        pull_requests_enabled: false
129
      })
130
131
    assert %{"code" => "forbidden"} = json_response(conn, 403)
132
  end
133
134
  defp seed_repository(repository) do
135
    path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
136
    blob = git!(path, ["hash-object", "-w", "--stdin"], "fixture\n")
137
    tree = git!(path, ["mktree"], "100644 blob #{blob}\tREADME.md\n")
138
139
    commit =
140
      git!(path, ["commit-tree", tree, "-m", "Seed repository"], "",
141
        env: [
142
          {"GIT_AUTHOR_NAME", "Test Author"},
143
          {"GIT_AUTHOR_EMAIL", "author@example.test"},
144
          {"GIT_COMMITTER_NAME", "Test Author"},
145
          {"GIT_COMMITTER_EMAIL", "author@example.test"}
146
        ]
147
      )
148
149
    {_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", commit])
150
    :ok
151
  end
152
153
  defp git!(git_dir, args, input, options \\ []) do
154
    input_path =
155
      Path.join(
156
        System.tmp_dir!(),
157
        "pull-request-controller-input-#{System.unique_integer([:positive])}"
158
      )
159
160
    File.write!(input_path, input)
161
162
    try do
163
      {output, 0} =
164
        System.cmd(
165
          "sh",
166
          ["-c", ~s(exec git --git-dir "$GIT_DIR" "$@" < "$INPUT"), "sh"] ++ args,
167
          env: [{"GIT_DIR", git_dir}, {"INPUT", input_path}] ++ Keyword.get(options, :env, [])
168
        )
169
170
      String.trim(output)
171
    after
172
      File.rm(input_path)
173
    end
174
  end
175
176
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
177
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
178
end
test/openagents_web/live/code_live_test.exs modified +50

@@ -422,6 +422,56 @@ defmodule OpenAgentsWeb.CodeLiveTest do

422 422
      refute has_element?(view, "#repository-danger-zone")
423 423
      refute has_element?(view, "#repository-delete-form")
424 424
    end
425
426
    test "an owner can update the pull request setting", %{conn: conn} do
427
      owner = github_user("pull-request-setting-owner", "pull-request-setting-owner")
428
429
      assert {:ok, _repository, :created} =
430
               OpenAgents.Repositories.create_user_repository(
431
                 owner,
432
                 %{name: "pull-request-setting", visibility: "private"},
433
                 "pull-request-setting-ui"
434
               )
435
436
      owner_conn = Plug.Test.init_test_session(conn, %{"user_id" => owner.id})
437
      {:ok, view, _html} = live(owner_conn, "/pull-request-setting-owner/pull-request-setting")
438
439
      assert has_element?(view, "#repository-pull-request-settings")
440
      assert has_element?(view, "#repository-pull-request-settings-form")
441
442
      view
443
      |> form("#repository-pull-request-settings-form", %{
444
        "repository" => %{"pull_requests_enabled" => "false"}
445
      })
446
      |> render_submit()
447
448
      repository =
449
        OpenAgents.Repositories.get_by_path!(
450
          "pull-request-setting-owner",
451
          "pull-request-setting"
452
        )
453
454
      refute repository.pull_requests_enabled
455
    end
456
457
    test "a non-owner never sees pull request settings", %{conn: conn} do
458
      owner = github_user("pull-request-control-owner", "pull-request-control-owner")
459
      viewer = github_user("pull-request-control-viewer", "pull-request-control-viewer")
460
461
      assert {:ok, repository, :created} =
462
               OpenAgents.Repositories.create_user_repository(
463
                 owner,
464
                 %{name: "pull-request-control", visibility: "private"},
465
                 "pull-request-setting-controls"
466
               )
467
468
      assert {:ok, _membership} = OpenAgents.Repositories.add_member(repository, viewer, "viewer")
469
      viewer_conn = Plug.Test.init_test_session(conn, %{"user_id" => viewer.id})
470
      {:ok, view, _html} = live(viewer_conn, "/pull-request-control-owner/pull-request-control")
471
472
      refute has_element?(view, "#repository-pull-request-settings")
473
      refute has_element?(view, "#repository-pull-request-settings-form")
474
    end
425 475
  end
426 476
427 477
  describe "/code/:repo/blob/:ref/*path" do
test/openagents_web/live/pull_request_live_test.exs added +39

@@ -0,0 +1,39 @@

1
defmodule OpenAgentsWeb.PullRequestLiveTest do
2
  use OpenAgentsWeb.ConnCase
3
  import Phoenix.LiveViewTest
4
  import OpenAgents.IssuesFixtures
5
6
  alias OpenAgents.PullRequests.PullRequest
7
  alias OpenAgents.Repo
8
9
  test "the pull request list links to a valid browser detail page", %{conn: conn} do
10
    target = repository_fixture()
11
    source = repository_fixture()
12
    issue = issue_fixture(target, %{title: "Add pull requests"})
13
14
    %PullRequest{}
15
    |> PullRequest.changeset(%{
16
      repository_id: target.id,
17
      issue_id: issue.id,
18
      head_repository_id: source.id,
19
      head_ref: "feature",
20
      head_sha: String.duplicate("a", 40),
21
      base_ref: "main",
22
      base_sha: String.duplicate("b", 40)
23
    })
24
    |> Repo.insert!()
25
26
    {:ok, index, _html} = live(conn, "/#{target.owner}/#{target.name}/pulls")
27
    assert has_element?(index, "#pull-request-index")
28
29
    assert has_element?(
30
             index,
31
             "a[href='/#{target.owner}/#{target.name}/pulls/#{issue.number}']"
32
           )
33
34
    {:ok, show, _html} =
35
      live(conn, "/#{target.owner}/#{target.name}/pulls/#{issue.number}")
36
37
    assert has_element?(show, "#pull-request-show")
38
  end
39
end

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