Enforce repository tenant boundaries

8db39fe3fe3c · Christopher David · · parent bb74a35618ba

Enforce repository tenant boundaries

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.md
  • modified docs/github-api-issues-projects-assessment.md
  • modified docs/issues-projects-ui-roadmap.md
  • modified lib/openagents/issues.ex
  • modified lib/openagents/issues/comment.ex
  • modified lib/openagents/issues/issue.ex
  • modified lib/openagents/labels.ex
  • modified lib/openagents/labels/label.ex
  • modified lib/openagents/milestones.ex
  • modified lib/openagents/milestones/milestone.ex
  • modified lib/openagents/project_items.ex
  • modified lib/openagents/project_items/project_item.ex
  • modified lib/openagents/projects.ex
  • modified lib/openagents/projects/project.ex
  • added lib/openagents/repositories.ex
  • added lib/openagents/repositories/membership.ex
  • added lib/openagents/repositories/repository.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/controllers/assignee_controller.ex
  • modified lib/openagents_web/controllers/auth_controller.ex
  • modified lib/openagents_web/controllers/comment_controller.ex
  • modified lib/openagents_web/controllers/issue_assignee_controller.ex
  • modified lib/openagents_web/controllers/issue_controller.ex
  • modified lib/openagents_web/controllers/issue_label_controller.ex
  • modified lib/openagents_web/controllers/label_controller.ex
  • modified lib/openagents_web/controllers/milestone_controller.ex
  • modified lib/openagents_web/controllers/project_controller.ex
  • modified lib/openagents_web/live/assignee_index_live.ex
  • modified lib/openagents_web/live/home_live.ex
  • modified lib/openagents_web/live/issue_index_live.ex
  • modified lib/openagents_web/live/issue_new_live.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • modified lib/openagents_web/live/label_index_live.ex
  • modified lib/openagents_web/live/milestone_index_live.ex
  • modified lib/openagents_web/live/project_index_live.ex
  • modified lib/openagents_web/live/project_show_live.ex
  • added priv/repo/migrations/20260820082100_add_repository_tenant_scoping.exs
  • modified test/openagents/forge/targets_test.exs
  • modified test/openagents/issues_test.exs
  • modified test/openagents/milestones_test.exs
  • modified test/openagents/project_items_test.exs
  • added test/openagents/repositories_test.exs
  • modified test/openagents_web/controllers/api_token_controller_test.exs
  • modified test/openagents_web/controllers/assignee_controller_test.exs
  • modified test/openagents_web/controllers/comment_controller_test.exs
  • modified test/openagents_web/controllers/issue_assignee_controller_test.exs
  • modified test/openagents_web/controllers/issue_controller_test.exs
  • modified test/openagents_web/controllers/issue_label_controller_test.exs
  • modified test/openagents_web/controllers/label_controller_test.exs
  • modified test/openagents_web/controllers/milestone_controller_test.exs
  • modified test/openagents_web/controllers/project_controller_test.exs
  • added test/openagents_web/controllers/repository_isolation_controller_test.exs
  • modified test/openagents_web/live/assignee_index_live_test.exs
  • modified test/openagents_web/live/issue_index_live_test.exs
  • modified test/openagents_web/live/issue_new_live_test.exs
  • modified test/openagents_web/live/issue_show_live_test.exs
  • modified test/openagents_web/live/label_index_live_test.exs
  • modified test/openagents_web/live/milestone_index_live_test.exs
  • modified test/openagents_web/live/project_index_live_test.exs
  • modified test/openagents_web/live/project_show_live_test.exs
  • modified test/support/conn_case.ex
  • added test/support/fixtures/accounts_fixtures.ex
  • modified test/support/fixtures/projects_fixtures.ex

Diff

63 files changed, +2215 -569

AGENTS.md modified +1 -1

@@ -499,7 +499,7 @@ Each endpoint test file follows this shape:

499 499
      use OpenAgentsWeb.ConnCase
500 500
501 501
      test "GET /api/v3/repos/:owner/:repo/issues lists open issues", %{conn: conn} do
502
        conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues")
502
        conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues")
503 503
        assert json_response(conn, 200)["issues"] != nil
504 504
      end
505 505
    end
docs/github-api-issues-projects-assessment.md modified +27 -13

@@ -2,7 +2,7 @@

2 2
3 3
Date: 2026-08-20
4 4
5
Status: Implemented subset with known compatibility and authorization gaps
5
Status: Repository-scoped subset implemented; bounded compatibility gaps remain
6 6
7 7
## Intent
8 8

@@ -44,30 +44,42 @@ GitHub Projects V2 creation workflow is not supplied by the assessed REST

44 44
surface. Project update/delete, item delete/read, field mutation, views,
45 45
ordering, draft items, and organization projects remain unimplemented.
46 46
47
## Known blockers
47
## Enforced authority contract
48 48
49
These are current measured behaviors, not hypothetical future concerns:
49
These are current measured behaviors:
50 50
51 51
- `/api/v3` public reads and authenticated writes use separate pipelines.
52 52
  Writes require an expiring digest-only `oa_pat_…` bearer with exact
53 53
  `forge:write` scope. An authenticated person creates and revokes credentials
54 54
  at `/settings/api-tokens`; plaintext is shown once.
55
- The route's owner/repository values are not backed by a canonical repository
56
  foreign key across the issue/project schema.
57
- Project show, item, update-item, and field actions do not consistently enforce
58
  the username in the route.
59
- The assignee read endpoints are placeholders while issue writes accept
60
  arbitrary login strings.
55
- Owner and repository path values resolve a canonical repository row. Public
56
  reads expose only repositories marked public; writes additionally require a
57
  writable membership for the PAT principal.
58
- Resource reads and mutations include repository ownership in their database
59
  query. Composite foreign keys reject cross-repository comments, label and
60
  assignee links, milestones, and project items.
61
- Issue and milestone numbers are repository-local. Project numbers are also
62
  repository-local for the repository-shaped LiveView surface.
63
- Project show, item, update-item, and field actions enforce the username in the
64
  route. The user-shaped Projects V2 subset is limited to the explicit initial
65
  repository because those compatibility URLs contain no repository segment.
66
- Assignee reads return active repository members with writable roles, and
67
  issue assignment accepts only those members.
68
69
## Remaining compatibility gaps
70
71
These are compatibility limits, not authorization fallbacks:
72
61 73
- Label rename does not implement GitHub's `new_name` behavior.
62 74
- Adding a nonexistent label, removing an absent label, and some label URL
63 75
  encoding cases differ from GitHub behavior.
64 76
- Error envelopes and pagination/link headers are a bounded local contract,
65 77
  not complete Octokit or `gh` parity.
66 78
67
Gate 6 must assign an explicit API principal and mutation policy. Gate 7 must
68
add repository entities, foreign keys, scoped uniqueness, one-query ownership
69
checks, and cross-repository isolation tests. Compatibility fixes follow those
70
authority boundaries rather than preceding them.
79
Gate 6 supplied the explicit API principal and mutation policy. Gate 7 supplied
80
repository entities, foreign keys, scoped uniqueness, ownership checks, and
81
cross-repository isolation tests. Further compatibility work must preserve
82
those authority boundaries.
71 83
72 84
## Evidence
73 85

@@ -79,3 +91,5 @@ authority boundaries rather than preceding them.

79 91
- `test/openagents_web/controllers/issue_assignee_controller_test.exs`
80 92
- `test/openagents_web/controllers/milestone_controller_test.exs`
81 93
- `test/openagents_web/controllers/project_controller_test.exs`
94
- `test/openagents_web/controllers/repository_isolation_controller_test.exs`
95
- `test/openagents/repositories_test.exs`
docs/issues-projects-ui-roadmap.md modified +38 -33

@@ -2,7 +2,7 @@

2 2
3 3
Date: 2026-08-20
4 4
5
Status: Core surfaces implemented; tenant and design-system hardening pending
5
Status: Core surfaces and repository boundary implemented; staging UX validation pending
6 6
7 7
## Current surface
8 8

@@ -39,46 +39,51 @@ and the coverage added to close them.

39 39
- Icons come from the vendored set through `OpenAgentsWeb.UI.icon/1`; icon-only
40 40
  controls have accessible names.
41 41
42
The current component inventory and transitional generated helpers are
43
documented in [docs/component-library.md](component-library.md).
44
45
## Blocking domain work
46
47
The route shape currently looks repository-scoped, but the durable issue and
48
project data model does not yet enforce that scope. Gate 7 must complete this
49
before the tracker is treated as a multi-repository forge:
50
51
1. Add a canonical repository entity.
52
2. Add repository foreign keys and scoped uniqueness to issues, labels,
53
   milestones, comments, assignees, and repository projects.
54
3. Resolve every resource through owner, repository, and resource identity in
55
   one authorized query.
56
4. Reject cross-repository identifiers in application code and PostgreSQL.
57
5. Replace the hardcoded assignee projection with repository membership and
58
   authorization.
59
6. Enforce project ownership instead of ignoring the username in Projects V2
60
   routes.
61
7. Separate public reads from authenticated browser and API writes.
62
8. Rehearse the backfill of existing rows into the initial repository.
63
64
Until this work passes cross-repository isolation tests, the URL is
65
presentation context rather than a proven tenancy boundary.
42
The current governed component inventory is documented in
43
[docs/component-library.md](component-library.md).
44
45
## Repository boundary
46
47
Gate 7 completed the durable repository boundary:
48
49
- `repositories` owns the stable repository ID, display and normalized path,
50
  visibility, and default branch. The explicit initial repository is
51
  `OpenAgentsInc/openagents.com`.
52
- Issues, labels, milestones, comments, projects, project items, issue-label
53
  links, and issue-assignee links carry repository ownership. Issue,
54
  milestone, and project numbers are unique within a repository.
55
- Public API reads resolve only public repositories. Authenticated LiveViews
56
  and PAT writes resolve a writable repository membership before loading or
57
  changing a resource.
58
- Assignees are active repository members with a writable role. Arbitrary
59
  login snapshots are no longer accepted.
60
- Projects V2 compatibility paths enforce the requested username in show,
61
  item, update-item, and field actions. The user-shaped API is deliberately
62
  bounded to the initial repository because its URL has no repository segment.
63
- Composite PostgreSQL foreign keys prevent a comment, label relation,
64
  assignee relation, milestone reference, or project item from crossing its
65
  repository.
66
- The reversible migration was run down/up, populated with pre-scope rows, run
67
  up, validated, then run down/up again. It reconstructed repository,
68
  membership, author, label, assignee, milestone, project-owner, and
69
  project-item relationships.
70
71
Context, controller, and LiveView tests cover same-number resources in multiple
72
repositories, private-repository hiding, nonmember write refusal, wrong-owner
73
Projects V2 paths, and database constraint failures.
66 74
67 75
## Remaining interface work
68 76
69
After Gate 7 establishes the domain boundary:
77
With the domain boundary established:
70 78
71
1. Reconcile every issue/project surface onto `OpenAgentsWeb.UI` and remove
72
   transitional generated component callers.
73
2. Extract repeated issue rows, comment threads, label selectors, milestone
79
1. Extract repeated issue rows, comment threads, label selectors, milestone
74 80
   progress, and project columns only where doing so improves behavior and test
75 81
   ownership.
76
3. Add bounded search, filtering, pagination, and useful empty/loading/error
82
2. Add bounded search, filtering, pagination, and useful empty/loading/error
77 83
   states.
78
4. Add PubSub invalidation and database rereads where concurrent users need
84
3. Add PubSub invalidation and database rereads where concurrent users need
79 85
   live updates.
80
5. Add repository authorization-aware actions and explicit refusal states.
81
6. Run accessibility, keyboard, responsive, compiled-CSS, and browser staging
86
4. Run accessibility, keyboard, responsive, compiled-CSS, and browser staging
82 87
   checks against the same candidate SHA.
83 88
84 89
Drag-and-drop boards, advanced project views, pull requests, review workflows,
lib/openagents/issues.ex modified +334 -115

@@ -1,86 +1,174 @@

1 1
defmodule OpenAgents.Issues do
2
  @moduledoc """
3
  The Issues context.
4
  """
2
  @moduledoc "Repository-scoped issues, comments, labels, milestones, and assignees."
5 3
6 4
  import Ecto.Query, warn: false
7
  alias OpenAgents.Repo
8
  alias OpenAgents.Issues.Comment
9
  alias OpenAgents.Issues.Issue
5
6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.Issues.{Comment, Issue}
10 8
  alias OpenAgents.Labels
9
  alias OpenAgents.Labels.Label
11 10
  alias OpenAgents.Milestones
11
  alias OpenAgents.Milestones.Milestone
12
  alias OpenAgents.Repo
13
  alias OpenAgents.Repositories
14
  alias OpenAgents.Repositories.Repository
15
16
  def list_issues(opts \\ []) when is_list(opts),
17
    do: list_issues(Repositories.initial_repository!(), opts)
12 18
13
  def list_issues(opts \\ []) do
19
  def list_issues(%Repository{id: repository_id}, opts) when is_list(opts) do
14 20
    state = Keyword.get(opts, :state, "open")
15 21
16 22
    Issue
23
    |> where(repository_id: ^repository_id)
17 24
    |> maybe_filter_state(state)
18 25
    |> order_by(desc: :inserted_at)
19 26
    |> Repo.all()
20 27
  end
21 28
22
  def get_issue!(id), do: Repo.get!(Issue, id)
29
  def get_issue!(id), do: get_issue!(Repositories.initial_repository!(), id)
30
31
  def get_issue!(%Repository{id: repository_id}, id) do
32
    Repo.get_by!(Issue, id: id, repository_id: repository_id)
33
  end
23 34
24 35
  def get_issue_by_number!(number) when is_integer(number),
25
    do: Repo.get_by!(Issue, number: number)
36
    do: get_issue_by_number!(Repositories.initial_repository!(), number)
37
38
  def get_issue_by_number!(%Repository{id: repository_id}, number) when is_integer(number),
39
    do: Repo.get_by!(Issue, repository_id: repository_id, number: number)
40
41
  def get_issue_by_path!(owner, repository_name, number) when is_integer(number) do
42
    Repo.one!(
43
      from issue in Issue,
44
        join: repository in Repository,
45
        on: repository.id == issue.repository_id,
46
        where:
47
          repository.owner_key == ^String.downcase(owner) and
48
            repository.name_key == ^String.downcase(repository_name) and
49
            repository.visibility == "public" and issue.number == ^number
50
    )
51
  end
52
53
  def create_issue(attrs \\ %{}),
54
    do: create_issue(Repositories.initial_repository!(), attrs, nil)
26 55
27
  def create_issue(attrs \\ %{}) do
28
    number = next_issue_number()
56
  def create_issue(%Repository{} = repository, attrs),
57
    do: create_issue(repository, attrs, nil)
29 58
59
  def create_issue(%Repository{} = repository, attrs, author)
60
      when is_nil(author) or is_struct(author, User) do
30 61
    normalized =
31 62
      attrs
32 63
      |> to_string_map()
33
      |> Map.put("number", number)
34
      |> prepare_collections()
64
      |> Map.put("repository_id", repository.id)
65
      |> put_author(author)
66
      |> prepare_collections(repository)
35 67
36
    %Issue{}
37
    |> Issue.changeset(normalized)
38
    |> Repo.insert()
68
    create_issue_with_number(repository, normalized, 20)
69
  end
70
71
  defp create_issue_with_number(repository, normalized, attempts_remaining) do
72
    Repo.transaction(fn ->
73
      number = next_issue_number(repository.id)
74
      normalized = Map.put(normalized, "number", number)
75
76
      with {:ok, issue} <- %Issue{} |> Issue.changeset(normalized) |> Repo.insert(),
77
           :ok <- sync_label_relationships(issue),
78
           :ok <- sync_assignee_relationships(issue) do
79
        issue
80
      else
81
        {:error, changeset} -> Repo.rollback(changeset)
82
      end
83
    end)
84
    |> case do
85
      {:error, changeset} when attempts_remaining > 1 ->
86
        if number_conflict?(changeset, "issues_repository_id_number_index") do
87
          create_issue_with_number(repository, normalized, attempts_remaining - 1)
88
        else
89
          {:error, changeset}
90
        end
91
92
      result ->
93
        result
94
    end
39 95
  end
40 96
41 97
  def update_issue(%Issue{} = issue, attrs) do
42
    normalized =
43
      issue
44
      |> maybe_closed_attrs(attrs)
45
      |> to_string_map()
46
      |> prepare_collections()
98
    repository = %Repository{id: issue.repository_id}
47 99
48
    issue
49
    |> Issue.changeset(normalized)
50
    |> Repo.update()
100
    Repo.transaction(fn ->
101
      normalized =
102
        issue
103
        |> maybe_closed_attrs(attrs)
104
        |> to_string_map()
105
        |> Map.drop(["number", "repository_id", "author_user_id", "user"])
106
        |> prepare_collections(repository)
107
108
      with {:ok, updated} <- issue |> Issue.changeset(normalized) |> Repo.update(),
109
           :ok <- sync_label_relationships(updated),
110
           :ok <- sync_assignee_relationships(updated) do
111
        updated
112
      else
113
        {:error, changeset} -> Repo.rollback(changeset)
114
      end
115
    end)
51 116
  end
52 117
53 118
  def change_issue(%Issue{} = issue, attrs \\ %{}) do
119
    attrs =
120
      if is_nil(issue.repository_id) do
121
        attrs
122
        |> to_string_map()
123
        |> Map.put("repository_id", Repositories.initial_repository!().id)
124
      else
125
        attrs
126
      end
127
54 128
    Issue.changeset(issue, attrs)
55 129
  end
56 130
57 131
  def add_labels(%Issue{} = issue, names) when is_list(names) do
58 132
    new_labels =
59
      names
60
      |> Enum.map(&Labels.get_label_by_name!/1)
61
      |> Enum.map(&label_json/1)
62
63
    existing = issue.labels || []
64
    labels = (existing ++ new_labels) |> Enum.uniq_by(& &1["name"])
133
      Enum.map(names, fn name ->
134
        issue.repository_id
135
        |> repository_stub()
136
        |> Labels.get_label_by_name!(name)
137
        |> label_json()
138
      end)
139
140
    labels = ((issue.labels || []) ++ new_labels) |> Enum.uniq_by(& &1["name"])
65 141
    update_issue(issue, %{"labels" => labels})
66 142
  end
67 143
68 144
  def remove_label(%Issue{} = issue, name) when is_binary(name) do
69
    name = URI.decode(name)
70
    labels = Enum.reject(issue.labels || [], &label_match?(&1, name))
145
    decoded = URI.decode(name)
146
    labels = Enum.reject(issue.labels || [], &label_match?(&1, decoded))
71 147
    update_issue(issue, %{"labels" => labels})
72 148
  end
73 149
74 150
  def add_assignees(%Issue{} = issue, logins) when is_list(logins) do
75
    new = Enum.map(logins, &%{"login" => &1})
76
    existing = issue.assignees || []
77
    assignees = (existing ++ new) |> Enum.uniq_by(& &1["login"])
151
    repository = repository_stub(issue.repository_id)
152
153
    new =
154
      Enum.map(logins, fn login ->
155
        repository
156
        |> Repositories.get_assignable_user_by_login!(login)
157
        |> assignee_json()
158
      end)
159
160
    assignees = ((issue.assignees || []) ++ new) |> Enum.uniq_by(& &1["login"])
78 161
    update_issue(issue, %{"assignees" => assignees})
79 162
  end
80 163
81 164
  def remove_assignees(%Issue{} = issue, logins) when is_list(logins) do
82
    logins = MapSet.new(logins)
83
    assignees = Enum.reject(issue.assignees || [], &(&1["login"] in logins))
165
    logins = logins |> Enum.map(&String.downcase/1) |> MapSet.new()
166
167
    assignees =
168
      Enum.reject(issue.assignees || [], fn assignee ->
169
        String.downcase(assignee["login"]) in logins
170
      end)
171
84 172
    update_issue(issue, %{"assignees" => assignees})
85 173
  end
86 174

@@ -89,35 +177,81 @@ defmodule OpenAgents.Issues do

89 177
  end
90 178
91 179
  def set_milestone(%Issue{} = issue, number) when is_integer(number) do
92
    milestone = Milestones.get_milestone_by_number!(number)
180
    milestone = Milestones.get_milestone_by_number!(repository_stub(issue.repository_id), number)
93 181
    update_issue(issue, %{"milestone" => milestone_json(milestone)})
94 182
  end
95 183
96
  def list_comments(issue_id) do
184
  def list_comments(%Issue{id: issue_id, repository_id: repository_id}) do
97 185
    Comment
98
    |> where(issue_id: ^issue_id)
186
    |> where(issue_id: ^issue_id, repository_id: ^repository_id)
99 187
    |> order_by(:created_at)
100 188
    |> Repo.all()
101 189
  end
102 190
103
  def get_comment!(id), do: Repo.get!(Comment, id)
191
  def list_comments(issue_id) do
192
    issue = get_issue!(issue_id)
193
    list_comments(issue)
194
  end
195
196
  def get_comment!(id), do: get_comment!(Repositories.initial_repository!(), id)
197
198
  def get_comment!(%Repository{id: repository_id}, id) do
199
    Repo.get_by!(Comment, id: id, repository_id: repository_id)
200
  end
201
202
  def get_comment_by_path!(owner, repository_name, id) do
203
    Repo.one!(
204
      from comment in Comment,
205
        join: repository in Repository,
206
        on: repository.id == comment.repository_id,
207
        where:
208
          repository.owner_key == ^String.downcase(owner) and
209
            repository.name_key == ^String.downcase(repository_name) and
210
            repository.visibility == "public" and comment.id == ^id
211
    )
212
  end
104 213
105 214
  def create_comment(attrs \\ %{}) do
215
    normalized = to_string_map(attrs)
216
217
    case Map.fetch(normalized, "issue_id") do
218
      {:ok, issue_id} ->
219
        issue = get_issue!(issue_id)
220
        create_comment(issue, normalized, nil)
221
222
      :error ->
223
        now = DateTime.utc_now() |> DateTime.truncate(:second)
224
225
        %Comment{}
226
        |> Comment.changeset(
227
          normalized
228
          |> Map.put("repository_id", Repositories.initial_repository!().id)
229
          |> Map.put_new("created_at", now)
230
          |> Map.put_new("updated_at", now)
231
        )
232
        |> Ecto.Changeset.apply_action(:insert)
233
    end
234
  end
235
236
  def create_comment(%Issue{} = issue, attrs, author \\ nil)
237
      when is_nil(author) or is_struct(author, User) do
106 238
    normalized =
107 239
      attrs
108 240
      |> to_string_map()
241
      |> Map.put("issue_id", issue.id)
242
      |> Map.put("repository_id", issue.repository_id)
243
      |> put_author(author)
109 244
      |> Map.put_new("created_at", DateTime.utc_now() |> DateTime.truncate(:second))
110 245
      |> Map.put_new("updated_at", DateTime.utc_now() |> DateTime.truncate(:second))
111 246
112
    issue_id = Map.get(normalized, "issue_id")
113
114 247
    Repo.transaction(fn ->
115 248
      with {:ok, %Comment{} = comment} <-
116
             %Comment{}
117
             |> Comment.changeset(normalized)
118
             |> Repo.insert(),
249
             %Comment{} |> Comment.changeset(normalized) |> Repo.insert(),
119 250
           {1, nil} <-
120
             from(i in Issue, where: i.id == ^issue_id, update: [inc: [comments: 1]])
251
             from(i in Issue,
252
               where: i.id == ^issue.id and i.repository_id == ^issue.repository_id,
253
               update: [inc: [comments: 1]]
254
             )
121 255
             |> Repo.update_all([]) do
122 256
        comment
123 257
      else

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

131 265
    normalized =
132 266
      attrs
133 267
      |> to_string_map()
268
      |> Map.drop(["issue_id", "repository_id", "author_user_id", "user"])
134 269
      |> Map.put("updated_at", DateTime.utc_now() |> DateTime.truncate(:second))
135 270
136 271
    comment

@@ -140,11 +275,12 @@ defmodule OpenAgents.Issues do

140 275
141 276
  def delete_comment(%Comment{} = comment) do
142 277
    Repo.transaction(fn ->
143
      issue_id = comment.issue_id
144
145 278
      with {:ok, %Comment{}} <- Repo.delete(comment),
146 279
           {1, nil} <-
147
             from(i in Issue, where: i.id == ^issue_id, update: [inc: [comments: -1]])
280
             from(i in Issue,
281
               where: i.id == ^comment.issue_id and i.repository_id == ^comment.repository_id,
282
               update: [inc: [comments: -1]]
283
             )
148 284
             |> Repo.update_all([]) do
149 285
        :ok
150 286
      else

@@ -154,103 +290,177 @@ defmodule OpenAgents.Issues do

154 290
    end)
155 291
  end
156 292
157
  defp maybe_closed_attrs(issue, %{"state" => "closed"} = attrs) do
158
    if issue.state == "open" do
159
      attrs
160
      |> Map.put("closed_at", DateTime.utc_now() |> DateTime.truncate(:second))
161
      |> Map.put_new("state_reason", "completed")
162
    else
163
      attrs
164
    end
293
  defp prepare_collections(attrs, repository) do
294
    attrs
295
    |> maybe_convert_milestone(repository)
296
    |> maybe_convert_labels(repository)
297
    |> maybe_convert_assignees(repository)
165 298
  end
166 299
167
  defp maybe_closed_attrs(_issue, %{"state" => "open"} = attrs) do
300
  defp maybe_convert_milestone(%{"milestone" => nil} = attrs, _repository) do
301
    attrs |> Map.put("milestone", nil) |> Map.put("milestone_id", nil)
302
  end
303
304
  defp maybe_convert_milestone(%{"milestone" => number} = attrs, repository)
305
       when is_integer(number) do
306
    milestone = Milestones.get_milestone_by_number!(repository, number)
307
168 308
    attrs
169
    |> Map.put("closed_at", nil)
170
    |> Map.put("state_reason", nil)
309
    |> Map.put("milestone", milestone_json(milestone))
310
    |> Map.put("milestone_id", milestone.id)
171 311
  end
172 312
173
  defp maybe_closed_attrs(_issue, %{state: "closed"} = attrs) do
174
    if is_nil(attrs[:closed_at]) do
175
      Map.put(attrs, :closed_at, DateTime.utc_now() |> DateTime.truncate(:second))
176
    else
177
      attrs
178
    end
179
    |> Map.put_new(:state_reason, "completed")
313
  defp maybe_convert_milestone(%{"milestone" => milestone} = attrs, repository)
314
       when is_map(milestone) do
315
    number = milestone["number"] || milestone[:number]
316
    maybe_convert_milestone(Map.put(attrs, "milestone", number), repository)
180 317
  end
181 318
182
  defp maybe_closed_attrs(_issue, %{state: "open"} = attrs) do
183
    attrs
184
    |> Map.put(:closed_at, nil)
185
    |> Map.put(:state_reason, nil)
319
  defp maybe_convert_milestone(attrs, _repository), do: attrs
320
321
  defp maybe_convert_labels(%{"labels" => labels} = attrs, repository) when is_list(labels) do
322
    snapshots =
323
      Enum.map(labels, fn label ->
324
        name = if is_binary(label), do: label, else: label["name"] || label[:name]
325
        repository |> Labels.get_label_by_name!(name) |> label_json()
326
      end)
327
328
    Map.put(attrs, "labels", Enum.uniq_by(snapshots, & &1["name"]))
186 329
  end
187 330
188
  defp maybe_closed_attrs(_issue, attrs), do: attrs
331
  defp maybe_convert_labels(attrs, _repository), do: attrs
189 332
190
  defp maybe_filter_state(query, "all"), do: query
191
  defp maybe_filter_state(query, state), do: where(query, state: ^state)
333
  defp maybe_convert_assignees(%{"assignees" => assignees} = attrs, repository)
334
       when is_list(assignees) do
335
    snapshots =
336
      Enum.map(assignees, fn assignee ->
337
        login = if is_binary(assignee), do: assignee, else: assignee["login"] || assignee[:login]
338
        repository |> Repositories.get_assignable_user_by_login!(login) |> assignee_json()
339
      end)
192 340
193
  defp next_issue_number do
194
    case Repo.aggregate(Issue, :max, :number) do
195
      nil -> 1
196
      n -> n + 1
197
    end
341
    Map.put(attrs, "assignees", Enum.uniq_by(snapshots, & &1["login"]))
198 342
  end
199 343
200
  defp to_string_map(attrs) do
201
    for {k, v} <- attrs, into: %{}, do: {to_string(k), v}
344
  defp maybe_convert_assignees(attrs, _repository), do: attrs
345
346
  defp sync_label_relationships(%Issue{} = issue) do
347
    Repo.delete_all(from row in "issue_labels", where: row.issue_id == ^issue.id)
348
349
    rows =
350
      Enum.map(issue.labels || [], fn snapshot ->
351
        label = Labels.get_label_by_name!(repository_stub(issue.repository_id), snapshot["name"])
352
353
        %{
354
          issue_id: issue.id,
355
          label_id: label.id,
356
          repository_id: issue.repository_id,
357
          inserted_at: DateTime.utc_now(),
358
          updated_at: DateTime.utc_now()
359
        }
360
      end)
361
362
    if rows != [], do: Repo.insert_all("issue_labels", dump_repository_ids(rows))
363
    :ok
202 364
  end
203 365
204
  defp prepare_collections(attrs) do
205
    attrs
206
    |> maybe_convert_milestone()
207
    |> maybe_convert_labels()
208
    |> maybe_convert_assignees()
366
  defp sync_assignee_relationships(%Issue{} = issue) do
367
    Repo.delete_all(from row in "issue_assignees", where: row.issue_id == ^issue.id)
368
369
    rows =
370
      Enum.map(issue.assignees || [], fn snapshot ->
371
        user =
372
          Repositories.get_assignable_user_by_login!(
373
            repository_stub(issue.repository_id),
374
            snapshot["login"]
375
          )
376
377
        %{
378
          issue_id: issue.id,
379
          user_id: user.id,
380
          repository_id: issue.repository_id,
381
          inserted_at: DateTime.utc_now(),
382
          updated_at: DateTime.utc_now()
383
        }
384
      end)
385
386
    if rows != [], do: Repo.insert_all("issue_assignees", dump_repository_ids(rows))
387
    :ok
209 388
  end
210 389
211
  defp maybe_convert_milestone(%{"milestone" => milestone} = attrs) when is_integer(milestone) do
212
    milestone = Milestones.get_milestone_by_number!(milestone)
213
    Map.put(attrs, "milestone", milestone_json(milestone))
390
  defp next_issue_number(repository_id) do
391
    case Repo.aggregate(from(i in Issue, where: i.repository_id == ^repository_id), :max, :number) do
392
      nil -> 1
393
      number -> number + 1
394
    end
214 395
  end
215 396
216
  defp maybe_convert_milestone(%{"milestone" => nil} = attrs) do
217
    Map.put(attrs, "milestone", nil)
397
  defp number_conflict?(%Ecto.Changeset{} = changeset, constraint_name) do
398
    Enum.any?(changeset.errors, fn {_field, {_message, options}} ->
399
      options[:constraint_name] == constraint_name
400
    end)
218 401
  end
219 402
220
  defp maybe_convert_milestone(attrs), do: attrs
403
  defp put_author(attrs, nil), do: attrs
221 404
222
  defp maybe_convert_labels(%{"labels" => labels} = attrs) when is_list(labels) do
223
    if Enum.all?(labels, &is_binary/1) do
224
      existing =
225
        Labels.list_labels()
226
        |> Enum.map(&label_json/1)
227
        |> Map.new(&{&1["name"], &1})
405
  defp put_author(attrs, %User{} = author) do
406
    attrs
407
    |> Map.put("author_user_id", author.id)
408
    |> Map.put("user", user_json(author))
409
  end
228 410
229
      label_maps =
230
        Enum.map(labels, fn name ->
231
          Map.get(existing, name, %{"name" => name, "color" => "ffffff"})
232
        end)
411
  defp dump_repository_ids(rows) do
412
    Enum.map(rows, fn row ->
413
      row = Map.update!(row, :repository_id, &Ecto.UUID.dump!/1)
414
415
      if Map.has_key?(row, :user_id) do
416
        Map.update!(row, :user_id, &Ecto.UUID.dump!/1)
417
      else
418
        row
419
      end
420
    end)
421
  end
233 422
234
      Map.put(attrs, "labels", label_maps)
423
  defp maybe_closed_attrs(issue, %{"state" => "closed"} = attrs) do
424
    if issue.state == "open" do
425
      attrs
426
      |> Map.put("closed_at", DateTime.utc_now() |> DateTime.truncate(:second))
427
      |> Map.put_new("state_reason", "completed")
235 428
    else
236 429
      attrs
237 430
    end
238 431
  end
239 432
240
  defp maybe_convert_labels(attrs), do: attrs
433
  defp maybe_closed_attrs(_issue, %{"state" => "open"} = attrs) do
434
    attrs |> Map.put("closed_at", nil) |> Map.put("state_reason", nil)
435
  end
436
437
  defp maybe_closed_attrs(issue, %{state: "closed"} = attrs) do
438
    attrs =
439
      if issue.state == "open" and is_nil(attrs[:closed_at]) do
440
        Map.put(attrs, :closed_at, DateTime.utc_now() |> DateTime.truncate(:second))
441
      else
442
        attrs
443
      end
241 444
242
  defp maybe_convert_assignees(%{"assignees" => logins} = attrs) when is_list(logins) do
243
    if Enum.all?(logins, &is_binary/1) do
244
      assignees = Enum.map(logins, &%{"login" => &1})
245
      Map.put(attrs, "assignees", assignees)
246
    else
247
      attrs
248
    end
445
    Map.put_new(attrs, :state_reason, "completed")
446
  end
447
448
  defp maybe_closed_attrs(_issue, %{state: "open"} = attrs) do
449
    attrs |> Map.put(:closed_at, nil) |> Map.put(:state_reason, nil)
450
  end
451
452
  defp maybe_closed_attrs(_issue, attrs), do: attrs
453
454
  defp maybe_filter_state(query, "all"), do: query
455
  defp maybe_filter_state(query, state), do: where(query, state: ^state)
456
457
  defp to_string_map(attrs) do
458
    for {key, value} <- attrs, into: %{}, do: {to_string(key), value}
249 459
  end
250 460
251
  defp maybe_convert_assignees(attrs), do: attrs
461
  defp repository_stub(id), do: %Repository{id: id}
252 462
253
  defp label_json(%OpenAgents.Labels.Label{} = label) do
463
  defp label_json(%Label{} = label) do
254 464
    %{
255 465
      "id" => label.id,
256 466
      "name" => label.name,

@@ -259,7 +469,7 @@ defmodule OpenAgents.Issues do

259 469
    }
260 470
  end
261 471
262
  defp milestone_json(%OpenAgents.Milestones.Milestone{} = milestone) do
472
  defp milestone_json(%Milestone{} = milestone) do
263 473
    %{
264 474
      "number" => milestone.number,
265 475
      "title" => milestone.title,

@@ -269,5 +479,14 @@ defmodule OpenAgents.Issues do

269 479
    }
270 480
  end
271 481
482
  defp user_json(%User{} = user) do
483
    %{
484
      "id" => user.github_id,
485
      "login" => user.github_login,
486
      "avatar_url" => user.github_avatar_url
487
    }
488
  end
489
490
  defp assignee_json(%User{} = user), do: %{"login" => user.github_login}
272 491
  defp label_match?(label, name), do: label["name"] == name
273 492
end
lib/openagents/issues/comment.ex modified +17 -2

@@ -3,6 +3,7 @@ defmodule OpenAgents.Issues.Comment do

3 3
  import Ecto.Changeset
4 4
5 5
  alias OpenAgents.Issues.Issue
6
  alias OpenAgents.Repositories.Repository
6 7
7 8
  schema "comments" do
8 9
    field :body, :string

@@ -11,12 +12,26 @@ defmodule OpenAgents.Issues.Comment do

11 12
    field :updated_at, :utc_datetime
12 13
13 14
    belongs_to :issue, Issue
15
    belongs_to :repository, Repository, type: :binary_id
16
    belongs_to :author_user, OpenAgents.Accounts.User, type: :binary_id
14 17
  end
15 18
16 19
  @doc false
17 20
  def changeset(comment, attrs) do
18 21
    comment
19
    |> cast(attrs, [:body, :user, :issue_id, :created_at, :updated_at])
20
    |> validate_required([:body, :issue_id, :created_at, :updated_at])
22
    |> cast(attrs, [
23
      :body,
24
      :user,
25
      :issue_id,
26
      :repository_id,
27
      :author_user_id,
28
      :created_at,
29
      :updated_at
30
    ])
31
    |> validate_required([:body, :issue_id, :repository_id, :created_at, :updated_at])
32
    |> foreign_key_constraint(:repository_id)
33
    |> foreign_key_constraint(:issue_id)
34
    |> foreign_key_constraint(:author_user_id)
35
    |> foreign_key_constraint(:issue_id, name: :comments_issue_repository_fkey)
21 36
  end
22 37
end
lib/openagents/issues/issue.ex modified +16 -2

@@ -2,6 +2,10 @@ defmodule OpenAgents.Issues.Issue do

2 2
  use Ecto.Schema
3 3
  import Ecto.Changeset
4 4
5
  alias OpenAgents.Accounts.User
6
  alias OpenAgents.Milestones.Milestone
7
  alias OpenAgents.Repositories.Repository
8
5 9
  schema "issues" do
6 10
    field :number, :integer
7 11
    field :title, :string

@@ -16,6 +20,9 @@ defmodule OpenAgents.Issues.Issue do

16 20
    field :assignees, {:array, :map}, default: []
17 21
    field :milestone, :map
18 22
    field :user, :map
23
    belongs_to :repository, Repository, type: :binary_id
24
    belongs_to :milestone_record, Milestone, foreign_key: :milestone_id
25
    belongs_to :author_user, User, type: :binary_id
19 26
    timestamps(type: :utc_datetime)
20 27
  end
21 28

@@ -35,8 +42,15 @@ defmodule OpenAgents.Issues.Issue do

35 42
      :labels,
36 43
      :assignees,
37 44
      :milestone,
38
      :user
45
      :user,
46
      :repository_id,
47
      :milestone_id,
48
      :author_user_id
39 49
    ])
40
    |> validate_required([:title, :number])
50
    |> validate_required([:title, :number, :repository_id])
51
    |> unique_constraint([:repository_id, :number])
52
    |> foreign_key_constraint(:repository_id)
53
    |> foreign_key_constraint(:milestone_id)
54
    |> foreign_key_constraint(:author_user_id)
41 55
  end
42 56
end
lib/openagents/labels.ex modified +41 -5

@@ -5,6 +5,8 @@ defmodule OpenAgents.Labels do

5 5
6 6
  import Ecto.Query, warn: false
7 7
  alias OpenAgents.Repo
8
  alias OpenAgents.Repositories
9
  alias OpenAgents.Repositories.Repository
8 10
9 11
  alias OpenAgents.Labels.Label
10 12

@@ -17,8 +19,13 @@ defmodule OpenAgents.Labels do

17 19
      [%Label{}, ...]
18 20
19 21
  """
20
  def list_labels do
21
    Repo.all(Label)
22
  def list_labels, do: list_labels(Repositories.initial_repository!())
23
24
  def list_labels(%Repository{id: repository_id}) do
25
    Label
26
    |> where(repository_id: ^repository_id)
27
    |> order_by(asc: :name)
28
    |> Repo.all()
22 29
  end
23 30
24 31
  @doc """

@@ -35,10 +42,30 @@ defmodule OpenAgents.Labels do

35 42
      ** (Ecto.NoResultsError)
36 43
37 44
  """
38
  def get_label!(id), do: Repo.get!(Label, id)
45
  def get_label!(id), do: get_label!(Repositories.initial_repository!(), id)
46
47
  def get_label!(%Repository{id: repository_id}, id) do
48
    Repo.get_by!(Label, id: id, repository_id: repository_id)
49
  end
39 50
40 51
  def get_label_by_name!(name) when is_binary(name) do
41
    Repo.get_by!(Label, name: URI.decode(name))
52
    get_label_by_name!(Repositories.initial_repository!(), name)
53
  end
54
55
  def get_label_by_name!(%Repository{id: repository_id}, name) when is_binary(name) do
56
    Repo.get_by!(Label, repository_id: repository_id, name: URI.decode(name))
57
  end
58
59
  def get_label_by_path!(owner, repository_name, name) do
60
    Repo.one!(
61
      from label in Label,
62
        join: repository in Repository,
63
        on: repository.id == label.repository_id,
64
        where:
65
          repository.owner_key == ^String.downcase(owner) and
66
            repository.name_key == ^String.downcase(repository_name) and
67
            repository.visibility == "public" and label.name == ^URI.decode(name)
68
    )
42 69
  end
43 70
44 71
  @doc """

@@ -53,7 +80,14 @@ defmodule OpenAgents.Labels do

53 80
      {:error, %Ecto.Changeset{}}
54 81
55 82
  """
56
  def create_label(attrs) do
83
  def create_label(attrs), do: create_label(Repositories.initial_repository!(), attrs)
84
85
  def create_label(%Repository{} = repository, attrs) do
86
    attrs =
87
      attrs
88
      |> Enum.into(%{}, fn {key, value} -> {to_string(key), value} end)
89
      |> Map.put("repository_id", repository.id)
90
57 91
    %Label{}
58 92
    |> Label.changeset(attrs)
59 93
    |> Repo.insert()

@@ -72,6 +106,8 @@ defmodule OpenAgents.Labels do

72 106
73 107
  """
74 108
  def update_label(%Label{} = label, attrs) do
109
    attrs = Map.drop(attrs, [:repository_id, "repository_id"])
110
75 111
    label
76 112
    |> Label.changeset(attrs)
77 113
    |> Repo.update()
lib/openagents/labels/label.ex modified +7 -2

@@ -2,10 +2,13 @@ defmodule OpenAgents.Labels.Label do

2 2
  use Ecto.Schema
3 3
  import Ecto.Changeset
4 4
5
  alias OpenAgents.Repositories.Repository
6
5 7
  schema "labels" do
6 8
    field :name, :string
7 9
    field :color, :string
8 10
    field :description, :string
11
    belongs_to :repository, Repository, type: :binary_id
9 12
10 13
    timestamps(type: :utc_datetime)
11 14
  end

@@ -13,7 +16,9 @@ defmodule OpenAgents.Labels.Label do

13 16
  @doc false
14 17
  def changeset(label, attrs) do
15 18
    label
16
    |> cast(attrs, [:name, :color, :description])
17
    |> validate_required([:name, :color])
19
    |> cast(attrs, [:name, :color, :description, :repository_id])
20
    |> validate_required([:name, :color, :repository_id])
21
    |> unique_constraint([:repository_id, :name])
22
    |> foreign_key_constraint(:repository_id)
18 23
  end
19 24
end
lib/openagents/milestones.ex modified +75 -9

@@ -5,6 +5,8 @@ defmodule OpenAgents.Milestones do

5 5
6 6
  import Ecto.Query, warn: false
7 7
  alias OpenAgents.Repo
8
  alias OpenAgents.Repositories
9
  alias OpenAgents.Repositories.Repository
8 10
9 11
  alias OpenAgents.Milestones.Milestone
10 12

@@ -17,8 +19,13 @@ defmodule OpenAgents.Milestones do

17 19
      [%Milestone{}, ...]
18 20
19 21
  """
20
  def list_milestones do
21
    Repo.all(Milestone)
22
  def list_milestones, do: list_milestones(Repositories.initial_repository!())
23
24
  def list_milestones(%Repository{id: repository_id}) do
25
    Milestone
26
    |> where(repository_id: ^repository_id)
27
    |> order_by(asc: :number)
28
    |> Repo.all()
22 29
  end
23 30
24 31
  @doc """

@@ -35,10 +42,29 @@ defmodule OpenAgents.Milestones do

35 42
      ** (Ecto.NoResultsError)
36 43
37 44
  """
38
  def get_milestone!(id), do: Repo.get!(Milestone, id)
45
  def get_milestone!(id), do: get_milestone!(Repositories.initial_repository!(), id)
46
47
  def get_milestone!(%Repository{id: repository_id}, id) do
48
    Repo.get_by!(Milestone, id: id, repository_id: repository_id)
49
  end
39 50
40 51
  def get_milestone_by_number!(number) when is_integer(number),
41
    do: Repo.get_by!(Milestone, number: number)
52
    do: get_milestone_by_number!(Repositories.initial_repository!(), number)
53
54
  def get_milestone_by_number!(%Repository{id: repository_id}, number) when is_integer(number),
55
    do: Repo.get_by!(Milestone, repository_id: repository_id, number: number)
56
57
  def get_milestone_by_path!(owner, repository_name, number) when is_integer(number) do
58
    Repo.one!(
59
      from milestone in Milestone,
60
        join: repository in Repository,
61
        on: repository.id == milestone.repository_id,
62
        where:
63
          repository.owner_key == ^String.downcase(owner) and
64
            repository.name_key == ^String.downcase(repository_name) and
65
            repository.visibility == "public" and milestone.number == ^number
66
    )
67
  end
42 68
43 69
  @doc """
44 70
  Creates a milestone.

@@ -52,22 +78,60 @@ defmodule OpenAgents.Milestones do

52 78
      {:error, %Ecto.Changeset{}}
53 79
54 80
  """
55
  def create_milestone(attrs \\ %{}) do
56
    number = next_milestone_number()
81
  def create_milestone(attrs \\ %{}),
82
    do: create_milestone(Repositories.initial_repository!(), attrs)
83
84
  def create_milestone(%Repository{} = repository, attrs) do
57 85
    normalized = for {k, v} <- attrs, into: %{}, do: {to_string(k), v}
86
    explicit_number? = Map.has_key?(normalized, "number")
87
    create_milestone_with_number(repository, normalized, explicit_number?, 20)
88
  end
89
90
  defp create_milestone_with_number(repository, normalized, explicit_number?, attempts_remaining) do
91
    number = next_milestone_number(repository.id)
58 92
59 93
    %Milestone{}
60
    |> Milestone.changeset(Map.put_new(normalized, "number", number))
94
    |> Milestone.changeset(
95
      normalized
96
      |> Map.put_new("number", number)
97
      |> Map.put("repository_id", repository.id)
98
    )
61 99
    |> Repo.insert()
100
    |> case do
101
      {:error, changeset} when not explicit_number? and attempts_remaining > 1 ->
102
        if number_conflict?(changeset) do
103
          create_milestone_with_number(
104
            repository,
105
            normalized,
106
            explicit_number?,
107
            attempts_remaining - 1
108
          )
109
        else
110
          {:error, changeset}
111
        end
112
113
      result ->
114
        result
115
    end
62 116
  end
63 117
64
  defp next_milestone_number do
65
    case Repo.aggregate(Milestone, :max, :number) do
118
  defp next_milestone_number(repository_id) do
119
    case Repo.aggregate(
120
           from(m in Milestone, where: m.repository_id == ^repository_id),
121
           :max,
122
           :number
123
         ) do
66 124
      nil -> 1
67 125
      n -> n + 1
68 126
    end
69 127
  end
70 128
129
  defp number_conflict?(changeset) do
130
    Enum.any?(changeset.errors, fn {_field, {_message, options}} ->
131
      options[:constraint_name] == "milestones_repository_id_number_index"
132
    end)
133
  end
134
71 135
  @doc """
72 136
  Updates a milestone.
73 137

@@ -81,6 +145,8 @@ defmodule OpenAgents.Milestones do

81 145
82 146
  """
83 147
  def update_milestone(%Milestone{} = milestone, attrs) do
148
    attrs = Map.drop(attrs, [:repository_id, "repository_id", :number, "number"])
149
84 150
    milestone
85 151
    |> Milestone.changeset(attrs)
86 152
    |> Repo.update()
lib/openagents/milestones/milestone.ex modified +7 -2

@@ -2,12 +2,15 @@ defmodule OpenAgents.Milestones.Milestone do

2 2
  use Ecto.Schema
3 3
  import Ecto.Changeset
4 4
5
  alias OpenAgents.Repositories.Repository
6
5 7
  schema "milestones" do
6 8
    field :title, :string
7 9
    field :state, :string, default: "open"
8 10
    field :description, :string
9 11
    field :due_on, :string
10 12
    field :number, :integer
13
    belongs_to :repository, Repository, type: :binary_id
11 14
12 15
    timestamps(type: :utc_datetime)
13 16
  end

@@ -15,7 +18,9 @@ defmodule OpenAgents.Milestones.Milestone do

15 18
  @doc false
16 19
  def changeset(milestone, attrs) do
17 20
    milestone
18
    |> cast(attrs, [:title, :state, :description, :due_on, :number])
19
    |> validate_required([:title, :number])
21
    |> cast(attrs, [:title, :state, :description, :due_on, :number, :repository_id])
22
    |> validate_required([:title, :number, :repository_id])
23
    |> unique_constraint([:repository_id, :number])
24
    |> foreign_key_constraint(:repository_id)
20 25
  end
21 26
end
lib/openagents/project_items.ex modified +21 -3

@@ -7,6 +7,8 @@ defmodule OpenAgents.ProjectItems do

7 7
  alias OpenAgents.Repo
8 8
9 9
  alias OpenAgents.ProjectItems.ProjectItem
10
  alias OpenAgents.Projects.Project
11
  alias OpenAgents.Repositories
10 12
11 13
  @doc """
12 14
  Returns the list of project_items.

@@ -18,7 +20,8 @@ defmodule OpenAgents.ProjectItems do

18 20
19 21
  """
20 22
  def list_project_items do
21
    Repo.all(ProjectItem)
23
    repository_id = Repositories.initial_repository!().id
24
    Repo.all(from item in ProjectItem, where: item.repository_id == ^repository_id)
22 25
  end
23 26
24 27
  @doc """

@@ -35,7 +38,10 @@ defmodule OpenAgents.ProjectItems do

35 38
      ** (Ecto.NoResultsError)
36 39
37 40
  """
38
  def get_project_item!(id), do: Repo.get!(ProjectItem, id)
41
  def get_project_item!(id) do
42
    repository_id = Repositories.initial_repository!().id
43
    Repo.get_by!(ProjectItem, id: id, repository_id: repository_id)
44
  end
39 45
40 46
  @doc """
41 47
  Creates a project_item.

@@ -50,8 +56,11 @@ defmodule OpenAgents.ProjectItems do

50 56
51 57
  """
52 58
  def create_project_item(attrs) do
59
    attrs = for {key, value} <- attrs, into: %{}, do: {to_string(key), value}
60
    repository_id = repository_id_for(Map.get(attrs, "project_id"))
61
53 62
    %ProjectItem{}
54
    |> ProjectItem.changeset(attrs)
63
    |> ProjectItem.changeset(Map.put(attrs, "repository_id", repository_id))
55 64
    |> Repo.insert()
56 65
  end
57 66

@@ -101,4 +110,13 @@ defmodule OpenAgents.ProjectItems do

101 110
  def change_project_item(%ProjectItem{} = project_item, attrs \\ %{}) do
102 111
    ProjectItem.changeset(project_item, attrs)
103 112
  end
113
114
  defp repository_id_for(nil), do: Repositories.initial_repository!().id
115
116
  defp repository_id_for(project_id) do
117
    case Repo.get(Project, project_id) do
118
      %Project{repository_id: repository_id} -> repository_id
119
      nil -> Repositories.initial_repository!().id
120
    end
121
  end
104 122
end
lib/openagents/project_items/project_item.ex modified +10 -2

@@ -2,10 +2,13 @@ defmodule OpenAgents.ProjectItems.ProjectItem do

2 2
  use Ecto.Schema
3 3
  import Ecto.Changeset
4 4
5
  alias OpenAgents.Repositories.Repository
6
5 7
  schema "project_items" do
6 8
    field :values, :map
7 9
    field :project_id, :id
8 10
    field :issue_id, :id
11
    belongs_to :repository, Repository, type: :binary_id
9 12
10 13
    timestamps(type: :utc_datetime)
11 14
  end

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

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

@@ -1,101 +1,214 @@

1 1
defmodule OpenAgents.Projects do
2
  @moduledoc """
3
  The Projects context.
4
  """
2
  @moduledoc "Repository-scoped projects and project items."
5 3
6 4
  import Ecto.Query, warn: false
7
  alias OpenAgents.Repo
8 5
6
  alias OpenAgents.Accounts.User
9 7
  alias OpenAgents.Issues.Issue
10 8
  alias OpenAgents.ProjectFields.ProjectField
11 9
  alias OpenAgents.ProjectItems.ProjectItem
12 10
  alias OpenAgents.Projects.Project
11
  alias OpenAgents.Repo
12
  alias OpenAgents.Repositories
13
  alias OpenAgents.Repositories.Repository
13 14
14
  @doc """
15
  Returns the list of projects.
15
  def list_projects, do: list_projects(Repositories.initial_repository!())
16 16
17
  ## Examples
17
  def list_projects(%Repository{id: repository_id}) do
18
    Project
19
    |> where(repository_id: ^repository_id)
20
    |> order_by(asc: :number)
21
    |> Repo.all()
22
  end
18 23
19
      iex> list_projects()
20
      [%Project{}, ...]
24
  def list_projects_by_owner(username) when is_binary(username) do
25
    repository_id = Repositories.initial_repository!().id
21 26
22
  """
23
  def list_projects do
24
    Repo.all(Project)
27
    Repo.all(
28
      from project in Project,
29
        where:
30
          project.repository_id == ^repository_id and
31
            fragment("lower(?)", project.owner) == ^String.downcase(username),
32
        order_by: [asc: project.number]
33
    )
25 34
  end
26 35
27
  @doc """
28
  Gets a single project.
36
  def get_project!(id), do: get_project!(Repositories.initial_repository!(), id)
29 37
30
  Raises `Ecto.NoResultsError` if the Project does not exist.
38
  def get_project!(%Repository{id: repository_id}, id) do
39
    Repo.get_by!(Project, id: id, repository_id: repository_id)
40
  end
31 41
32
  ## Examples
42
  def get_project_by_number!(number) when is_integer(number),
43
    do: get_project_by_number!(Repositories.initial_repository!(), number)
44
45
  def get_project_by_number!(%Repository{id: repository_id}, number) when is_integer(number),
46
    do: Repo.get_by!(Project, repository_id: repository_id, number: number)
47
48
  def get_project_by_path!(owner, repository_name, number) when is_integer(number) do
49
    Repo.one!(
50
      from project in Project,
51
        join: repository in Repository,
52
        on: repository.id == project.repository_id,
53
        where:
54
          repository.owner_key == ^String.downcase(owner) and
55
            repository.name_key == ^String.downcase(repository_name) and
56
            repository.visibility == "public" and project.number == ^number
57
    )
58
  end
33 59
34
      iex> get_project!(123)
35
      %Project{}
60
  def get_project_by_owner_and_number!(username, number) when is_integer(number) do
61
    repository_id = Repositories.initial_repository!().id
36 62
37
      iex> get_project!(456)
38
      ** (Ecto.NoResultsError)
63
    Repo.one!(
64
      from project in Project,
65
        where:
66
          project.repository_id == ^repository_id and
67
            fragment("lower(?)", project.owner) == ^String.downcase(username) and
68
            project.number == ^number
69
    )
70
  end
39 71
40
  """
41
  def get_project!(id), do: Repo.get!(Project, id)
72
  def create_project(attrs \\ %{}),
73
    do: create_project(Repositories.initial_repository!(), attrs, nil)
42 74
43
  def get_project_by_number!(number) when is_integer(number),
44
    do: Repo.get_by!(Project, number: number)
75
  def create_project(%Repository{} = repository, attrs),
76
    do: create_project(repository, attrs, nil)
45 77
46
  @doc """
47
  Creates a project.
78
  def create_project(%Repository{} = repository, attrs, owner_user)
79
      when is_nil(owner_user) or is_struct(owner_user, User) do
80
    normalized = to_string_map(attrs)
81
    explicit_number? = Map.has_key?(normalized, "number")
48 82
49
  ## Examples
83
    normalized =
84
      normalized
85
      |> Map.put("repository_id", repository.id)
86
      |> put_owner(owner_user)
50 87
51
      iex> create_project(%{field: value})
52
      {:ok, %Project{}}
53
54
      iex> create_project(%{field: bad_value})
55
      {:error, %Ecto.Changeset{}}
88
    create_project_with_number(repository, normalized, explicit_number?, 20)
89
  end
56 90
57
  """
58
  def create_project(attrs \\ %{}) do
59
    number = next_project_number()
60
    normalized = for {k, v} <- attrs, into: %{}, do: {to_string(k), v}
91
  defp create_project_with_number(repository, normalized, explicit_number?, attempts_remaining) do
92
    normalized = Map.put_new(normalized, "number", next_project_number(repository.id))
61 93
62 94
    %Project{}
63
    |> Project.changeset(Map.put_new(normalized, "number", number))
95
    |> Project.changeset(normalized)
64 96
    |> Repo.insert()
97
    |> case do
98
      {:error, changeset} when not explicit_number? and attempts_remaining > 1 ->
99
        if number_conflict?(changeset) do
100
          normalized = Map.delete(normalized, "number")
101
          create_project_with_number(repository, normalized, false, attempts_remaining - 1)
102
        else
103
          {:error, changeset}
104
        end
105
106
      result ->
107
        result
108
    end
65 109
  end
66 110
67
  def list_project_items(project_id) do
68
    ProjectItem
69
    |> where(project_id: ^project_id)
70
    |> Repo.all()
111
  def update_project(%Project{} = project, attrs) do
112
    attrs = attrs |> to_string_map() |> Map.drop(["repository_id", "owner_user_id"])
113
114
    attrs =
115
      if project.owner_user_id do
116
        Map.drop(attrs, ["owner"])
117
      else
118
        attrs
119
      end
120
121
    project
122
    |> Project.changeset(attrs)
123
    |> Repo.update()
71 124
  end
72 125
73
  def get_project_item!(id), do: Repo.get!(ProjectItem, id)
126
  def delete_project(%Project{} = project), do: Repo.delete(project)
74 127
75
  def create_project_item(attrs, project_id) do
128
  def change_project(%Project{} = project, attrs \\ %{}) do
76 129
    attrs =
77
      for {k, v} <- attrs, into: %{} do
78
        {to_string(k), v}
130
      if is_nil(project.repository_id) do
131
        attrs
132
        |> to_string_map()
133
        |> Map.put("repository_id", Repositories.initial_repository!().id)
134
      else
135
        attrs
79 136
      end
80 137
138
    Project.changeset(project, attrs)
139
  end
140
141
  def list_project_items(project_id) when is_integer(project_id) do
142
    project = get_project!(project_id)
143
    list_project_items(project)
144
  end
145
146
  def list_project_items(%Project{id: project_id, repository_id: repository_id}) do
147
    ProjectItem
148
    |> where(project_id: ^project_id, repository_id: ^repository_id)
149
    |> order_by(asc: :id)
150
    |> Repo.all()
151
  end
152
153
  def get_project_item!(id) do
154
    repository = Repositories.initial_repository!()
155
    Repo.get_by!(ProjectItem, id: id, repository_id: repository.id)
156
  end
157
158
  def get_project_item!(%Project{id: project_id, repository_id: repository_id}, id) do
159
    Repo.get_by!(ProjectItem,
160
      id: id,
161
      project_id: project_id,
162
      repository_id: repository_id
163
    )
164
  end
165
166
  def get_project_item_by_owner!(username, project_number, item_id) do
167
    repository_id = Repositories.initial_repository!().id
168
169
    Repo.one!(
170
      from item in ProjectItem,
171
        join: project in Project,
172
        on:
173
          project.id == item.project_id and project.repository_id == item.repository_id and
174
            project.number == ^project_number,
175
        where:
176
          item.repository_id == ^repository_id and item.id == ^item_id and
177
            fragment("lower(?)", project.owner) == ^String.downcase(username)
178
    )
179
  end
180
181
  def create_project_item(attrs, project_id) when is_integer(project_id) do
182
    project = get_project!(project_id)
183
    create_project_item(attrs, project)
184
  end
185
186
  def create_project_item(attrs, %Project{} = project) do
187
    attrs = to_string_map(attrs)
81 188
    values = Map.get(attrs, "values", %{})
82 189
83 190
    case Map.get(attrs, "issue_number") do
84 191
      nil ->
85
        # Feeding a nil straight into `get_by!` raises ArgumentError ("comparison
86
        # with nil is forbidden"), which callers do not expect from a create.
87
        # A missing issue number is a bad request, so answer with a changeset.
88 192
        %ProjectItem{}
89
        |> ProjectItem.changeset(%{"project_id" => project_id, "values" => values})
193
        |> ProjectItem.changeset(%{
194
          "project_id" => project.id,
195
          "repository_id" => project.repository_id,
196
          "values" => values
197
        })
90 198
        |> Ecto.Changeset.apply_action(:insert)
91 199
92 200
      issue_number ->
93
        issue = Repo.get_by!(Issue, number: issue_number)
201
        issue =
202
          Repo.get_by!(Issue,
203
            repository_id: project.repository_id,
204
            number: issue_number
205
          )
94 206
95 207
        %ProjectItem{}
96 208
        |> ProjectItem.changeset(%{
97
          "project_id" => project_id,
209
          "project_id" => project.id,
98 210
          "issue_id" => issue.id,
211
          "repository_id" => project.repository_id,
99 212
          "values" => values
100 213
        })
101 214
        |> Repo.insert()

@@ -103,11 +216,7 @@ defmodule OpenAgents.Projects do

103 216
  end
104 217
105 218
  def update_project_item(%ProjectItem{} = item, attrs) do
106
    attrs =
107
      for {k, v} <- attrs, into: %{} do
108
        {to_string(k), v}
109
      end
110
219
    attrs = to_string_map(attrs)
111 220
    values = Map.merge(item.values || %{}, Map.get(attrs, "values", %{}))
112 221
113 222
    item

@@ -115,9 +224,15 @@ defmodule OpenAgents.Projects do

115 224
    |> Repo.update()
116 225
  end
117 226
118
  def list_project_fields(project_id) do
227
  def list_project_fields(project_id) when is_integer(project_id) do
228
    project = get_project!(project_id)
229
    list_project_fields(project)
230
  end
231
232
  def list_project_fields(%Project{id: project_id}) do
119 233
    ProjectField
120 234
    |> where(project_id: ^project_id)
235
    |> order_by(asc: :id)
121 236
    |> Repo.all()
122 237
  end
123 238

@@ -127,57 +242,32 @@ defmodule OpenAgents.Projects do

127 242
    |> Repo.insert()
128 243
  end
129 244
130
  defp next_project_number do
131
    case Repo.aggregate(Project, :max, :number) do
245
  defp next_project_number(repository_id) do
246
    case Repo.aggregate(
247
           from(project in Project, where: project.repository_id == ^repository_id),
248
           :max,
249
           :number
250
         ) do
132 251
      nil -> 1
133
      n -> n + 1
252
      number -> number + 1
134 253
    end
135 254
  end
136 255
137
  @doc """
138
  Updates a project.
139
140
  ## Examples
141
142
      iex> update_project(project, %{field: new_value})
143
      {:ok, %Project{}}
144
145
      iex> update_project(project, %{field: bad_value})
146
      {:error, %Ecto.Changeset{}}
147
148
  """
149
  def update_project(%Project{} = project, attrs) do
150
    project
151
    |> Project.changeset(attrs)
152
    |> Repo.update()
256
  defp number_conflict?(changeset) do
257
    Enum.any?(changeset.errors, fn {_field, {_message, options}} ->
258
      options[:constraint_name] == "projects_repository_id_number_index"
259
    end)
153 260
  end
154 261
155
  @doc """
156
  Deletes a project.
262
  defp put_owner(attrs, nil), do: attrs
157 263
158
  ## Examples
159
160
      iex> delete_project(project)
161
      {:ok, %Project{}}
162
163
      iex> delete_project(project)
164
      {:error, %Ecto.Changeset{}}
165
166
  """
167
  def delete_project(%Project{} = project) do
168
    Repo.delete(project)
264
  defp put_owner(attrs, %User{} = owner) do
265
    attrs
266
    |> Map.put("owner", owner.github_login)
267
    |> Map.put("owner_user_id", owner.id)
169 268
  end
170 269
171
  @doc """
172
  Returns an `%Ecto.Changeset{}` for tracking project changes.
173
174
  ## Examples
175
176
      iex> change_project(project)
177
      %Ecto.Changeset{data: %Project{}}
178
179
  """
180
  def change_project(%Project{} = project, attrs \\ %{}) do
181
    Project.changeset(project, attrs)
270
  defp to_string_map(attrs) do
271
    for {key, value} <- attrs, into: %{}, do: {to_string(key), value}
182 272
  end
183 273
end
lib/openagents/projects/project.ex modified +10 -2

@@ -2,11 +2,15 @@ defmodule OpenAgents.Projects.Project do

2 2
  use Ecto.Schema
3 3
  import Ecto.Changeset
4 4
5
  alias OpenAgents.Repositories.Repository
6
5 7
  schema "projects" do
6 8
    field :number, :integer
7 9
    field :title, :string
8 10
    field :owner, :string
9 11
    field :state, :string, default: "open"
12
    belongs_to :repository, Repository, type: :binary_id
13
    belongs_to :owner_user, OpenAgents.Accounts.User, type: :binary_id
10 14
11 15
    timestamps(type: :utc_datetime)
12 16
  end

@@ -14,7 +18,11 @@ defmodule OpenAgents.Projects.Project do

14 18
  @doc false
15 19
  def changeset(project, attrs) do
16 20
    project
17
    |> cast(attrs, [:number, :title, :owner, :state])
18
    |> validate_required([:number, :title, :owner, :state])
21
    |> cast(attrs, [:number, :title, :owner, :state, :repository_id, :owner_user_id])
22
    |> validate_required([:number, :title, :owner, :state, :repository_id])
23
    |> unique_constraint([:repository_id, :number])
24
    |> foreign_key_constraint(:repository_id)
25
    |> foreign_key_constraint(:owner_user_id)
26
    |> foreign_key_constraint(:owner_user_id, name: :projects_owner_membership_fkey)
19 27
  end
20 28
end
lib/openagents/repositories.ex added +108

@@ -0,0 +1,108 @@

1
defmodule OpenAgents.Repositories do
2
  @moduledoc "Canonical repository identity and membership authorization."
3
4
  import Ecto.Query, warn: false
5
6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.Repo
8
  alias OpenAgents.Repositories.{Membership, Repository}
9
10
  @initial_owner "OpenAgentsInc"
11
  @initial_name "openagents.com"
12
  @writable_roles ~w(owner maintainer contributor)
13
14
  def initial_path, do: {@initial_owner, @initial_name}
15
16
  def initial_repository! do
17
    get_by_path!(@initial_owner, @initial_name)
18
  end
19
20
  def get_by_path!(owner, name) when is_binary(owner) and is_binary(name) do
21
    Repo.one!(
22
      from repository in Repository,
23
        where:
24
          repository.owner_key == ^String.downcase(owner) and
25
            repository.name_key == ^String.downcase(name)
26
    )
27
  end
28
29
  def get_public_by_path!(owner, name) when is_binary(owner) and is_binary(name) do
30
    Repo.one!(
31
      from repository in Repository,
32
        where:
33
          repository.owner_key == ^String.downcase(owner) and
34
            repository.name_key == ^String.downcase(name) and repository.visibility == "public"
35
    )
36
  end
37
38
  def get_writable_by_path!(owner, name, %User{id: user_id}) do
39
    Repo.one!(
40
      from repository in Repository,
41
        join: membership in Membership,
42
        on:
43
          membership.repository_id == repository.id and membership.user_id == ^user_id and
44
            membership.role in ^@writable_roles,
45
        join: user in User,
46
        on: user.id == membership.user_id and user.status == "active",
47
        where:
48
          repository.owner_key == ^String.downcase(owner) and
49
            repository.name_key == ^String.downcase(name)
50
    )
51
  end
52
53
  def create_repository(attrs) do
54
    %Repository{}
55
    |> Repository.changeset(attrs)
56
    |> Repo.insert()
57
  end
58
59
  def add_member(%Repository{} = repository, %User{} = user, role \\ "contributor") do
60
    %Membership{}
61
    |> Membership.changeset(%{repository_id: repository.id, user_id: user.id, role: role})
62
    |> Repo.insert(
63
      on_conflict: {:replace, [:role, :updated_at]},
64
      conflict_target: [:repository_id, :user_id],
65
      returning: true
66
    )
67
  end
68
69
  def ensure_initial_membership(%User{} = user) do
70
    add_member(initial_repository!(), user)
71
  end
72
73
  def writable?(%Repository{id: repository_id}, %User{id: user_id}) do
74
    Repo.exists?(
75
      from membership in Membership,
76
        join: user in User,
77
        on: user.id == membership.user_id and user.status == "active",
78
        where:
79
          membership.repository_id == ^repository_id and membership.user_id == ^user_id and
80
            membership.role in ^@writable_roles
81
    )
82
  end
83
84
  def list_assignable_users(%Repository{id: repository_id}) do
85
    Repo.all(
86
      from user in User,
87
        join: membership in Membership,
88
        on: membership.user_id == user.id,
89
        where:
90
          membership.repository_id == ^repository_id and
91
            membership.role in ^@writable_roles and user.status == "active",
92
        order_by: [asc: fragment("lower(?)", user.github_login)]
93
    )
94
  end
95
96
  def get_assignable_user_by_login!(%Repository{id: repository_id}, login)
97
      when is_binary(login) do
98
    Repo.one!(
99
      from user in User,
100
        join: membership in Membership,
101
        on: membership.user_id == user.id,
102
        where:
103
          membership.repository_id == ^repository_id and
104
            membership.role in ^@writable_roles and user.status == "active" and
105
            fragment("lower(?)", user.github_login) == ^String.downcase(URI.decode(login))
106
    )
107
  end
108
end
lib/openagents/repositories/membership.ex added +29

@@ -0,0 +1,29 @@

1
defmodule OpenAgents.Repositories.Membership do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key false
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "repository_memberships" do
12
    belongs_to :repository, OpenAgents.Repositories.Repository, primary_key: true
13
    belongs_to :user, OpenAgents.Accounts.User, primary_key: true
14
    field :role, :string
15
16
    timestamps()
17
  end
18
19
  def changeset(membership, attrs) do
20
    membership
21
    |> cast(attrs, [:repository_id, :user_id, :role])
22
    |> validate_required([:repository_id, :user_id, :role])
23
    |> validate_inclusion(:role, ~w(owner maintainer contributor viewer))
24
    |> unique_constraint([:repository_id, :user_id])
25
    |> foreign_key_constraint(:repository_id)
26
    |> foreign_key_constraint(:user_id)
27
    |> check_constraint(:role, name: :repository_memberships_role_check)
28
  end
29
end
lib/openagents/repositories/repository.ex added +52

@@ -0,0 +1,52 @@

1
defmodule OpenAgents.Repositories.Repository do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "repositories" do
12
    field :owner, :string
13
    field :name, :string
14
    field :owner_key, :string
15
    field :name_key, :string
16
    field :visibility, :string, default: "private"
17
    field :default_branch, :string, default: "main"
18
19
    has_many :memberships, OpenAgents.Repositories.Membership
20
21
    timestamps()
22
  end
23
24
  def changeset(repository, attrs) do
25
    repository
26
    |> cast(attrs, [:owner, :name, :visibility, :default_branch])
27
    |> validate_required([:owner, :name, :visibility, :default_branch])
28
    |> validate_inclusion(:visibility, ~w(public private))
29
    |> validate_length(:owner, min: 1, max: 100)
30
    |> validate_length(:name, min: 1, max: 100)
31
    |> validate_length(:default_branch, min: 1, max: 255)
32
    |> normalize_path()
33
    |> unique_constraint([:owner_key, :name_key])
34
    |> check_constraint(:visibility, name: :repositories_visibility_check)
35
    |> check_constraint(:owner_key, name: :repositories_normalized_path_check)
36
  end
37
38
  defp normalize_path(changeset) do
39
    owner = get_field(changeset, :owner)
40
    name = get_field(changeset, :name)
41
42
    changeset
43
    |> maybe_put_key(:owner_key, owner)
44
    |> maybe_put_key(:name_key, name)
45
  end
46
47
  defp maybe_put_key(changeset, _field, value) when not is_binary(value), do: changeset
48
49
  defp maybe_put_key(changeset, field, value) do
50
    put_change(changeset, field, String.downcase(value))
51
  end
52
end
lib/openagents_web/components/layouts.ex modified +2 -2

@@ -291,7 +291,7 @@ defmodule OpenAgentsWeb.Layouts do

291 291
292 292
        <div class="sidebar-row">
293 293
          <.link
294
            navigate={~p"/OpenAgents/openagents/issues"}
294
            navigate={~p"/OpenAgentsInc/openagents.com/issues"}
295 295
            class="sidebar-row__hit"
296 296
            aria-label="Issues"
297 297
          ></.link>

@@ -303,7 +303,7 @@ defmodule OpenAgentsWeb.Layouts do

303 303
304 304
        <div class="sidebar-row">
305 305
          <.link
306
            navigate={~p"/OpenAgents/openagents/projects"}
306
            navigate={~p"/OpenAgentsInc/openagents.com/projects"}
307 307
            class="sidebar-row__hit"
308 308
            aria-label="Projects"
309 309
          ></.link>
lib/openagents_web/controllers/assignee_controller.ex modified +33 -4

@@ -1,11 +1,40 @@

1 1
defmodule OpenAgentsWeb.AssigneeController do
2 2
  use OpenAgentsWeb, :controller
3 3
4
  def index(conn, _params) do
5
    json(conn, %{assignees: []})
4
  alias OpenAgents.Repositories
5
6
  def index(conn, %{"owner" => owner, "repo" => repo}) do
7
    assignees =
8
      owner
9
      |> Repositories.get_public_by_path!(repo)
10
      |> Repositories.list_assignable_users()
11
      |> Enum.map(&projection/1)
12
13
    json(conn, %{assignees: assignees})
14
  rescue
15
    Ecto.NoResultsError -> not_found(conn)
16
  end
17
18
  def show(conn, %{"owner" => owner, "repo" => repo, "assignee" => login}) do
19
    assignee =
20
      owner
21
      |> Repositories.get_public_by_path!(repo)
22
      |> Repositories.get_assignable_user_by_login!(login)
23
24
    json(conn, projection(assignee))
25
  rescue
26
    Ecto.NoResultsError -> not_found(conn)
27
  end
28
29
  defp projection(user) do
30
    %{
31
      id: user.github_id,
32
      login: user.github_login,
33
      avatar_url: user.github_avatar_url
34
    }
6 35
  end
7 36
8
  def show(conn, %{"assignee" => _assignee}) do
9
    send_resp(conn, :not_found, "")
37
  defp not_found(conn) do
38
    conn |> put_status(:not_found) |> json(%{message: "Not Found"})
10 39
  end
11 40
end
lib/openagents_web/controllers/auth_controller.ex modified +2 -1

@@ -1,7 +1,7 @@

1 1
defmodule OpenAgentsWeb.AuthController do
2 2
  use OpenAgentsWeb, :controller
3 3
4
  alias OpenAgents.{Accounts, GitHubOAuth}
4
  alias OpenAgents.{Accounts, GitHubOAuth, Repositories}
5 5
6 6
  @attempt_session_key "github_oauth_attempt"
7 7

@@ -30,6 +30,7 @@ defmodule OpenAgentsWeb.AuthController do

30 30
           GitHubOAuth.exchange_and_fetch(code, verifier),
31 31
         {:ok, user} <- Accounts.upsert_github_user(profile),
32 32
         {:ok, active_user} <- Accounts.get_active_user(user.id),
33
         {:ok, _membership} <- Repositories.ensure_initial_membership(active_user),
33 34
         {:ok, _stored} <-
34 35
           Accounts.store_github_token(active_user, access_token, granted_scopes) do
35 36
      conn
lib/openagents_web/controllers/comment_controller.ex modified +18 -14

@@ -3,14 +3,15 @@ defmodule OpenAgentsWeb.CommentController do

3 3
4 4
  alias OpenAgents.Issues
5 5
  alias OpenAgents.Issues.Comment
6
  alias OpenAgents.Repositories
6 7
7 8
  def index(conn, %{
8
        "owner" => _owner,
9
        "repo" => _repo,
9
        "owner" => owner,
10
        "repo" => repo,
10 11
        "issue_number" => issue_number
11 12
      }) do
12
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
13
    comments = Issues.list_comments(issue.id)
13
    issue = Issues.get_issue_by_path!(owner, repo, String.to_integer(issue_number))
14
    comments = Issues.list_comments(issue)
14 15
    render(conn, :index, comments: comments)
15 16
  rescue
16 17
    Ecto.NoResultsError ->

@@ -22,14 +23,15 @@ defmodule OpenAgentsWeb.CommentController do

22 23
  def create(
23 24
        conn,
24 25
        %{
25
          "owner" => _owner,
26
          "repo" => _repo,
26
          "owner" => owner,
27
          "repo" => repo,
27 28
          "issue_number" => issue_number
28 29
        } = params
29 30
      ) do
30
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
31
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
32
    issue = Issues.get_issue_by_number!(repository, String.to_integer(issue_number))
31 33
32
    case Issues.create_comment(Map.put(params, :issue_id, issue.id)) do
34
    case Issues.create_comment(issue, params, conn.assigns.current_user) do
33 35
      {:ok, %Comment{} = comment} ->
34 36
        conn
35 37
        |> put_status(:created)

@@ -47,8 +49,8 @@ defmodule OpenAgentsWeb.CommentController do

47 49
      |> json(%{message: "Not Found"})
48 50
  end
49 51
50
  def show(conn, %{"owner" => _owner, "repo" => _repo, "id" => id}) do
51
    comment = Issues.get_comment!(String.to_integer(id))
52
  def show(conn, %{"owner" => owner, "repo" => repo, "id" => id}) do
53
    comment = Issues.get_comment_by_path!(owner, repo, String.to_integer(id))
52 54
    render(conn, :show, comment: comment)
53 55
  rescue
54 56
    Ecto.NoResultsError ->

@@ -57,8 +59,9 @@ defmodule OpenAgentsWeb.CommentController do

57 59
      |> json(%{message: "Not Found"})
58 60
  end
59 61
60
  def update(conn, %{"owner" => _owner, "repo" => _repo, "id" => id} = params) do
61
    comment = Issues.get_comment!(String.to_integer(id))
62
  def update(conn, %{"owner" => owner, "repo" => repo, "id" => id} = params) do
63
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
64
    comment = Issues.get_comment!(repository, String.to_integer(id))
62 65
63 66
    case Issues.update_comment(comment, params) do
64 67
      {:ok, %Comment{} = comment} ->

@@ -76,8 +79,9 @@ defmodule OpenAgentsWeb.CommentController do

76 79
      |> json(%{message: "Not Found"})
77 80
  end
78 81
79
  def delete(conn, %{"owner" => _owner, "repo" => _repo, "id" => id}) do
80
    comment = Issues.get_comment!(String.to_integer(id))
82
  def delete(conn, %{"owner" => owner, "repo" => repo, "id" => id}) do
83
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
84
    comment = Issues.get_comment!(repository, String.to_integer(id))
81 85
82 86
    case Issues.delete_comment(comment) do
83 87
      {:ok, :ok} ->
lib/openagents_web/controllers/issue_assignee_controller.ex modified +11 -8

@@ -2,9 +2,10 @@ defmodule OpenAgentsWeb.IssueAssigneeController do

2 2
  use OpenAgentsWeb, :controller
3 3
4 4
  alias OpenAgents.Issues
5
  alias OpenAgents.Repositories
5 6
6
  def index(conn, %{"owner" => _owner, "repo" => _repo, "issue_number" => issue_number}) do
7
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
7
  def index(conn, %{"owner" => owner, "repo" => repo, "issue_number" => issue_number}) do
8
    issue = Issues.get_issue_by_path!(owner, repo, String.to_integer(issue_number))
8 9
    json(conn, %{assignees: issue.assignees || []})
9 10
  rescue
10 11
    Ecto.NoResultsError ->

@@ -16,12 +17,13 @@ defmodule OpenAgentsWeb.IssueAssigneeController do

16 17
  def create(
17 18
        conn,
18 19
        %{
19
          "owner" => _owner,
20
          "repo" => _repo,
20
          "owner" => owner,
21
          "repo" => repo,
21 22
          "issue_number" => issue_number
22 23
        } = params
23 24
      ) do
24
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
25
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
26
    issue = Issues.get_issue_by_number!(repository, String.to_integer(issue_number))
25 27
    logins = params["assignees"] || []
26 28
27 29
    case Issues.add_assignees(issue, logins) do

@@ -43,12 +45,13 @@ defmodule OpenAgentsWeb.IssueAssigneeController do

43 45
  def delete(
44 46
        conn,
45 47
        %{
46
          "owner" => _owner,
47
          "repo" => _repo,
48
          "owner" => owner,
49
          "repo" => repo,
48 50
          "issue_number" => issue_number
49 51
        } = params
50 52
      ) do
51
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
53
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
54
    issue = Issues.get_issue_by_number!(repository, String.to_integer(issue_number))
52 55
    logins = params["assignees"] || []
53 56
54 57
    case Issues.remove_assignees(issue, logins) do
lib/openagents_web/controllers/issue_controller.ex modified +17 -4

@@ -3,15 +3,21 @@ defmodule OpenAgentsWeb.IssueController do

3 3
4 4
  alias OpenAgents.Issues
5 5
  alias OpenAgents.Issues.Issue
6
  alias OpenAgents.Repositories
6 7
7 8
  def index(conn, %{"owner" => owner, "repo" => repo} = params) do
8 9
    state = Map.get(params, "state", "open")
9
    issues = Issues.list_issues(state: state)
10
    repository = Repositories.get_public_by_path!(owner, repo)
11
    issues = Issues.list_issues(repository, state: state)
10 12
    render(conn, :index, issues: issues, owner: owner, repo: repo)
13
  rescue
14
    Ecto.NoResultsError -> not_found(conn)
11 15
  end
12 16
13 17
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
14
    case Issues.create_issue(params) do
18
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
19
20
    case Issues.create_issue(repository, params, conn.assigns.current_user) do
15 21
      {:ok, %Issue{} = issue} ->
16 22
        conn
17 23
        |> put_status(:created)

@@ -22,6 +28,8 @@ defmodule OpenAgentsWeb.IssueController do

22 28
        |> put_status(:unprocessable_entity)
23 29
        |> render(:error, changeset: changeset)
24 30
    end
31
  rescue
32
    Ecto.NoResultsError -> not_found(conn)
25 33
  end
26 34
27 35
  def show(conn, %{

@@ -29,7 +37,7 @@ defmodule OpenAgentsWeb.IssueController do

29 37
        "repo" => repo,
30 38
        "issue_number" => issue_number
31 39
      }) do
32
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
40
    issue = Issues.get_issue_by_path!(owner, repo, String.to_integer(issue_number))
33 41
    render(conn, :show, issue: issue, owner: owner, repo: repo)
34 42
  rescue
35 43
    Ecto.NoResultsError ->

@@ -46,7 +54,8 @@ defmodule OpenAgentsWeb.IssueController do

46 54
          "issue_number" => issue_number
47 55
        } = params
48 56
      ) do
49
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
57
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
58
    issue = Issues.get_issue_by_number!(repository, String.to_integer(issue_number))
50 59
51 60
    case Issues.update_issue(issue, params) do
52 61
      {:ok, %Issue{} = issue} ->

@@ -63,4 +72,8 @@ defmodule OpenAgentsWeb.IssueController do

63 72
      |> put_status(:not_found)
64 73
      |> json(%{message: "Not Found"})
65 74
  end
75
76
  defp not_found(conn) do
77
    conn |> put_status(:not_found) |> json(%{message: "Not Found"})
78
  end
66 79
end
lib/openagents_web/controllers/issue_label_controller.ex modified +11 -8

@@ -2,9 +2,10 @@ defmodule OpenAgentsWeb.IssueLabelController do

2 2
  use OpenAgentsWeb, :controller
3 3
4 4
  alias OpenAgents.Issues
5
  alias OpenAgents.Repositories
5 6
6
  def index(conn, %{"owner" => _owner, "repo" => _repo, "issue_number" => issue_number}) do
7
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
7
  def index(conn, %{"owner" => owner, "repo" => repo, "issue_number" => issue_number}) do
8
    issue = Issues.get_issue_by_path!(owner, repo, String.to_integer(issue_number))
8 9
    json(conn, %{labels: issue.labels || []})
9 10
  rescue
10 11
    Ecto.NoResultsError ->

@@ -16,12 +17,13 @@ defmodule OpenAgentsWeb.IssueLabelController do

16 17
  def create(
17 18
        conn,
18 19
        %{
19
          "owner" => _owner,
20
          "repo" => _repo,
20
          "owner" => owner,
21
          "repo" => repo,
21 22
          "issue_number" => issue_number
22 23
        } = params
23 24
      ) do
24
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
25
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
26
    issue = Issues.get_issue_by_number!(repository, String.to_integer(issue_number))
25 27
    names = params["labels"] || []
26 28
27 29
    case Issues.add_labels(issue, names) do

@@ -41,12 +43,13 @@ defmodule OpenAgentsWeb.IssueLabelController do

41 43
  end
42 44
43 45
  def delete(conn, %{
44
        "owner" => _owner,
45
        "repo" => _repo,
46
        "owner" => owner,
47
        "repo" => repo,
46 48
        "issue_number" => issue_number,
47 49
        "name" => name
48 50
      }) do
49
    issue = Issues.get_issue_by_number!(String.to_integer(issue_number))
51
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
52
    issue = Issues.get_issue_by_number!(repository, String.to_integer(issue_number))
50 53
51 54
    case Issues.remove_label(issue, name) do
52 55
      {:ok, %Issues.Issue{} = issue} ->
lib/openagents_web/controllers/label_controller.ex modified +20 -6

@@ -3,14 +3,20 @@ defmodule OpenAgentsWeb.LabelController do

3 3
4 4
  alias OpenAgents.Labels
5 5
  alias OpenAgents.Labels.Label
6
  alias OpenAgents.Repositories
6 7
7 8
  def index(conn, %{"owner" => owner, "repo" => repo}) do
8
    labels = Labels.list_labels()
9
    repository = Repositories.get_public_by_path!(owner, repo)
10
    labels = Labels.list_labels(repository)
9 11
    render(conn, :index, labels: labels, owner: owner, repo: repo)
12
  rescue
13
    Ecto.NoResultsError -> not_found(conn)
10 14
  end
11 15
12 16
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
13
    case Labels.create_label(params) do
17
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
18
19
    case Labels.create_label(repository, params) do
14 20
      {:ok, %Label{} = label} ->
15 21
        conn
16 22
        |> put_status(:created)

@@ -21,10 +27,12 @@ defmodule OpenAgentsWeb.LabelController do

21 27
        |> put_status(:unprocessable_entity)
22 28
        |> render(:error, changeset: changeset)
23 29
    end
30
  rescue
31
    Ecto.NoResultsError -> not_found(conn)
24 32
  end
25 33
26 34
  def show(conn, %{"owner" => owner, "repo" => repo, "name" => name}) do
27
    label = Labels.get_label_by_name!(name)
35
    label = Labels.get_label_by_path!(owner, repo, name)
28 36
    render(conn, :show, label: label, owner: owner, repo: repo)
29 37
  rescue
30 38
    Ecto.NoResultsError ->

@@ -34,7 +42,8 @@ defmodule OpenAgentsWeb.LabelController do

34 42
  end
35 43
36 44
  def update(conn, %{"owner" => owner, "repo" => repo, "name" => name} = params) do
37
    label = Labels.get_label_by_name!(name)
45
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
46
    label = Labels.get_label_by_name!(repository, name)
38 47
39 48
    case Labels.update_label(label, params) do
40 49
      {:ok, %Label{} = label} ->

@@ -52,8 +61,9 @@ defmodule OpenAgentsWeb.LabelController do

52 61
      |> json(%{message: "Not Found"})
53 62
  end
54 63
55
  def delete(conn, %{"owner" => _owner, "repo" => _repo, "name" => name}) do
56
    label = Labels.get_label_by_name!(name)
64
  def delete(conn, %{"owner" => owner, "repo" => repo, "name" => name}) do
65
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
66
    label = Labels.get_label_by_name!(repository, name)
57 67
58 68
    case Labels.delete_label(label) do
59 69
      {:ok, %Label{}} ->

@@ -70,4 +80,8 @@ defmodule OpenAgentsWeb.LabelController do

70 80
      |> put_status(:not_found)
71 81
      |> json(%{message: "Not Found"})
72 82
  end
83
84
  defp not_found(conn) do
85
    conn |> put_status(:not_found) |> json(%{message: "Not Found"})
86
  end
73 87
end
lib/openagents_web/controllers/milestone_controller.ex modified +27 -7

@@ -3,14 +3,20 @@ defmodule OpenAgentsWeb.MilestoneController do

3 3
4 4
  alias OpenAgents.Milestones
5 5
  alias OpenAgents.Milestones.Milestone
6
  alias OpenAgents.Repositories
6 7
7 8
  def index(conn, %{"owner" => owner, "repo" => repo}) do
8
    milestones = Milestones.list_milestones()
9
    repository = Repositories.get_public_by_path!(owner, repo)
10
    milestones = Milestones.list_milestones(repository)
9 11
    render(conn, :index, milestones: milestones, owner: owner, repo: repo)
12
  rescue
13
    Ecto.NoResultsError -> not_found(conn)
10 14
  end
11 15
12 16
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
13
    case Milestones.create_milestone(params) do
17
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
18
19
    case Milestones.create_milestone(repository, params) do
14 20
      {:ok, %Milestone{} = milestone} ->
15 21
        conn
16 22
        |> put_status(:created)

@@ -21,6 +27,8 @@ defmodule OpenAgentsWeb.MilestoneController do

21 27
        |> put_status(:unprocessable_entity)
22 28
        |> render(:error, changeset: changeset)
23 29
    end
30
  rescue
31
    Ecto.NoResultsError -> not_found(conn)
24 32
  end
25 33
26 34
  def show(conn, %{

@@ -28,7 +36,9 @@ defmodule OpenAgentsWeb.MilestoneController do

28 36
        "repo" => repo,
29 37
        "milestone_number" => milestone_number
30 38
      }) do
31
    milestone = Milestones.get_milestone_by_number!(String.to_integer(milestone_number))
39
    milestone =
40
      Milestones.get_milestone_by_path!(owner, repo, String.to_integer(milestone_number))
41
32 42
    render(conn, :show, milestone: milestone, owner: owner, repo: repo)
33 43
  rescue
34 44
    Ecto.NoResultsError ->

@@ -45,7 +55,10 @@ defmodule OpenAgentsWeb.MilestoneController do

45 55
          "milestone_number" => milestone_number
46 56
        } = params
47 57
      ) do
48
    milestone = Milestones.get_milestone_by_number!(String.to_integer(milestone_number))
58
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
59
60
    milestone =
61
      Milestones.get_milestone_by_number!(repository, String.to_integer(milestone_number))
49 62
50 63
    case Milestones.update_milestone(milestone, params) do
51 64
      {:ok, %Milestone{} = milestone} ->

@@ -64,11 +77,14 @@ defmodule OpenAgentsWeb.MilestoneController do

64 77
  end
65 78
66 79
  def delete(conn, %{
67
        "owner" => _owner,
68
        "repo" => _repo,
80
        "owner" => owner,
81
        "repo" => repo,
69 82
        "milestone_number" => milestone_number
70 83
      }) do
71
    milestone = Milestones.get_milestone_by_number!(String.to_integer(milestone_number))
84
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
85
86
    milestone =
87
      Milestones.get_milestone_by_number!(repository, String.to_integer(milestone_number))
72 88
73 89
    case Milestones.delete_milestone(milestone) do
74 90
      {:ok, %Milestone{}} ->

@@ -85,4 +101,8 @@ defmodule OpenAgentsWeb.MilestoneController do

85 101
      |> put_status(:not_found)
86 102
      |> json(%{message: "Not Found"})
87 103
  end
104
105
  defp not_found(conn) do
106
    conn |> put_status(:not_found) |> json(%{message: "Not Found"})
107
  end
88 108
end
lib/openagents_web/controllers/project_controller.ex modified +57 -21

@@ -3,21 +3,27 @@ defmodule OpenAgentsWeb.ProjectController do

3 3
4 4
  alias OpenAgents.Projects
5 5
  alias OpenAgents.Projects.Project
6
  alias OpenAgents.Repositories
6 7
7 8
  def index(conn, %{"username" => username}) do
8
    projects =
9
      Projects.list_projects()
10
      |> Enum.filter(&(&1.owner == username))
9
    projects = Projects.list_projects_by_owner(username)
11 10
12 11
    render(conn, :index, projects: projects)
13 12
  end
14 13
15 14
  def create(conn, %{"owner" => owner} = params) do
16
    attrs =
17
      params
18
      |> Map.put("owner", owner)
15
    user = conn.assigns.current_user
19 16
20
    case Projects.create_project(attrs) do
17
    if String.downcase(owner) != String.downcase(user.github_login) do
18
      raise Ecto.NoResultsError, queryable: Project
19
    end
20
21
    {repository_owner, repository_name} = Repositories.initial_path()
22
23
    repository =
24
      Repositories.get_writable_by_path!(repository_owner, repository_name, user)
25
26
    case Projects.create_project(repository, params, user) do
21 27
      {:ok, %Project{} = project} ->
22 28
        conn
23 29
        |> put_status(:created)

@@ -28,10 +34,14 @@ defmodule OpenAgentsWeb.ProjectController do

28 34
        |> put_status(:unprocessable_entity)
29 35
        |> render(:error, changeset: changeset)
30 36
    end
37
  rescue
38
    Ecto.NoResultsError -> not_found(conn)
31 39
  end
32 40
33
  def show(conn, %{"username" => _username, "project_number" => project_number}) do
34
    project = Projects.get_project_by_number!(String.to_integer(project_number))
41
  def show(conn, %{"username" => username, "project_number" => project_number}) do
42
    project =
43
      Projects.get_project_by_owner_and_number!(username, String.to_integer(project_number))
44
35 45
    render(conn, :show, project: project)
36 46
  rescue
37 47
    Ecto.NoResultsError ->

@@ -41,11 +51,13 @@ defmodule OpenAgentsWeb.ProjectController do

41 51
  end
42 52
43 53
  def items(conn, %{
44
        "username" => _username,
54
        "username" => username,
45 55
        "project_number" => project_number
46 56
      }) do
47
    project = Projects.get_project_by_number!(String.to_integer(project_number))
48
    items = Projects.list_project_items(project.id)
57
    project =
58
      Projects.get_project_by_owner_and_number!(username, String.to_integer(project_number))
59
60
    items = Projects.list_project_items(project)
49 61
    render(conn, :items, items: items)
50 62
  rescue
51 63
    Ecto.NoResultsError ->

@@ -57,11 +69,14 @@ defmodule OpenAgentsWeb.ProjectController do

57 69
  def create_item(
58 70
        conn,
59 71
        %{
60
          "username" => _username,
72
          "username" => username,
61 73
          "project_number" => project_number
62 74
        } = params
63 75
      ) do
64
    project = Projects.get_project_by_number!(String.to_integer(project_number))
76
    authorize_owner!(conn.assigns.current_user, username)
77
78
    project =
79
      Projects.get_project_by_owner_and_number!(username, String.to_integer(project_number))
65 80
66 81
    case cast_issue_number(params["issue_number"]) do
67 82
      :error ->

@@ -70,7 +85,7 @@ defmodule OpenAgentsWeb.ProjectController do

70 85
      {:ok, issue_number} ->
71 86
        params = Map.put(params, "issue_number", issue_number)
72 87
73
        case Projects.create_project_item(params, project.id) do
88
        case Projects.create_project_item(params, project) do
74 89
          {:ok, item} ->
75 90
            conn
76 91
            |> put_status(:created)

@@ -92,12 +107,19 @@ defmodule OpenAgentsWeb.ProjectController do

92 107
  def update_item(
93 108
        conn,
94 109
        %{
95
          "username" => _username,
96
          "project_number" => _project_number,
110
          "username" => username,
111
          "project_number" => project_number,
97 112
          "item_id" => item_id
98 113
        } = params
99 114
      ) do
100
    item = Projects.get_project_item!(String.to_integer(item_id))
115
    authorize_owner!(conn.assigns.current_user, username)
116
117
    item =
118
      Projects.get_project_item_by_owner!(
119
        username,
120
        String.to_integer(project_number),
121
        String.to_integer(item_id)
122
      )
101 123
102 124
    if is_map(Map.get(params, "values", %{})) do
103 125
      case Projects.update_project_item(item, params) do

@@ -120,11 +142,13 @@ defmodule OpenAgentsWeb.ProjectController do

120 142
  end
121 143
122 144
  def fields(conn, %{
123
        "username" => _username,
145
        "username" => username,
124 146
        "project_number" => project_number
125 147
      }) do
126
    project = Projects.get_project_by_number!(String.to_integer(project_number))
127
    fields = Projects.list_project_fields(project.id)
148
    project =
149
      Projects.get_project_by_owner_and_number!(username, String.to_integer(project_number))
150
151
    fields = Projects.list_project_fields(project)
128 152
    render(conn, :fields, fields: fields)
129 153
  rescue
130 154
    Ecto.NoResultsError ->

@@ -149,4 +173,16 @@ defmodule OpenAgentsWeb.ProjectController do

149 173
    |> put_status(:unprocessable_entity)
150 174
    |> json(%{errors: errors})
151 175
  end
176
177
  defp authorize_owner!(user, username) do
178
    if String.downcase(user.github_login) != String.downcase(username) do
179
      raise Ecto.NoResultsError, queryable: Project
180
    end
181
182
    :ok
183
  end
184
185
  defp not_found(conn) do
186
    conn |> put_status(:not_found) |> json(%{message: "Not Found"})
187
  end
152 188
end
lib/openagents_web/live/assignee_index_live.ex modified +5 -1

@@ -5,10 +5,13 @@ defmodule OpenAgentsWeb.AssigneeIndexLive do

5 5
  use OpenAgentsWeb, :live_view
6 6
7 7
  alias OpenAgents.Issues
8
  alias OpenAgents.Repositories
8 9
9 10
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
11
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
12
10 13
    assignees =
11
      Issues.list_issues(state: "all")
14
      Issues.list_issues(repository, state: "all")
12 15
      |> Enum.flat_map(&(&1.assignees || []))
13 16
      |> Enum.frequencies_by(& &1["login"])
14 17
      |> Enum.sort_by(fn {_, count} -> -count end)

@@ -18,6 +21,7 @@ defmodule OpenAgentsWeb.AssigneeIndexLive do

18 21
     |> assign(:current_scope, socket.assigns[:current_scope])
19 22
     |> assign(:owner, owner)
20 23
     |> assign(:repo, repo)
24
     |> assign(:repository, repository)
21 25
     |> assign(:assignees, assignees)}
22 26
  end
23 27
lib/openagents_web/live/home_live.ex modified +2 -2

@@ -24,12 +24,12 @@ defmodule OpenAgentsWeb.HomeLive do

24 24
              <%= if @current_user do %>
25 25
                <.button
26 26
                  id="home-cta-create"
27
                  navigate={~p"/OpenAgents/openagents/issues/new"}
27
                  navigate={~p"/OpenAgentsInc/openagents.com/issues/new"}
28 28
                  variant={:primary}
29 29
                >
30 30
                  Create new issue
31 31
                </.button>
32
                <.button id="home-cta-browse" navigate={~p"/OpenAgents/openagents/issues"}>
32
                <.button id="home-cta-browse" navigate={~p"/OpenAgentsInc/openagents.com/issues"}>
33 33
                  View issues
34 34
                </.button>
35 35
              <% else %>
lib/openagents_web/live/issue_index_live.ex modified +6 -3

@@ -5,6 +5,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

5 5
  use OpenAgentsWeb, :live_view
6 6
7 7
  alias OpenAgents.Issues
8
  alias OpenAgents.Repositories
8 9
9 10
  def mount(_params, _session, socket) do
10 11
    {:ok, assign(socket, :current_scope, socket.assigns[:current_scope])}

@@ -12,14 +13,16 @@ defmodule OpenAgentsWeb.IssueIndexLive do

12 13
13 14
  def handle_params(%{"owner" => owner, "repo" => repo} = params, _url, socket) do
14 15
    state = params["state"] || "open"
15
    issues = Issues.list_issues(state: state)
16
    open_count = Issues.list_issues(state: "open") |> length()
17
    closed_count = Issues.list_issues(state: "closed") |> length()
16
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
17
    issues = Issues.list_issues(repository, state: state)
18
    open_count = Issues.list_issues(repository, state: "open") |> length()
19
    closed_count = Issues.list_issues(repository, state: "closed") |> length()
18 20
19 21
    socket =
20 22
      socket
21 23
      |> assign(:owner, owner)
22 24
      |> assign(:repo, repo)
25
      |> assign(:repository, repository)
23 26
      |> assign(:state, state)
24 27
      |> assign(:open_count, open_count)
25 28
      |> assign(:closed_count, closed_count)
lib/openagents_web/live/issue_new_live.ex modified +10 -6

@@ -8,8 +8,10 @@ defmodule OpenAgentsWeb.IssueNewLive do

8 8
  alias OpenAgents.Issues.Issue
9 9
  alias OpenAgents.Labels
10 10
  alias OpenAgents.Milestones
11
  alias OpenAgents.Repositories
11 12
12 13
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
14
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
13 15
    changeset = Issues.change_issue(%Issue{}, %{"title" => "", "body" => ""})
14 16
15 17
    socket =

@@ -17,14 +19,15 @@ defmodule OpenAgentsWeb.IssueNewLive do

17 19
      |> assign(:current_scope, socket.assigns[:current_scope])
18 20
      |> assign(:owner, owner)
19 21
      |> assign(:repo, repo)
22
      |> assign(:repository, repository)
20 23
      |> assign(:form, to_form(changeset))
21 24
      |> assign(
22 25
        :milestone_options,
23
        Enum.map(Milestones.list_milestones(), &{&1.title, &1.number})
26
        Enum.map(Milestones.list_milestones(repository), &{&1.title, &1.number})
24 27
      )
25 28
      |> assign(
26 29
        :label_options,
27
        Enum.map(Labels.list_labels(), &{&1.name, &1.name})
30
        Enum.map(Labels.list_labels(repository), &{&1.name, &1.name})
28 31
      )
29 32
30 33
    {:ok, socket}

@@ -36,10 +39,11 @@ defmodule OpenAgentsWeb.IssueNewLive do

36 39
    milestone = issue_params["milestone"] || ""
37 40
    labels = issue_params["labels"] || []
38 41
39
    case Issues.create_issue(%{
40
           "title" => title,
41
           "body" => body
42
         }) do
42
    case Issues.create_issue(
43
           socket.assigns.repository,
44
           %{"title" => title, "body" => body},
45
           socket.assigns.current_user
46
         ) do
43 47
      {:ok, issue} ->
44 48
        issue = apply_metadata(issue, labels, milestone)
45 49
lib/openagents_web/live/issue_show_live.ex modified +6 -7

@@ -6,16 +6,19 @@ defmodule OpenAgentsWeb.IssueShowLive do

6 6
7 7
  alias OpenAgents.Issues
8 8
  alias OpenAgents.Issues.Comment
9
  alias OpenAgents.Repositories
9 10
10 11
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
11
    issue = Issues.get_issue_by_number!(String.to_integer(number))
12
    comments = Issues.list_comments(issue.id)
12
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
13
    issue = Issues.get_issue_by_number!(repository, String.to_integer(number))
14
    comments = Issues.list_comments(issue)
13 15
14 16
    {:ok,
15 17
     socket
16 18
     |> assign(:current_scope, socket.assigns[:current_scope])
17 19
     |> assign(:owner, owner)
18 20
     |> assign(:repo, repo)
21
     |> assign(:repository, repository)
19 22
     |> assign(:issue, issue)
20 23
     |> assign(:comments, comments)
21 24
     |> assign(:editing, false)

@@ -81,11 +84,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

81 84
  def handle_event("add_comment", %{"comment" => %{"body" => body}}, socket) do
82 85
    issue = socket.assigns.issue
83 86
84
    case Issues.create_comment(%{
85
           issue_id: issue.id,
86
           body: body,
87
           user: %{"login" => "anonymous"}
88
         }) do
87
    case Issues.create_comment(issue, %{body: body}, socket.assigns.current_user) do
89 88
      {:ok, comment} ->
90 89
        {:noreply,
91 90
         socket
lib/openagents_web/live/label_index_live.ex modified +9 -5

@@ -6,23 +6,27 @@ defmodule OpenAgentsWeb.LabelIndexLive do

6 6
7 7
  alias OpenAgents.Labels
8 8
  alias OpenAgents.Labels.Label
9
  alias OpenAgents.Repositories
9 10
10 11
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
12
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
13
11 14
    {:ok,
12 15
     socket
13 16
     |> assign(:current_scope, socket.assigns[:current_scope])
14 17
     |> assign(:owner, owner)
15 18
     |> assign(:repo, repo)
16
     |> assign(:labels, Labels.list_labels())
19
     |> assign(:repository, repository)
20
     |> assign(:labels, Labels.list_labels(repository))
17 21
     |> assign(:form, to_form(Labels.change_label(%Label{})))}
18 22
  end
19 23
20 24
  def handle_event("save", %{"label" => label_params}, socket) do
21
    case Labels.create_label(label_params) do
25
    case Labels.create_label(socket.assigns.repository, label_params) do
22 26
      {:ok, _label} ->
23 27
        {:noreply,
24 28
         socket
25
         |> assign(:labels, Labels.list_labels())
29
         |> assign(:labels, Labels.list_labels(socket.assigns.repository))
26 30
         |> assign(:form, to_form(Labels.change_label(%Label{})))
27 31
         |> put_flash(:info, "Label created")}
28 32

@@ -32,12 +36,12 @@ defmodule OpenAgentsWeb.LabelIndexLive do

32 36
  end
33 37
34 38
  def handle_event("delete", %{"id" => id}, socket) do
35
    label = Labels.get_label!(String.to_integer(id))
39
    label = Labels.get_label!(socket.assigns.repository, String.to_integer(id))
36 40
    {:ok, _} = Labels.delete_label(label)
37 41
38 42
    {:noreply,
39 43
     socket
40
     |> assign(:labels, Labels.list_labels())
44
     |> assign(:labels, Labels.list_labels(socket.assigns.repository))
41 45
     |> put_flash(:info, "Label deleted")}
42 46
  end
43 47
lib/openagents_web/live/milestone_index_live.ex modified +14 -10

@@ -7,23 +7,27 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

7 7
  alias OpenAgents.Issues
8 8
  alias OpenAgents.Milestones
9 9
  alias OpenAgents.Milestones.Milestone
10
  alias OpenAgents.Repositories
10 11
11 12
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
13
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
14
12 15
    {:ok,
13 16
     socket
14 17
     |> assign(:current_scope, socket.assigns[:current_scope])
15 18
     |> assign(:owner, owner)
16 19
     |> assign(:repo, repo)
17
     |> assign(:milestones, milestones_with_stats())
20
     |> assign(:repository, repository)
21
     |> assign(:milestones, milestones_with_stats(repository))
18 22
     |> assign(:form, to_form(Milestones.change_milestone(%Milestone{})))}
19 23
  end
20 24
21 25
  def handle_event("save", %{"milestone" => milestone_params}, socket) do
22
    case Milestones.create_milestone(milestone_params) do
26
    case Milestones.create_milestone(socket.assigns.repository, milestone_params) do
23 27
      {:ok, _milestone} ->
24 28
        {:noreply,
25 29
         socket
26
         |> assign(:milestones, milestones_with_stats())
30
         |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
27 31
         |> assign(:form, to_form(Milestones.change_milestone(%Milestone{})))
28 32
         |> put_flash(:info, "Milestone created")}
29 33

@@ -33,29 +37,29 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

33 37
  end
34 38
35 39
  def handle_event("close", %{"id" => id}, socket) do
36
    milestone = Milestones.get_milestone!(String.to_integer(id))
40
    milestone = Milestones.get_milestone!(socket.assigns.repository, String.to_integer(id))
37 41
    {:ok, _} = Milestones.update_milestone(milestone, %{"state" => "closed"})
38 42
39 43
    {:noreply,
40 44
     socket
41
     |> assign(:milestones, milestones_with_stats())
45
     |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
42 46
     |> put_flash(:info, "Milestone closed")}
43 47
  end
44 48
45 49
  def handle_event("delete", %{"id" => id}, socket) do
46
    milestone = Milestones.get_milestone!(String.to_integer(id))
50
    milestone = Milestones.get_milestone!(socket.assigns.repository, String.to_integer(id))
47 51
    {:ok, _} = Milestones.delete_milestone(milestone)
48 52
49 53
    {:noreply,
50 54
     socket
51
     |> assign(:milestones, milestones_with_stats())
55
     |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
52 56
     |> put_flash(:info, "Milestone deleted")}
53 57
  end
54 58
55
  defp milestones_with_stats do
56
    all_issues = Issues.list_issues(state: "all")
59
  defp milestones_with_stats(repository) do
60
    all_issues = Issues.list_issues(repository, state: "all")
57 61
58
    Milestones.list_milestones()
62
    Milestones.list_milestones(repository)
59 63
    |> Enum.map(fn milestone ->
60 64
      open =
61 65
        Enum.count(all_issues, fn i ->
lib/openagents_web/live/project_index_live.ex modified +13 -13

@@ -6,26 +6,31 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

6 6
7 7
  alias OpenAgents.Projects
8 8
  alias OpenAgents.Projects.Project
9
  alias OpenAgents.Repositories
9 10
10 11
  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
12
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
13
11 14
    {:ok,
12 15
     socket
13 16
     |> assign(:current_scope, socket.assigns[:current_scope])
14 17
     |> assign(:owner, owner)
15 18
     |> assign(:repo, repo)
16
     |> assign(:projects, filter_projects(owner))
19
     |> assign(:repository, repository)
20
     |> assign(:projects, Projects.list_projects(repository))
17 21
     |> assign(:form, to_form(Projects.change_project(%Project{})))}
18 22
  end
19 23
20 24
  def handle_event("save", %{"project" => project_params}, socket) do
21
    owner = socket.assigns.owner
22
    params = Map.put(project_params, "owner", owner)
23
24
    case Projects.create_project(params) do
25
    case Projects.create_project(
26
           socket.assigns.repository,
27
           project_params,
28
           socket.assigns.current_user
29
         ) do
25 30
      {:ok, _project} ->
26 31
        {:noreply,
27 32
         socket
28
         |> assign(:projects, filter_projects(owner))
33
         |> assign(:projects, Projects.list_projects(socket.assigns.repository))
29 34
         |> assign(:form, to_form(Projects.change_project(%Project{})))
30 35
         |> put_flash(:info, "Project created")}
31 36

@@ -35,20 +40,15 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

35 40
  end
36 41
37 42
  def handle_event("delete", %{"id" => id}, socket) do
38
    project = Projects.get_project!(String.to_integer(id))
43
    project = Projects.get_project!(socket.assigns.repository, String.to_integer(id))
39 44
    {:ok, _} = Projects.delete_project(project)
40 45
41 46
    {:noreply,
42 47
     socket
43
     |> assign(:projects, filter_projects(socket.assigns.owner))
48
     |> assign(:projects, Projects.list_projects(socket.assigns.repository))
44 49
     |> put_flash(:info, "Project deleted")}
45 50
  end
46 51
47
  defp filter_projects(owner) do
48
    Projects.list_projects()
49
    |> Enum.filter(&(&1.owner == owner))
50
  end
51
52 52
  def render(assigns) do
53 53
    ~H"""
54 54
    <Layouts.app flash={@flash} current_scope={@current_scope}>
lib/openagents_web/live/project_show_live.ex modified +13 -10

@@ -7,19 +7,22 @@ defmodule OpenAgentsWeb.ProjectShowLive do

7 7
  alias OpenAgents.Issues
8 8
  alias OpenAgents.Projects
9 9
  alias OpenAgents.ProjectItems.ProjectItem
10
  alias OpenAgents.Repositories
10 11
11 12
  @statuses ["To Do", "In Progress", "Done"]
12 13
13 14
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
14
    project = Projects.get_project_by_number!(String.to_integer(number))
15
    items = project_items(project.id)
16
    issue_options = issue_options()
15
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
16
    project = Projects.get_project_by_number!(repository, String.to_integer(number))
17
    items = project_items(repository, project)
18
    issue_options = issue_options(repository)
17 19
18 20
    {:ok,
19 21
     socket
20 22
     |> assign(:current_scope, socket.assigns[:current_scope])
21 23
     |> assign(:owner, owner)
22 24
     |> assign(:repo, repo)
25
     |> assign(:repository, repository)
23 26
     |> assign(:project, project)
24 27
     |> assign(:items, items)
25 28
     |> assign(:issue_options, issue_options)

@@ -38,12 +41,12 @@ defmodule OpenAgentsWeb.ProjectShowLive do

38 41
39 42
    case Projects.create_project_item(
40 43
           %{"issue_number" => number, "values" => %{"Status" => status}},
41
           project.id
44
           project
42 45
         ) do
43 46
      {:ok, _item} ->
44 47
        {:noreply,
45 48
         socket
46
         |> assign(:items, project_items(project.id))
49
         |> assign(:items, project_items(socket.assigns.repository, project))
47 50
         |> assign(:form, to_form(ProjectItem.changeset(%ProjectItem{}, %{}), as: "item"))
48 51
         |> put_flash(:info, "Issue added to project")}
49 52

@@ -52,17 +55,17 @@ defmodule OpenAgentsWeb.ProjectShowLive do

52 55
    end
53 56
  end
54 57
55
  defp project_items(project_id) do
56
    Projects.list_project_items(project_id)
58
  defp project_items(repository, project) do
59
    Projects.list_project_items(project)
57 60
    |> Enum.map(fn item ->
58
      issue = Issues.get_issue!(item.issue_id)
61
      issue = Issues.get_issue!(repository, item.issue_id)
59 62
      status = get_in(item.values, ["Status"]) || "To Do"
60 63
      Map.merge(item, %{issue: issue, status: status})
61 64
    end)
62 65
  end
63 66
64
  defp issue_options do
65
    Issues.list_issues(state: "all")
67
  defp issue_options(repository) do
68
    Issues.list_issues(repository, state: "all")
66 69
    |> Enum.map(&{"##{&1.number} #{&1.title}", &1.number})
67 70
  end
68 71
priv/repo/migrations/20260820082100_add_repository_tenant_scoping.exs added +331

@@ -0,0 +1,331 @@

1
defmodule OpenAgents.Repo.Migrations.AddRepositoryTenantScoping do
2
  use Ecto.Migration
3
4
  @initial_repository_id "00000000-0000-4000-8000-000000000001"
5
6
  def up do
7
    create table(:repositories, primary_key: false) do
8
      add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
9
      add :owner, :string, null: false
10
      add :name, :string, null: false
11
      add :owner_key, :string, null: false
12
      add :name_key, :string, null: false
13
      add :visibility, :string, null: false
14
      add :default_branch, :string, null: false
15
16
      timestamps(type: :utc_datetime_usec)
17
    end
18
19
    create unique_index(:repositories, [:owner_key, :name_key])
20
21
    create constraint(:repositories, :repositories_visibility_check,
22
             check: "visibility IN ('public', 'private')"
23
           )
24
25
    create constraint(:repositories, :repositories_normalized_path_check,
26
             check: "owner_key = lower(owner) AND name_key = lower(name)"
27
           )
28
29
    execute("""
30
    INSERT INTO repositories
31
      (id, owner, name, owner_key, name_key, visibility, default_branch, inserted_at, updated_at)
32
    VALUES
33
      ('#{@initial_repository_id}', 'OpenAgentsInc', 'openagents.com',
34
       'openagentsinc', 'openagents.com', 'public', 'main', now(), now())
35
    """)
36
37
    create table(:repository_memberships, primary_key: false) do
38
      add :repository_id,
39
          references(:repositories, type: :binary_id, on_delete: :delete_all),
40
          primary_key: true
41
42
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all),
43
        primary_key: true
44
45
      add :role, :string, null: false
46
      timestamps(type: :utc_datetime_usec)
47
    end
48
49
    create unique_index(:repository_memberships, [:repository_id, :user_id])
50
51
    create constraint(:repository_memberships, :repository_memberships_role_check,
52
             check: "role IN ('owner', 'maintainer', 'contributor', 'viewer')"
53
           )
54
55
    alter table(:issues) do
56
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all)
57
      add :milestone_id, references(:milestones, on_delete: :nilify_all)
58
      add :author_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
59
    end
60
61
    alter table(:labels) do
62
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all)
63
    end
64
65
    alter table(:milestones) do
66
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all)
67
    end
68
69
    alter table(:comments) do
70
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all)
71
      add :author_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
72
    end
73
74
    alter table(:projects) do
75
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all)
76
      add :owner_user_id, references(:users, type: :binary_id, on_delete: :restrict)
77
    end
78
79
    alter table(:project_items) do
80
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all)
81
    end
82
83
    flush()
84
85
    for table <- ~w(issues labels milestones comments projects project_items)a do
86
      execute("UPDATE #{table} SET repository_id = '#{@initial_repository_id}'")
87
      execute("ALTER TABLE #{table} ALTER COLUMN repository_id SET NOT NULL")
88
    end
89
90
    execute("""
91
    INSERT INTO repository_memberships
92
      (repository_id, user_id, role, inserted_at, updated_at)
93
    SELECT '#{@initial_repository_id}', id, 'contributor', now(), now()
94
    FROM users
95
    ON CONFLICT (repository_id, user_id) DO NOTHING
96
    """)
97
98
    execute("""
99
    UPDATE issues AS issue
100
    SET author_user_id = app_user.id
101
    FROM users AS app_user
102
    WHERE issue.user IS NOT NULL
103
      AND lower(issue.user->>'login') = lower(app_user.github_login)
104
    """)
105
106
    execute("""
107
    UPDATE comments AS comment
108
    SET author_user_id = app_user.id
109
    FROM users AS app_user
110
    WHERE comment.user IS NOT NULL
111
      AND lower(comment.user->>'login') = lower(app_user.github_login)
112
    """)
113
114
    execute("""
115
    UPDATE projects AS project
116
    SET owner_user_id = app_user.id
117
    FROM users AS app_user
118
    WHERE lower(project.owner) = lower(app_user.github_login)
119
    """)
120
121
    execute("""
122
    UPDATE issues AS issue
123
    SET milestone_id = milestone.id
124
    FROM milestones AS milestone
125
    WHERE issue.milestone IS NOT NULL
126
      AND issue.repository_id = milestone.repository_id
127
      AND (issue.milestone->>'number')::integer = milestone.number
128
    """)
129
130
    drop index(:issues, [:number])
131
    create unique_index(:issues, [:repository_id, :number])
132
    create unique_index(:milestones, [:repository_id, :number])
133
    create unique_index(:labels, [:repository_id, :name])
134
    create unique_index(:projects, [:repository_id, :number])
135
    create unique_index(:issues, [:id, :repository_id])
136
    create unique_index(:labels, [:id, :repository_id])
137
    create unique_index(:milestones, [:id, :repository_id])
138
    create unique_index(:projects, [:id, :repository_id])
139
140
    create table(:issue_labels, primary_key: false) do
141
      add :issue_id, references(:issues, on_delete: :delete_all), primary_key: true
142
      add :label_id, references(:labels, on_delete: :delete_all), primary_key: true
143
144
      add :repository_id,
145
          references(:repositories, type: :binary_id, on_delete: :delete_all),
146
          null: false
147
148
      timestamps(type: :utc_datetime_usec)
149
    end
150
151
    create unique_index(:issue_labels, [:issue_id, :label_id])
152
153
    create table(:issue_assignees, primary_key: false) do
154
      add :issue_id, references(:issues, on_delete: :delete_all), primary_key: true
155
156
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all),
157
        primary_key: true
158
159
      add :repository_id,
160
          references(:repositories, type: :binary_id, on_delete: :delete_all),
161
          null: false
162
163
      timestamps(type: :utc_datetime_usec)
164
    end
165
166
    create unique_index(:issue_assignees, [:issue_id, :user_id])
167
168
    flush()
169
170
    execute("""
171
    INSERT INTO labels (repository_id, name, color, description, inserted_at, updated_at)
172
    SELECT DISTINCT ON (issue.repository_id, label->>'name')
173
      issue.repository_id,
174
      label->>'name',
175
      COALESCE(NULLIF(label->>'color', ''), 'ffffff'),
176
      label->>'description',
177
      now(),
178
      now()
179
    FROM issues AS issue
180
    CROSS JOIN LATERAL unnest(issue.labels) AS label
181
    WHERE NULLIF(label->>'name', '') IS NOT NULL
182
    ON CONFLICT (repository_id, name) DO NOTHING
183
    """)
184
185
    execute("""
186
    INSERT INTO issue_labels
187
      (issue_id, label_id, repository_id, inserted_at, updated_at)
188
    SELECT issue.id, label.id, issue.repository_id, now(), now()
189
    FROM issues AS issue
190
    CROSS JOIN LATERAL unnest(issue.labels) AS snapshot
191
    JOIN labels AS label
192
      ON label.repository_id = issue.repository_id
193
     AND label.name = snapshot->>'name'
194
    ON CONFLICT (issue_id, label_id) DO NOTHING
195
    """)
196
197
    execute("""
198
    DO $$
199
    BEGIN
200
      IF EXISTS (
201
        SELECT 1
202
        FROM issues AS issue
203
        CROSS JOIN LATERAL unnest(issue.assignees) AS snapshot
204
        LEFT JOIN users AS app_user
205
          ON lower(app_user.github_login) = lower(snapshot->>'login')
206
        WHERE NULLIF(snapshot->>'login', '') IS NOT NULL
207
          AND app_user.id IS NULL
208
      ) THEN
209
        RAISE EXCEPTION 'repository backfill found an assignee without a matching user';
210
      END IF;
211
    END
212
    $$
213
    """)
214
215
    execute("""
216
    INSERT INTO issue_assignees
217
      (issue_id, user_id, repository_id, inserted_at, updated_at)
218
    SELECT issue.id, app_user.id, issue.repository_id, now(), now()
219
    FROM issues AS issue
220
    CROSS JOIN LATERAL unnest(issue.assignees) AS snapshot
221
    JOIN users AS app_user
222
      ON lower(app_user.github_login) = lower(snapshot->>'login')
223
    ON CONFLICT (issue_id, user_id) DO NOTHING
224
    """)
225
226
    execute("""
227
    ALTER TABLE issue_labels
228
      ADD CONSTRAINT issue_labels_issue_repository_fkey
229
      FOREIGN KEY (issue_id, repository_id)
230
      REFERENCES issues (id, repository_id)
231
      ON DELETE CASCADE,
232
      ADD CONSTRAINT issue_labels_label_repository_fkey
233
      FOREIGN KEY (label_id, repository_id)
234
      REFERENCES labels (id, repository_id)
235
      ON DELETE CASCADE
236
    """)
237
238
    execute("""
239
    ALTER TABLE issue_assignees
240
      ADD CONSTRAINT issue_assignees_issue_repository_fkey
241
      FOREIGN KEY (issue_id, repository_id)
242
      REFERENCES issues (id, repository_id)
243
      ON DELETE CASCADE,
244
      ADD CONSTRAINT issue_assignees_membership_fkey
245
      FOREIGN KEY (repository_id, user_id)
246
      REFERENCES repository_memberships (repository_id, user_id)
247
      ON DELETE CASCADE
248
    """)
249
250
    execute("""
251
    ALTER TABLE comments
252
      ADD CONSTRAINT comments_issue_repository_fkey
253
      FOREIGN KEY (issue_id, repository_id)
254
      REFERENCES issues (id, repository_id)
255
      ON DELETE CASCADE
256
    """)
257
258
    execute("""
259
    ALTER TABLE issues
260
      ADD CONSTRAINT issues_milestone_repository_fkey
261
      FOREIGN KEY (milestone_id, repository_id)
262
      REFERENCES milestones (id, repository_id)
263
      ON DELETE SET NULL (milestone_id)
264
    """)
265
266
    execute("""
267
    ALTER TABLE projects
268
      ADD CONSTRAINT projects_owner_membership_fkey
269
      FOREIGN KEY (repository_id, owner_user_id)
270
      REFERENCES repository_memberships (repository_id, user_id)
271
      ON DELETE RESTRICT
272
    """)
273
274
    execute("""
275
    ALTER TABLE project_items
276
      ADD CONSTRAINT project_items_project_repository_fkey
277
      FOREIGN KEY (project_id, repository_id)
278
      REFERENCES projects (id, repository_id)
279
      ON DELETE CASCADE,
280
      ADD CONSTRAINT project_items_issue_repository_fkey
281
      FOREIGN KEY (issue_id, repository_id)
282
      REFERENCES issues (id, repository_id)
283
      ON DELETE RESTRICT
284
    """)
285
  end
286
287
  def down do
288
    execute("ALTER TABLE project_items DROP CONSTRAINT project_items_issue_repository_fkey")
289
    execute("ALTER TABLE project_items DROP CONSTRAINT project_items_project_repository_fkey")
290
    execute("ALTER TABLE projects DROP CONSTRAINT projects_owner_membership_fkey")
291
    execute("ALTER TABLE issues DROP CONSTRAINT issues_milestone_repository_fkey")
292
    execute("ALTER TABLE comments DROP CONSTRAINT comments_issue_repository_fkey")
293
294
    drop table(:issue_assignees)
295
    drop table(:issue_labels)
296
297
    drop index(:projects, [:id, :repository_id])
298
    drop index(:milestones, [:id, :repository_id])
299
    drop index(:labels, [:id, :repository_id])
300
    drop index(:issues, [:id, :repository_id])
301
    drop index(:projects, [:repository_id, :number])
302
    drop index(:labels, [:repository_id, :name])
303
    drop index(:milestones, [:repository_id, :number])
304
    drop index(:issues, [:repository_id, :number])
305
    create unique_index(:issues, [:number])
306
307
    alter table(:project_items), do: remove(:repository_id)
308
309
    alter table(:projects) do
310
      remove :owner_user_id
311
      remove :repository_id
312
    end
313
314
    alter table(:comments) do
315
      remove :author_user_id
316
      remove :repository_id
317
    end
318
319
    alter table(:milestones), do: remove(:repository_id)
320
    alter table(:labels), do: remove(:repository_id)
321
322
    alter table(:issues) do
323
      remove :author_user_id
324
      remove :milestone_id
325
      remove :repository_id
326
    end
327
328
    drop table(:repository_memberships)
329
    drop table(:repositories)
330
  end
331
end
test/openagents/forge/targets_test.exs modified +1 -1

@@ -17,7 +17,7 @@ defmodule OpenAgents.Forge.TargetsTest do

17 17
  alias OpenAgents.Forge.Target
18 18
  alias OpenAgents.Forge.Targets
19 19
20
  @repo "OpenAgents/openagents.com"
20
  @repo "OpenAgentsInc/openagents.com"
21 21
22 22
  # A SHA that is well-formed but not in any repo, so only the injected
23 23
  # store decides whether it is promotable.
test/openagents/issues_test.exs modified +23 -9

@@ -8,6 +8,12 @@ defmodule OpenAgents.IssuesTest do

8 8
  import OpenAgents.IssuesFixtures
9 9
  import OpenAgents.LabelsFixtures
10 10
  import OpenAgents.MilestonesFixtures
11
  import OpenAgents.AccountsFixtures
12
13
  setup do
14
    Enum.each(~w(alice bob carol), &repository_user_fixture/1)
15
    :ok
16
  end
11 17
12 18
  defp backdate!(%Issue{} = issue, seconds_ago) do
13 19
    at = DateTime.utc_now() |> DateTime.add(-seconds_ago, :second) |> DateTime.truncate(:second)

@@ -140,18 +146,26 @@ defmodule OpenAgents.IssuesTest do

140 146
             ]
141 147
    end
142 148
143
    test "invents a white label for an unknown name" do
144
      assert {:ok, %Issue{} = issue} =
145
               Issues.create_issue(%{title: "labelled", labels: ["nope"]})
146
147
      assert issue.labels == [%{"name" => "nope", "color" => "ffffff"}]
149
    test "rejects a label outside the repository label set" do
150
      assert_raise Ecto.NoResultsError, fn ->
151
        Issues.create_issue(%{title: "labelled", labels: ["nope"]})
152
      end
148 153
    end
149 154
150
    test "passes label maps through untouched" do
155
    test "canonicalizes label maps from the repository row" do
156
      label = label_fixture(name: "bug", color: "abcdef")
151 157
      given = [%{"name" => "bug", "color" => "abcdef"}]
152 158
153 159
      assert {:ok, %Issue{} = issue} = Issues.create_issue(%{title: "labelled", labels: given})
154
      assert issue.labels == given
160
161
      assert issue.labels == [
162
               %{
163
                 "id" => label.id,
164
                 "name" => "bug",
165
                 "color" => "abcdef",
166
                 "description" => label.description
167
               }
168
             ]
155 169
    end
156 170
157 171
    test "accepts an empty label list" do

@@ -165,11 +179,11 @@ defmodule OpenAgents.IssuesTest do

165 179
      assert issue.assignees == [%{"login" => "alice"}, %{"login" => "bob"}]
166 180
    end
167 181
168
    test "passes assignee maps through untouched" do
182
    test "canonicalizes assignee maps from repository membership" do
169 183
      given = [%{"login" => "alice", "id" => 7}]
170 184
171 185
      assert {:ok, %Issue{} = issue} = Issues.create_issue(%{title: "assigned", assignees: given})
172
      assert issue.assignees == given
186
      assert issue.assignees == [%{"login" => "alice"}]
173 187
    end
174 188
175 189
    test "expands a milestone number into a milestone map" do
test/openagents/milestones_test.exs modified +2 -2

@@ -41,7 +41,7 @@ defmodule OpenAgents.MilestonesTest do

41 41
      assert {:error, %Ecto.Changeset{}} = Milestones.create_milestone(@invalid_attrs)
42 42
    end
43 43
44
    test "update_milestone/2 with valid data updates the milestone" do
44
    test "update_milestone/2 updates mutable fields but preserves its repository number" do
45 45
      milestone = milestone_fixture()
46 46
47 47
      update_attrs = %{

@@ -58,7 +58,7 @@ defmodule OpenAgents.MilestonesTest do

58 58
      assert milestone.state == "some updated state"
59 59
      assert milestone.description == "some updated description"
60 60
      assert milestone.title == "some updated title"
61
      assert milestone.number == 43
61
      assert milestone.number == 42
62 62
      assert milestone.due_on == "some updated due_on"
63 63
    end
64 64
test/openagents/project_items_test.exs modified +8 -7

@@ -97,15 +97,16 @@ defmodule OpenAgents.ProjectItemsTest do

97 97
      refute Map.has_key?(errors_on(changeset), :project_id)
98 98
    end
99 99
100
    test "create_project_item/1 does not declare a foreign key constraint" do
101
      # `project_items.project_id` / `issue_id` reference their parent tables but
102
      # the changeset never calls `foreign_key_constraint/2`, so a dangling id
103
      # raises instead of returning an error changeset. Characterised, not endorsed.
100
    test "create_project_item/1 reports a dangling project as a changeset error" do
104 101
      issue = issue_fixture()
105 102
106
      assert_raise Ecto.ConstraintError, fn ->
107
        ProjectItems.create_project_item(%{project_id: 2_147_483_000, issue_id: issue.id})
108
      end
103
      assert {:error, changeset} =
104
               ProjectItems.create_project_item(%{
105
                 project_id: 2_147_483_000,
106
                 issue_id: issue.id
107
               })
108
109
      assert %{project_id: ["does not exist"]} = errors_on(changeset)
109 110
    end
110 111
111 112
    test "create_project_item/1 allows the same issue in two projects" do
test/openagents/repositories_test.exs added +167

@@ -0,0 +1,167 @@

1
defmodule OpenAgents.RepositoriesTest do
2
  use OpenAgents.DataCase
3
4
  import OpenAgents.AccountsFixtures
5
6
  alias OpenAgents.Issues
7
  alias OpenAgents.Issues.Comment
8
  alias OpenAgents.Labels
9
  alias OpenAgents.Milestones
10
  alias OpenAgents.ProjectItems.ProjectItem
11
  alias OpenAgents.Projects
12
  alias OpenAgents.Repositories
13
14
  setup do
15
    initial = Repositories.initial_repository!()
16
17
    {:ok, second} =
18
      Repositories.create_repository(%{
19
        owner: "SecondOrg",
20
        name: "second-repo",
21
        visibility: "private",
22
        default_branch: "trunk"
23
      })
24
25
    user = repository_user_fixture("tenant-member")
26
    {:ok, _membership} = Repositories.add_member(second, user, "maintainer")
27
28
    %{initial: initial, second: second, user: user}
29
  end
30
31
  test "the explicit initial repository has a stable canonical identity", %{initial: initial} do
32
    assert initial.id == "00000000-0000-4000-8000-000000000001"
33
    assert initial.owner == "OpenAgentsInc"
34
    assert initial.name == "openagents.com"
35
    assert initial.visibility == "public"
36
    assert initial.default_branch == "main"
37
38
    assert Repositories.get_by_path!("openagentsinc", "OPENAGENTS.COM").id == initial.id
39
  end
40
41
  test "issue and milestone numbers are allocated independently per repository", %{
42
    initial: initial,
43
    second: second
44
  } do
45
    assert {:ok, initial_issue} = Issues.create_issue(initial, %{title: "Initial"})
46
    assert {:ok, second_issue} = Issues.create_issue(second, %{title: "Second"})
47
    assert initial_issue.number == 1
48
    assert second_issue.number == 1
49
50
    assert {:ok, initial_milestone} = Milestones.create_milestone(initial, %{title: "Initial"})
51
    assert {:ok, second_milestone} = Milestones.create_milestone(second, %{title: "Second"})
52
    assert initial_milestone.number == 1
53
    assert second_milestone.number == 1
54
  end
55
56
  test "public paths cannot cross repository ownership or expose a private repository", %{
57
    initial: initial,
58
    second: second
59
  } do
60
    assert {:ok, initial_issue} = Issues.create_issue(initial, %{title: "Initial"})
61
    assert {:ok, second_issue} = Issues.create_issue(second, %{title: "Second"})
62
63
    assert Issues.get_issue_by_path!("OpenAgentsInc", "openagents.com", 1).id ==
64
             initial_issue.id
65
66
    assert Issues.get_issue_by_number!(second, 1).id == second_issue.id
67
68
    assert_raise Ecto.NoResultsError, fn ->
69
      Issues.get_issue_by_path!("SecondOrg", "second-repo", 1)
70
    end
71
  end
72
73
  test "labels and milestones from another repository are rejected", %{
74
    initial: initial,
75
    second: second
76
  } do
77
    assert {:ok, issue} = Issues.create_issue(initial, %{title: "Scoped"})
78
    assert {:ok, _label} = Labels.create_label(second, %{name: "private", color: "ffffff"})
79
    assert {:ok, milestone} = Milestones.create_milestone(second, %{title: "Second"})
80
81
    assert_raise Ecto.NoResultsError, fn -> Issues.add_labels(issue, ["private"]) end
82
    assert_raise Ecto.NoResultsError, fn -> Issues.set_milestone(issue, milestone.number) end
83
    assert Labels.list_labels(initial) == []
84
  end
85
86
  test "only active repository members are assignable", %{initial: initial, second: second} do
87
    _initial_only = repository_user_fixture("initial-only")
88
89
    assert Enum.map(Repositories.list_assignable_users(initial), & &1.github_login) == [
90
             "initial-only",
91
             "tenant-member"
92
           ]
93
94
    assert Enum.map(Repositories.list_assignable_users(second), & &1.github_login) == [
95
             "tenant-member"
96
           ]
97
98
    assert_raise Ecto.NoResultsError, fn ->
99
      Repositories.get_assignable_user_by_login!(second, "initial-only")
100
    end
101
  end
102
103
  test "a banned membership is neither writable nor assignable", %{second: second, user: user} do
104
    assert {:ok, banned} = OpenAgents.Accounts.ban_user(user, "repository_policy")
105
    refute Repositories.writable?(second, banned)
106
    assert Repositories.list_assignable_users(second) == []
107
108
    assert_raise Ecto.NoResultsError, fn ->
109
      Repositories.get_writable_by_path!(second.owner, second.name, banned)
110
    end
111
  end
112
113
  test "project-item and comment constraints reject cross-repository parents", %{
114
    initial: initial,
115
    second: second,
116
    user: user
117
  } do
118
    assert {:ok, project} = Projects.create_project(initial, %{title: "Initial"}, user)
119
    assert {:ok, second_issue} = Issues.create_issue(second, %{title: "Second"})
120
121
    assert {:error, item_changeset} =
122
             %ProjectItem{}
123
             |> ProjectItem.changeset(%{
124
               project_id: project.id,
125
               issue_id: second_issue.id,
126
               repository_id: initial.id
127
             })
128
             |> Repo.insert()
129
130
    assert %{issue_id: ["does not exist"]} = errors_on(item_changeset)
131
132
    now = DateTime.utc_now() |> DateTime.truncate(:second)
133
134
    assert {:error, comment_changeset} =
135
             %Comment{}
136
             |> Comment.changeset(%{
137
               issue_id: second_issue.id,
138
               repository_id: initial.id,
139
               body: "cross tenant",
140
               created_at: now,
141
               updated_at: now
142
             })
143
             |> Repo.insert()
144
145
    assert %{issue_id: ["does not exist"]} = errors_on(comment_changeset)
146
  end
147
148
  test "project paths require both the repository and project number", %{
149
    initial: initial,
150
    second: second,
151
    user: user
152
  } do
153
    assert {:ok, initial_project} = Projects.create_project(initial, %{title: "Initial"}, user)
154
    assert {:ok, second_project} = Projects.create_project(second, %{title: "Second"}, user)
155
156
    assert initial_project.number == second_project.number
157
158
    assert Projects.get_project_by_path!("OpenAgentsInc", "openagents.com", 1).id ==
159
             initial_project.id
160
161
    assert Projects.get_project_by_number!(second, 1).id == second_project.id
162
163
    assert_raise Ecto.NoResultsError, fn ->
164
      Projects.get_project_by_path!("SecondOrg", "second-repo", 1)
165
    end
166
  end
167
end
test/openagents_web/controllers/api_token_controller_test.exs modified +1 -1

@@ -32,7 +32,7 @@ defmodule OpenAgentsWeb.ApiTokenControllerTest do

32 32
  test "forge mutations refuse missing, malformed, expired, and revoked credentials", %{
33 33
    conn: conn
34 34
  } do
35
    path = ~p"/api/v3/repos/OpenAgents/openagents/issues"
35
    path = ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues"
36 36
37 37
    missing = conn |> delete_req_header("authorization") |> post(path, %{title: "denied"})
38 38
    assert json_response(missing, 401) == %{"error" => "invalid_api_token"}
test/openagents_web/controllers/assignee_controller_test.exs modified +15 -5

@@ -1,20 +1,30 @@

1 1
defmodule OpenAgentsWeb.AssigneeControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  import OpenAgents.AccountsFixtures
5
4 6
  describe "index" do
5 7
    test "GET /api/v3/repos/:owner/:repo/assignees returns the assignable list", %{conn: conn} do
6
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/assignees")
8
      repository_user_fixture("octocat")
9
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/assignees")
7 10
8
      assert json_response(conn, 200) == %{"assignees" => []}
11
      assert %{"assignees" => [%{"login" => "octocat"}]} = json_response(conn, 200)
9 12
    end
10 13
  end
11 14
12 15
  describe "show" do
13
    test "GET /api/v3/repos/:owner/:repo/assignees/:assignee returns 404 for a user who cannot be assigned",
16
    test "GET /api/v3/repos/:owner/:repo/assignees/:assignee returns an assignable user",
14 17
         %{conn: conn} do
15
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/assignees/octocat")
18
      repository_user_fixture("octocat")
19
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/assignees/octocat")
20
21
      assert %{"login" => "octocat"} = json_response(conn, 200)
22
    end
23
24
    test "GET /api/v3/repos/:owner/:repo/assignees/:assignee hides a nonmember", %{conn: conn} do
25
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/assignees/not-a-member")
16 26
17
      assert response(conn, 404) == ""
27
      assert json_response(conn, 404) == %{"message" => "Not Found"}
18 28
    end
19 29
  end
20 30
end
test/openagents_web/controllers/comment_controller_test.exs modified +11 -7

@@ -17,7 +17,7 @@ defmodule OpenAgentsWeb.CommentControllerTest do

17 17
    Issues.create_comment(%{issue_id: issue.id, body: "First comment"})
18 18
19 19
    conn =
20
      get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/comments")
20
      get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/comments")
21 21
22 22
    assert %{"comments" => [comment]} = json_response(conn, 200)
23 23
    assert comment["body"] == "First comment"

@@ -28,9 +28,13 @@ defmodule OpenAgentsWeb.CommentControllerTest do

28 28
    issue: issue
29 29
  } do
30 30
    conn =
31
      post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/comments", %{
32
        body: "New comment"
33
      })
31
      post(
32
        conn,
33
        ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/comments",
34
        %{
35
          body: "New comment"
36
        }
37
      )
34 38
35 39
    assert %{"body" => "New comment"} = json_response(conn, 201)
36 40
  end

@@ -42,7 +46,7 @@ defmodule OpenAgentsWeb.CommentControllerTest do

42 46
    {:ok, comment} = Issues.create_comment(%{issue_id: issue.id, body: "Show me"})
43 47
44 48
    conn =
45
      get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/comments/#{comment.id}")
49
      get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/comments/#{comment.id}")
46 50
47 51
    assert %{"body" => "Show me"} = json_response(conn, 200)
48 52
  end

@@ -54,7 +58,7 @@ defmodule OpenAgentsWeb.CommentControllerTest do

54 58
    {:ok, comment} = Issues.create_comment(%{issue_id: issue.id, body: "Before"})
55 59
56 60
    conn =
57
      patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/comments/#{comment.id}", %{
61
      patch(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/comments/#{comment.id}", %{
58 62
        body: "After"
59 63
      })
60 64

@@ -68,7 +72,7 @@ defmodule OpenAgentsWeb.CommentControllerTest do

68 72
    {:ok, comment} = Issues.create_comment(%{issue_id: issue.id, body: "Delete me"})
69 73
70 74
    conn =
71
      delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/comments/#{comment.id}")
75
      delete(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/comments/#{comment.id}")
72 76
73 77
    assert response(conn, 204)
74 78
  end
test/openagents_web/controllers/issue_assignee_controller_test.exs modified +50 -21

@@ -4,8 +4,11 @@ defmodule OpenAgentsWeb.IssueAssigneeControllerTest do

4 4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "issue-assignees")}
5 5
6 6
  alias OpenAgents.Issues
7
  import OpenAgents.AccountsFixtures
7 8
8 9
  setup do
10
    repository_user_fixture("octocat")
11
    repository_user_fixture("hubot")
9 12
    {:ok, issue} = Issues.create_issue(%{title: "Assignable issue"})
10 13
    %{issue: issue}
11 14
  end

@@ -15,7 +18,8 @@ defmodule OpenAgentsWeb.IssueAssigneeControllerTest do

15 18
      conn: conn,
16 19
      issue: issue
17 20
    } do
18
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees")
21
      conn =
22
        get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/assignees")
19 23
20 24
      assert json_response(conn, 200) == %{"assignees" => []}
21 25
    end

@@ -26,14 +30,15 @@ defmodule OpenAgentsWeb.IssueAssigneeControllerTest do

26 30
    } do
27 31
      {:ok, _issue} = Issues.add_assignees(issue, ["octocat"])
28 32
29
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees")
33
      conn =
34
        get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/assignees")
30 35
31 36
      assert %{"assignees" => [assignee]} = json_response(conn, 200)
32 37
      assert assignee["login"] == "octocat"
33 38
    end
34 39
35 40
    test "GET .../issues/:issue_number/assignees returns 404 for a missing issue", %{conn: conn} do
36
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/assignees")
41
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/999999/assignees")
37 42
38 43
      assert json_response(conn, 404) == %{"message" => "Not Found"}
39 44
    end

@@ -42,9 +47,13 @@ defmodule OpenAgentsWeb.IssueAssigneeControllerTest do

42 47
  describe "create" do
43 48
    test "POST .../issues/:issue_number/assignees adds an assignee", %{conn: conn, issue: issue} do
44 49
      conn =
45
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{
46
          assignees: ["octocat"]
47
        })
50
        post(
51
          conn,
52
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/assignees",
53
          %{
54
            assignees: ["octocat"]
55
          }
56
        )
48 57
49 58
      assert %{"assignees" => [%{"login" => "octocat"}]} = json_response(conn, 200)
50 59

@@ -56,9 +65,13 @@ defmodule OpenAgentsWeb.IssueAssigneeControllerTest do

56 65
      issue: issue
57 66
    } do
58 67
      conn =
59
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{
60
          assignees: ["octocat", "hubot"]
61
        })
68
        post(
69
          conn,
70
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/assignees",
71
          %{
72
            assignees: ["octocat", "hubot"]
73
          }
74
        )
62 75
63 76
      assert %{"assignees" => assignees} = json_response(conn, 200)
64 77
      assert Enum.map(assignees, & &1["login"]) == ["octocat", "hubot"]

@@ -71,9 +84,13 @@ defmodule OpenAgentsWeb.IssueAssigneeControllerTest do

71 84
      {:ok, _issue} = Issues.add_assignees(issue, ["octocat"])
72 85
73 86
      conn =
74
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{
75
          assignees: ["octocat"]
76
        })
87
        post(
88
          conn,
89
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/assignees",
90
          %{
91
            assignees: ["octocat"]
92
          }
93
        )
77 94
78 95
      assert %{"assignees" => [_only_one]} = json_response(conn, 200)
79 96
    end

@@ -81,14 +98,18 @@ defmodule OpenAgentsWeb.IssueAssigneeControllerTest do

81 98
    test "POST .../issues/:issue_number/assignees with no assignees leaves the issue unchanged",
82 99
         %{conn: conn, issue: issue} do
83 100
      conn =
84
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{})
101
        post(
102
          conn,
103
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/assignees",
104
          %{}
105
        )
85 106
86 107
      assert json_response(conn, 200) == %{"assignees" => []}
87 108
    end
88 109
89 110
    test "POST .../issues/:issue_number/assignees returns 404 for a missing issue", %{conn: conn} do
90 111
      conn =
91
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/assignees", %{
112
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/999999/assignees", %{
92 113
          assignees: ["octocat"]
93 114
        })
94 115

@@ -104,9 +125,13 @@ defmodule OpenAgentsWeb.IssueAssigneeControllerTest do

104 125
      {:ok, _issue} = Issues.add_assignees(issue, ["octocat", "hubot"])
105 126
106 127
      conn =
107
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{
108
          assignees: ["octocat"]
109
        })
128
        delete(
129
          conn,
130
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/assignees",
131
          %{
132
            assignees: ["octocat"]
133
          }
134
        )
110 135
111 136
      assert %{"assignees" => [%{"login" => "hubot"}]} = json_response(conn, 200)
112 137

@@ -120,9 +145,13 @@ defmodule OpenAgentsWeb.IssueAssigneeControllerTest do

120 145
      {:ok, _issue} = Issues.add_assignees(issue, ["octocat"])
121 146
122 147
      conn =
123
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/assignees", %{
124
          assignees: ["nobody"]
125
        })
148
        delete(
149
          conn,
150
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/assignees",
151
          %{
152
            assignees: ["nobody"]
153
          }
154
        )
126 155
127 156
      assert %{"assignees" => [%{"login" => "octocat"}]} = json_response(conn, 200)
128 157
    end

@@ -131,7 +160,7 @@ defmodule OpenAgentsWeb.IssueAssigneeControllerTest do

131 160
      conn: conn
132 161
    } do
133 162
      conn =
134
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/assignees", %{
163
        delete(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/999999/assignees", %{
135 164
          assignees: ["octocat"]
136 165
        })
137 166
test/openagents_web/controllers/issue_controller_test.exs modified +7 -7

@@ -11,7 +11,7 @@ defmodule OpenAgentsWeb.IssueControllerTest do

11 11
    } do
12 12
      {:ok, _issue} = Issues.create_issue(%{title: "First issue"})
13 13
14
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues")
14
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues")
15 15
16 16
      assert %{"issues" => [issue | _]} = json_response(conn, 200)
17 17
      assert issue["title"] == "First issue"

@@ -22,7 +22,7 @@ defmodule OpenAgentsWeb.IssueControllerTest do

22 22
      {:ok, _open_issue} = Issues.create_issue(%{title: "Open issue"})
23 23
      {:ok, _closed_issue} = Issues.create_issue(%{title: "Closed issue", state: "closed"})
24 24
25
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues?state=closed")
25
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues?state=closed")
26 26
27 27
      assert %{"issues" => [issue]} = json_response(conn, 200)
28 28
      assert issue["title"] == "Closed issue"

@@ -33,7 +33,7 @@ defmodule OpenAgentsWeb.IssueControllerTest do

33 33
  describe "create" do
34 34
    test "POST /api/v3/repos/:owner/:repo/issues creates an issue", %{conn: conn} do
35 35
      conn =
36
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues", %{
36
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues", %{
37 37
          title: "New issue",
38 38
          body: "A description"
39 39
        })

@@ -50,7 +50,7 @@ defmodule OpenAgentsWeb.IssueControllerTest do

50 50
      conn: conn
51 51
    } do
52 52
      conn =
53
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues", %{body: "No title"})
53
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues", %{body: "No title"})
54 54
55 55
      assert json_response(conn, 422)["errors"] != %{}
56 56
    end

@@ -62,7 +62,7 @@ defmodule OpenAgentsWeb.IssueControllerTest do

62 62
    } do
63 63
      {:ok, issue} = Issues.create_issue(%{title: "Show me"})
64 64
65
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}")
65
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")
66 66
67 67
      assert %{
68 68
               "title" => "Show me",

@@ -75,7 +75,7 @@ defmodule OpenAgentsWeb.IssueControllerTest do

75 75
    test "GET /api/v3/repos/:owner/:repo/issues/:issue_number returns 404 when missing", %{
76 76
      conn: conn
77 77
    } do
78
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999")
78
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/999999")
79 79
80 80
      assert json_response(conn, 404)
81 81
    end

@@ -88,7 +88,7 @@ defmodule OpenAgentsWeb.IssueControllerTest do

88 88
      {:ok, issue} = Issues.create_issue(%{title: "Close me"})
89 89
90 90
      conn =
91
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}", %{
91
        patch(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}", %{
92 92
          state: "closed"
93 93
        })
94 94
test/openagents_web/controllers/issue_label_controller_test.exs modified +49 -21

@@ -17,7 +17,8 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

17 17
      conn: conn,
18 18
      issue: issue
19 19
    } do
20
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels")
20
      conn =
21
        get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels")
21 22
22 23
      assert json_response(conn, 200) == %{"labels" => []}
23 24
    end

@@ -29,14 +30,15 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

29 30
      label_fixture(%{name: "bug", color: "d73a4a"})
30 31
      {:ok, _issue} = Issues.add_labels(issue, ["bug"])
31 32
32
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels")
33
      conn =
34
        get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels")
33 35
34 36
      assert %{"labels" => [label]} = json_response(conn, 200)
35 37
      assert label["name"] == "bug"
36 38
    end
37 39
38 40
    test "GET .../issues/:issue_number/labels returns 404 for a missing issue", %{conn: conn} do
39
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/labels")
41
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/999999/labels")
40 42
41 43
      assert json_response(conn, 404) == %{"message" => "Not Found"}
42 44
    end

@@ -50,9 +52,13 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

50 52
      label_fixture(%{name: "bug", color: "d73a4a"})
51 53
52 54
      conn =
53
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels", %{
54
          labels: ["bug"]
55
        })
55
        post(
56
          conn,
57
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels",
58
          %{
59
            labels: ["bug"]
60
          }
61
        )
56 62
57 63
      assert %{"labels" => [label]} = json_response(conn, 200)
58 64
      assert label["name"] == "bug"

@@ -68,9 +74,13 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

68 74
      label_fixture(%{name: "docs", color: "0075ca"})
69 75
70 76
      conn =
71
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels", %{
72
          labels: ["bug", "docs"]
73
        })
77
        post(
78
          conn,
79
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels",
80
          %{
81
            labels: ["bug", "docs"]
82
          }
83
        )
74 84
75 85
      assert %{"labels" => labels} = json_response(conn, 200)
76 86
      assert Enum.map(labels, & &1["name"]) == ["bug", "docs"]

@@ -84,9 +94,13 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

84 94
      {:ok, _issue} = Issues.add_labels(issue, ["bug"])
85 95
86 96
      conn =
87
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels", %{
88
          labels: ["bug"]
89
        })
97
        post(
98
          conn,
99
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels",
100
          %{
101
            labels: ["bug"]
102
          }
103
        )
90 104
91 105
      assert %{"labels" => [_only_one]} = json_response(conn, 200)
92 106
    end

@@ -96,7 +110,11 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

96 110
      issue: issue
97 111
    } do
98 112
      conn =
99
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels", %{})
113
        post(
114
          conn,
115
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels",
116
          %{}
117
        )
100 118
101 119
      assert json_response(conn, 200) == %{"labels" => []}
102 120
    end

@@ -106,9 +124,13 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

106 124
      issue: issue
107 125
    } do
108 126
      conn =
109
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels", %{
110
          labels: ["never-created"]
111
        })
127
        post(
128
          conn,
129
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels",
130
          %{
131
            labels: ["never-created"]
132
          }
133
        )
112 134
113 135
      assert json_response(conn, 404) == %{"message" => "Not Found"}
114 136
      assert Issues.get_issue_by_number!(issue.number).labels == []

@@ -116,7 +138,7 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

116 138
117 139
    test "POST .../issues/:issue_number/labels returns 404 for a missing issue", %{conn: conn} do
118 140
      conn =
119
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/labels", %{
141
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/999999/labels", %{
120 142
          labels: ["bug"]
121 143
        })
122 144

@@ -134,7 +156,10 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

134 156
      {:ok, _issue} = Issues.add_labels(issue, ["bug", "docs"])
135 157
136 158
      conn =
137
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels/bug")
159
        delete(
160
          conn,
161
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels/bug"
162
        )
138 163
139 164
      assert %{"labels" => [label]} = json_response(conn, 200)
140 165
      assert label["name"] == "docs"

@@ -152,7 +177,7 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

152 177
      conn =
153 178
        delete(
154 179
          conn,
155
          ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels/good first issue"
180
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels/good first issue"
156 181
        )
157 182
158 183
      assert json_response(conn, 200) == %{"labels" => []}

@@ -166,7 +191,10 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

166 191
      {:ok, _issue} = Issues.add_labels(issue, ["bug"])
167 192
168 193
      conn =
169
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/#{issue.number}/labels/docs")
194
        delete(
195
          conn,
196
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/labels/docs"
197
        )
170 198
171 199
      assert %{"labels" => [%{"name" => "bug"}]} = json_response(conn, 200)
172 200
    end

@@ -174,7 +202,7 @@ defmodule OpenAgentsWeb.IssueLabelControllerTest do

174 202
    test "DELETE .../issues/:issue_number/labels/:name returns 404 for a missing issue", %{
175 203
      conn: conn
176 204
    } do
177
      conn = delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/issues/999999/labels/bug")
205
      conn = delete(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/999999/labels/bug")
178 206
179 207
      assert json_response(conn, 404) == %{"message" => "Not Found"}
180 208
    end
test/openagents_web/controllers/label_controller_test.exs modified +21 -17

@@ -11,7 +11,7 @@ defmodule OpenAgentsWeb.LabelControllerTest do

11 11
    test "GET /api/v3/repos/:owner/:repo/labels lists labels", %{conn: conn} do
12 12
      label_fixture(%{name: "bug", color: "d73a4a"})
13 13
14
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels")
14
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels")
15 15
16 16
      assert %{"labels" => [label]} = json_response(conn, 200)
17 17
      assert label["name"] == "bug"

@@ -21,16 +21,16 @@ defmodule OpenAgentsWeb.LabelControllerTest do

21 21
    test "GET /api/v3/repos/:owner/:repo/labels renders a repo-scoped url", %{conn: conn} do
22 22
      label_fixture(%{name: "bug", color: "d73a4a"})
23 23
24
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels")
24
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels")
25 25
26 26
      assert %{"labels" => [label]} = json_response(conn, 200)
27 27
28 28
      assert label["url"] ==
29
               "https://openagents.com/api/v3/repos/OpenAgents/openagents/labels/bug"
29
               "https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/labels/bug"
30 30
    end
31 31
32 32
    test "GET /api/v3/repos/:owner/:repo/labels returns an empty list", %{conn: conn} do
33
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels")
33
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels")
34 34
35 35
      assert json_response(conn, 200) == %{"labels" => []}
36 36
    end

@@ -39,7 +39,7 @@ defmodule OpenAgentsWeb.LabelControllerTest do

39 39
  describe "create" do
40 40
    test "POST /api/v3/repos/:owner/:repo/labels creates a label", %{conn: conn} do
41 41
      conn =
42
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels", %{
42
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels", %{
43 43
          name: "enhancement",
44 44
          color: "a2eeef",
45 45
          description: "New feature"

@@ -56,19 +56,20 @@ defmodule OpenAgentsWeb.LabelControllerTest do

56 56
    end
57 57
58 58
    test "POST /api/v3/repos/:owner/:repo/labels returns 422 without a color", %{conn: conn} do
59
      conn = post(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels", %{name: "no-color"})
59
      conn =
60
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels", %{name: "no-color"})
60 61
61 62
      assert %{"errors" => %{"color" => _}} = json_response(conn, 422)
62 63
    end
63 64
64 65
    test "POST /api/v3/repos/:owner/:repo/labels returns 422 without a name", %{conn: conn} do
65
      conn = post(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels", %{color: "ffffff"})
66
      conn = post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels", %{color: "ffffff"})
66 67
67 68
      assert %{"errors" => %{"name" => _}} = json_response(conn, 422)
68 69
    end
69 70
70 71
    test "POST /api/v3/repos/:owner/:repo/labels does not persist owner or repo", %{conn: conn} do
71
      post(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels", %{
72
      post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels", %{
72 73
        name: "scoped",
73 74
        color: "ffffff"
74 75
      })

@@ -83,7 +84,7 @@ defmodule OpenAgentsWeb.LabelControllerTest do

83 84
    test "GET /api/v3/repos/:owner/:repo/labels/:name returns the label", %{conn: conn} do
84 85
      label = label_fixture(%{name: "bug", color: "d73a4a"})
85 86
86
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/bug")
87
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels/bug")
87 88
88 89
      assert %{"name" => "bug", "id" => id} = json_response(conn, 200)
89 90
      assert id == label.id

@@ -92,13 +93,13 @@ defmodule OpenAgentsWeb.LabelControllerTest do

92 93
    test "GET /api/v3/repos/:owner/:repo/labels/:name decodes an escaped name", %{conn: conn} do
93 94
      label_fixture(%{name: "good first issue", color: "7057ff"})
94 95
95
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/good first issue")
96
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels/good first issue")
96 97
97 98
      assert json_response(conn, 200)["name"] == "good first issue"
98 99
    end
99 100
100 101
    test "GET /api/v3/repos/:owner/:repo/labels/:name returns 404 when missing", %{conn: conn} do
101
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/nope")
102
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels/nope")
102 103
103 104
      assert json_response(conn, 404) == %{"message" => "Not Found"}
104 105
    end

@@ -109,7 +110,7 @@ defmodule OpenAgentsWeb.LabelControllerTest do

109 110
      label_fixture(%{name: "bug", color: "d73a4a"})
110 111
111 112
      conn =
112
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/bug", %{color: "000000"})
113
        patch(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels/bug", %{color: "000000"})
113 114
114 115
      assert %{"name" => "bug", "color" => "000000"} = json_response(conn, 200)
115 116
      assert Labels.get_label_by_name!("bug").color == "000000"

@@ -119,7 +120,9 @@ defmodule OpenAgentsWeb.LabelControllerTest do

119 120
      label_fixture(%{name: "bug", color: "d73a4a", description: "old"})
120 121
121 122
      conn =
122
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/bug", %{description: "new"})
123
        patch(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels/bug", %{
124
          description: "new"
125
        })
123 126
124 127
      assert json_response(conn, 200)["description"] == "new"
125 128
    end

@@ -132,14 +135,15 @@ defmodule OpenAgentsWeb.LabelControllerTest do

132 135
      conn =
133 136
        conn
134 137
        |> put_req_header("content-type", "application/json")
135
        |> patch(~p"/api/v3/repos/OpenAgents/openagents/labels/bug", ~s({"color": null}))
138
        |> patch(~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels/bug", ~s({"color": null}))
136 139
137 140
      assert %{"errors" => %{"color" => _}} = json_response(conn, 422)
138 141
      assert Labels.get_label_by_name!("bug").color == "d73a4a"
139 142
    end
140 143
141 144
    test "PATCH /api/v3/repos/:owner/:repo/labels/:name returns 404 when missing", %{conn: conn} do
142
      conn = patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/nope", %{color: "000000"})
145
      conn =
146
        patch(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels/nope", %{color: "000000"})
143 147
144 148
      assert json_response(conn, 404) == %{"message" => "Not Found"}
145 149
    end

@@ -149,14 +153,14 @@ defmodule OpenAgentsWeb.LabelControllerTest do

149 153
    test "DELETE /api/v3/repos/:owner/:repo/labels/:name returns 204", %{conn: conn} do
150 154
      label_fixture(%{name: "bug", color: "d73a4a"})
151 155
152
      conn = delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/bug")
156
      conn = delete(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels/bug")
153 157
154 158
      assert response(conn, 204) == ""
155 159
      assert Labels.list_labels() == []
156 160
    end
157 161
158 162
    test "DELETE /api/v3/repos/:owner/:repo/labels/:name returns 404 when missing", %{conn: conn} do
159
      conn = delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/labels/nope")
163
      conn = delete(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/labels/nope")
160 164
161 165
      assert json_response(conn, 404) == %{"message" => "Not Found"}
162 166
    end
test/openagents_web/controllers/milestone_controller_test.exs modified +38 -20

@@ -11,7 +11,7 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

11 11
    test "GET /api/v3/repos/:owner/:repo/milestones lists milestones", %{conn: conn} do
12 12
      milestone_fixture(%{title: "v1.0", state: "open"})
13 13
14
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones")
14
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones")
15 15
16 16
      assert %{"milestones" => [milestone]} = json_response(conn, 200)
17 17
      assert milestone["title"] == "v1.0"

@@ -21,18 +21,18 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

21 21
    test "GET /api/v3/repos/:owner/:repo/milestones renders issue counts and url", %{conn: conn} do
22 22
      milestone = milestone_fixture(%{title: "v1.0"})
23 23
24
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones")
24
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones")
25 25
26 26
      assert %{"milestones" => [rendered]} = json_response(conn, 200)
27 27
      assert rendered["open_issues"] == 0
28 28
      assert rendered["closed_issues"] == 0
29 29
30 30
      assert rendered["url"] ==
31
               "https://openagents.com/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}"
31
               "https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/milestones/#{milestone.number}"
32 32
    end
33 33
34 34
    test "GET /api/v3/repos/:owner/:repo/milestones returns an empty list", %{conn: conn} do
35
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones")
35
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones")
36 36
37 37
      assert json_response(conn, 200) == %{"milestones" => []}
38 38
    end

@@ -41,7 +41,7 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

41 41
  describe "create" do
42 42
    test "POST /api/v3/repos/:owner/:repo/milestones creates a milestone", %{conn: conn} do
43 43
      conn =
44
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones", %{
44
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones", %{
45 45
          title: "v2.0",
46 46
          description: "Second release"
47 47
        })

@@ -57,15 +57,20 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

57 57
    end
58 58
59 59
    test "POST /api/v3/repos/:owner/:repo/milestones assigns sequential numbers", %{conn: conn} do
60
      first = post(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones", %{title: "one"})
61
      second = post(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones", %{title: "two"})
60
      first =
61
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones", %{title: "one"})
62
63
      second =
64
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones", %{title: "two"})
62 65
63 66
      assert json_response(second, 201)["number"] == json_response(first, 201)["number"] + 1
64 67
    end
65 68
66 69
    test "POST /api/v3/repos/:owner/:repo/milestones returns 422 without a title", %{conn: conn} do
67 70
      conn =
68
        post(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones", %{description: "No title"})
71
        post(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones", %{
72
          description: "No title"
73
        })
69 74
70 75
      assert %{"errors" => %{"title" => _}} = json_response(conn, 422)
71 76
      assert Milestones.list_milestones() == []

@@ -77,7 +82,7 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

77 82
      milestone = milestone_fixture(%{title: "Show me"})
78 83
79 84
      conn =
80
        get(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}")
85
        get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones/#{milestone.number}")
81 86
82 87
      assert %{"title" => "Show me", "id" => id} = json_response(conn, 200)
83 88
      assert id == milestone.id

@@ -85,7 +90,7 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

85 90
86 91
    test "GET /api/v3/repos/:owner/:repo/milestones/:milestone_number returns 404 when missing",
87 92
         %{conn: conn} do
88
      conn = get(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/999999")
93
      conn = get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones/999999")
89 94
90 95
      assert json_response(conn, 404) == %{"message" => "Not Found"}
91 96
    end

@@ -96,9 +101,13 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

96 101
      milestone = milestone_fixture(%{title: "Close me", state: "open"})
97 102
98 103
      conn =
99
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}", %{
100
          state: "closed"
101
        })
104
        patch(
105
          conn,
106
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones/#{milestone.number}",
107
          %{
108
            state: "closed"
109
          }
110
        )
102 111
103 112
      assert json_response(conn, 200)["state"] == "closed"
104 113
      assert Milestones.get_milestone_by_number!(milestone.number).state == "closed"

@@ -110,9 +119,13 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

110 119
      milestone = milestone_fixture(%{title: "Old title"})
111 120
112 121
      conn =
113
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}", %{
114
          title: "New title"
115
        })
122
        patch(
123
          conn,
124
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones/#{milestone.number}",
125
          %{
126
            title: "New title"
127
          }
128
        )
116 129
117 130
      assert json_response(conn, 200)["title"] == "New title"
118 131
    end

@@ -125,7 +138,7 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

125 138
        conn
126 139
        |> put_req_header("content-type", "application/json")
127 140
        |> patch(
128
          ~p"/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}",
141
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones/#{milestone.number}",
129 142
          ~s({"title": null})
130 143
        )
131 144

@@ -136,7 +149,9 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

136 149
    test "PATCH /api/v3/repos/:owner/:repo/milestones/:milestone_number returns 404 when missing",
137 150
         %{conn: conn} do
138 151
      conn =
139
        patch(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/999999", %{state: "closed"})
152
        patch(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones/999999", %{
153
          state: "closed"
154
        })
140 155
141 156
      assert json_response(conn, 404) == %{"message" => "Not Found"}
142 157
    end

@@ -149,7 +164,10 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

149 164
      milestone = milestone_fixture(%{title: "Delete me"})
150 165
151 166
      conn =
152
        delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/#{milestone.number}")
167
        delete(
168
          conn,
169
          ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones/#{milestone.number}"
170
        )
153 171
154 172
      assert response(conn, 204) == ""
155 173
      assert Milestones.list_milestones() == []

@@ -157,7 +175,7 @@ defmodule OpenAgentsWeb.MilestoneControllerTest do

157 175
158 176
    test "DELETE /api/v3/repos/:owner/:repo/milestones/:milestone_number returns 404 when missing",
159 177
         %{conn: conn} do
160
      conn = delete(conn, ~p"/api/v3/repos/OpenAgents/openagents/milestones/999999")
178
      conn = delete(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/milestones/999999")
161 179
162 180
      assert json_response(conn, 404) == %{"message" => "Not Found"}
163 181
    end
test/openagents_web/controllers/project_controller_test.exs modified +52 -1

@@ -1,7 +1,7 @@

1 1
defmodule OpenAgentsWeb.ProjectControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "projects")}
4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "projects", "alice")}
5 5
6 6
  import OpenAgents.ProjectFieldsFixtures
7 7
  import OpenAgents.ProjectItemsFixtures

@@ -316,4 +316,55 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

316 316
      assert json_response(conn, 404) == %{"message" => "Not Found"}
317 317
    end
318 318
  end
319
320
  describe "owner-path authorization" do
321
    test "another username cannot read or mutate a project through any project action", %{
322
      conn: conn
323
    } do
324
      project = project_fixture(%{title: "Alice only", owner: "alice"})
325
      {:ok, issue} = Issues.create_issue(%{title: "Tracked"})
326
327
      {:ok, item} =
328
        Projects.create_project_item(%{"issue_number" => issue.number}, project)
329
330
      _field =
331
        project_field_fixture(%{
332
          project_id: project.id,
333
          name: "Status",
334
          data_type: "single_select"
335
        })
336
337
      assert get(conn, ~p"/api/v3/users/bob/projectsV2/#{project.number}")
338
             |> json_response(404) == %{"message" => "Not Found"}
339
340
      assert get(recycle(conn), ~p"/api/v3/users/bob/projectsV2/#{project.number}/items")
341
             |> json_response(404) == %{"message" => "Not Found"}
342
343
      assert get(recycle(conn), ~p"/api/v3/users/bob/projectsV2/#{project.number}/fields")
344
             |> json_response(404) == %{"message" => "Not Found"}
345
346
      assert post(
347
               recycle(conn),
348
               ~p"/api/v3/users/bob/projectsV2/#{project.number}/items",
349
               %{issue_number: issue.number}
350
             )
351
             |> json_response(404) == %{"message" => "Not Found"}
352
353
      assert patch(
354
               recycle(conn),
355
               ~p"/api/v3/users/bob/projectsV2/#{project.number}/items/#{item.id}",
356
               %{values: %{"Status" => "Done"}}
357
             )
358
             |> json_response(404) == %{"message" => "Not Found"}
359
360
      assert Projects.get_project_item!(item.id).values == %{}
361
    end
362
363
    test "a token owner cannot create a project for another username", %{conn: conn} do
364
      assert post(conn, ~p"/api/v3/bob/projectsV2", %{title: "Not Bob's"})
365
             |> json_response(404) == %{"message" => "Not Found"}
366
367
      assert Projects.list_projects() == []
368
    end
369
  end
319 370
end
test/openagents_web/controllers/repository_isolation_controller_test.exs added +97

@@ -0,0 +1,97 @@

1
defmodule OpenAgentsWeb.RepositoryIsolationControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  alias OpenAgents.Issues
5
  alias OpenAgents.Labels
6
  alias OpenAgents.Milestones
7
  alias OpenAgents.Repositories
8
9
  setup %{conn: conn} do
10
    conn = put_forge_api_token(conn, "repository-isolation")
11
    initial = Repositories.initial_repository!()
12
13
    {:ok, second} =
14
      Repositories.create_repository(%{
15
        owner: "SecondOrg",
16
        name: "second-repo",
17
        visibility: "public",
18
        default_branch: "main"
19
      })
20
21
    {:ok, initial_issue} = Issues.create_issue(initial, %{title: "Initial issue"})
22
    {:ok, second_issue} = Issues.create_issue(second, %{title: "Second issue"})
23
24
    %{
25
      conn: conn,
26
      initial: initial,
27
      second: second,
28
      initial_issue: initial_issue,
29
      second_issue: second_issue
30
    }
31
  end
32
33
  test "the same issue number resolves only inside the requested repository", %{
34
    conn: conn,
35
    initial_issue: initial_issue,
36
    second_issue: second_issue
37
  } do
38
    assert initial_issue.number == second_issue.number
39
40
    assert get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/1")
41
           |> json_response(200)
42
           |> Map.fetch!("title") == "Initial issue"
43
44
    assert get(recycle(conn), ~p"/api/v3/repos/SecondOrg/second-repo/issues/1")
45
           |> json_response(200)
46
           |> Map.fetch!("title") == "Second issue"
47
  end
48
49
  test "an initial-repository token cannot mutate a repository it has not joined", %{
50
    conn: conn,
51
    second_issue: second_issue
52
  } do
53
    assert patch(conn, ~p"/api/v3/repos/SecondOrg/second-repo/issues/1", %{title: "Crossed"})
54
           |> json_response(404) == %{"message" => "Not Found"}
55
56
    assert Issues.get_issue_by_path!("SecondOrg", "second-repo", 1).title == second_issue.title
57
  end
58
59
  test "labels and milestones with equal identifiers render from the requested repository", %{
60
    conn: conn,
61
    initial: initial,
62
    second: second
63
  } do
64
    {:ok, _initial_label} = Labels.create_label(initial, %{name: "priority", color: "111111"})
65
    {:ok, _second_label} = Labels.create_label(second, %{name: "priority", color: "222222"})
66
    {:ok, _initial_milestone} = Milestones.create_milestone(initial, %{title: "Initial M"})
67
    {:ok, _second_milestone} = Milestones.create_milestone(second, %{title: "Second M"})
68
69
    assert get(conn, ~p"/api/v3/repos/SecondOrg/second-repo/labels/priority")
70
           |> json_response(200)
71
           |> Map.fetch!("color") == "222222"
72
73
    assert get(recycle(conn), ~p"/api/v3/repos/SecondOrg/second-repo/milestones/1")
74
           |> json_response(200)
75
           |> Map.fetch!("title") == "Second M"
76
  end
77
78
  test "a resource cannot be read through an unknown repository path", %{conn: conn} do
79
    assert get(conn, ~p"/api/v3/repos/Unknown/nope/issues/1")
80
           |> json_response(404) == %{"message" => "Not Found"}
81
  end
82
83
  test "anonymous API reads do not expose a private repository", %{conn: conn} do
84
    {:ok, private_repository} =
85
      Repositories.create_repository(%{
86
        owner: "PrivateOrg",
87
        name: "private-repo",
88
        visibility: "private",
89
        default_branch: "main"
90
      })
91
92
    assert {:ok, _issue} = Issues.create_issue(private_repository, %{title: "Private"})
93
94
    assert get(conn, ~p"/api/v3/repos/PrivateOrg/private-repo/issues/1")
95
           |> json_response(404) == %{"message" => "Not Found"}
96
  end
97
end
test/openagents_web/live/assignee_index_live_test.exs modified +19 -7

@@ -1,9 +1,10 @@

1 1
defmodule OpenAgentsWeb.AssigneeIndexLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  import Phoenix.LiveViewTest
5 5
6 6
  alias OpenAgents.Issues
7
  import OpenAgents.AccountsFixtures
7 8
8 9
  setup %{conn: conn} do
9 10
    {:ok, conn: log_in_github_user(conn, "assignee-index")}

@@ -18,9 +19,20 @@ defmodule OpenAgentsWeb.AssigneeIndexLiveTest do

18 19
  end
19 20
20 21
  test "tallies assignees across open and closed issues, most-assigned first", %{conn: conn} do
21
    {:ok, _first} = Issues.create_issue(%{"title" => "First", "assignees" => ["ada", "grace"]})
22
    {:ok, _second} = Issues.create_issue(%{"title" => "Second", "assignees" => ["ada"]})
23
    {:ok, third} = Issues.create_issue(%{"title" => "Third", "assignees" => ["ada"]})
22
    repository_user_fixture("ada-assignee")
23
    repository_user_fixture("grace-assignee")
24
25
    {:ok, _first} =
26
      Issues.create_issue(%{
27
        "title" => "First",
28
        "assignees" => ["ada-assignee", "grace-assignee"]
29
      })
30
31
    {:ok, _second} =
32
      Issues.create_issue(%{"title" => "Second", "assignees" => ["ada-assignee"]})
33
34
    {:ok, third} =
35
      Issues.create_issue(%{"title" => "Third", "assignees" => ["ada-assignee"]})
24 36
25 37
    # A closed issue still counts: the view lists `state: "all"`.
26 38
    {:ok, _} = Issues.update_issue(third, %{"state" => "closed"})

@@ -30,10 +42,10 @@ defmodule OpenAgentsWeb.AssigneeIndexLiveTest do

30 42
    assert has_element?(view, "#assignees")
31 43
    refute html =~ "No assignees have been assigned"
32 44
33
    # Rows are sorted by descending count, so `ada` (3) precedes `grace` (1).
34
    assert has_element?(view, "#assignees tr:first-child td:first-child", "ada")
45
    # Rows are sorted by descending count.
46
    assert has_element?(view, "#assignees tr:first-child td:first-child", "ada-assignee")
35 47
    assert has_element?(view, "#assignees tr:first-child td:nth-child(2)", "3")
36
    assert has_element?(view, "#assignees tr:nth-child(2) td:first-child", "grace")
48
    assert has_element?(view, "#assignees tr:nth-child(2) td:first-child", "grace-assignee")
37 49
    assert has_element?(view, "#assignees tr:nth-child(2) td:nth-child(2)", "1")
38 50
    refute has_element?(view, "#assignees tr:nth-child(3)")
39 51
  end
test/openagents_web/live/issue_index_live_test.exs modified +5 -3

@@ -1,7 +1,8 @@

1 1
defmodule OpenAgentsWeb.IssueIndexLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  import Phoenix.LiveViewTest
5
  import OpenAgents.AccountsFixtures
5 6
  import OpenAgents.LabelsFixtures
6 7
7 8
  alias OpenAgents.Issues

@@ -32,6 +33,7 @@ defmodule OpenAgentsWeb.IssueIndexLiveTest do

32 33
  end
33 34
34 35
  test "lists open issues with their number, author, labels, and assignees", %{conn: conn} do
36
    repository_user_fixture("grace-index")
35 37
    label_fixture(%{name: "bug", color: "d73a4a"})
36 38
37 39
    {:ok, issue} =

@@ -39,7 +41,7 @@ defmodule OpenAgentsWeb.IssueIndexLiveTest do

39 41
        "title" => "Streaming stalls",
40 42
        "user" => %{"login" => "ada"},
41 43
        "labels" => ["bug"],
42
        "assignees" => ["grace"]
44
        "assignees" => ["grace-index"]
43 45
      })
44 46
45 47
    {:ok, view, html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues")

@@ -56,7 +58,7 @@ defmodule OpenAgentsWeb.IssueIndexLiveTest do

56 58
    assert html =~ "##{issue.number}"
57 59
    assert html =~ "ada"
58 60
    assert html =~ "bug"
59
    assert has_element?(view, ~s{[title="grace"]})
61
    assert has_element?(view, ~s{[title="grace-index"]})
60 62
  end
61 63
62 64
  test "an issue with no author falls back to anonymous", %{conn: conn} do
test/openagents_web/live/issue_new_live_test.exs modified +1 -1

@@ -1,5 +1,5 @@

1 1
defmodule OpenAgentsWeb.IssueNewLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  import Phoenix.LiveViewTest
5 5
  import OpenAgents.LabelsFixtures
test/openagents_web/live/issue_show_live_test.exs modified +5 -3

@@ -1,7 +1,8 @@

1 1
defmodule OpenAgentsWeb.IssueShowLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  import Phoenix.LiveViewTest
5
  import OpenAgents.AccountsFixtures
5 6
  import OpenAgents.LabelsFixtures
6 7
  import OpenAgents.MilestonesFixtures
7 8

@@ -46,6 +47,7 @@ defmodule OpenAgentsWeb.IssueShowLiveTest do

46 47
  end
47 48
48 49
  test "the sidebar only renders sections the issue actually has", %{conn: conn} do
50
    repository_user_fixture("grace-show")
49 51
    bare = issue!(%{"title" => "Bare"})
50 52
    {:ok, _view, html} = live(conn, path(bare))
51 53

@@ -56,7 +58,7 @@ defmodule OpenAgentsWeb.IssueShowLiveTest do

56 58
    label_fixture(%{name: "bug", color: "d73a4a"})
57 59
    milestone = milestone_fixture(%{title: "v1.0", due_on: nil})
58 60
59
    rich = issue!(%{"title" => "Rich", "labels" => ["bug"], "assignees" => ["grace"]})
61
    rich = issue!(%{"title" => "Rich", "labels" => ["bug"], "assignees" => ["grace-show"]})
60 62
    {:ok, rich} = Issues.set_milestone(rich, milestone.number)
61 63
62 64
    {:ok, view, html} = live(conn, path(rich))

@@ -64,7 +66,7 @@ defmodule OpenAgentsWeb.IssueShowLiveTest do

64 66
    assert html =~ "Labels"
65 67
    assert html =~ "bug"
66 68
    assert html =~ "Assignees"
67
    assert has_element?(view, ~s{[title="grace"]})
69
    assert has_element?(view, ~s{[title="grace-show"]})
68 70
    assert has_element?(view, ~s{a[href="/OpenAgentsInc/openagents.com/milestones"]}, "v1.0")
69 71
  end
70 72
test/openagents_web/live/label_index_live_test.exs modified +1 -1

@@ -1,5 +1,5 @@

1 1
defmodule OpenAgentsWeb.LabelIndexLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  import Phoenix.LiveViewTest
5 5
  import OpenAgents.LabelsFixtures
test/openagents_web/live/milestone_index_live_test.exs modified +1 -1

@@ -1,5 +1,5 @@

1 1
defmodule OpenAgentsWeb.MilestoneIndexLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  import Phoenix.LiveViewTest
5 5
  import OpenAgents.MilestonesFixtures
test/openagents_web/live/project_index_live_test.exs modified +12 -7

@@ -1,5 +1,5 @@

1 1
defmodule OpenAgentsWeb.ProjectIndexLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  import Phoenix.LiveViewTest
5 5
  import OpenAgents.ProjectsFixtures

@@ -7,7 +7,9 @@ defmodule OpenAgentsWeb.ProjectIndexLiveTest do

7 7
  alias OpenAgents.Projects
8 8
9 9
  setup %{conn: conn} do
10
    {:ok, conn: log_in_github_user(conn, "project-index")}
10
    user = github_user("project-index")
11
    conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
12
    {:ok, conn: conn, user: user}
11 13
  end
12 14
13 15
  test "mounts with the create form and an empty state", %{conn: conn} do

@@ -39,16 +41,19 @@ defmodule OpenAgentsWeb.ProjectIndexLiveTest do

39 41
           )
40 42
  end
41 43
42
  test "a project owned by someone else is filtered out", %{conn: conn} do
44
  test "the repository board lists projects regardless of their user owner", %{conn: conn} do
43 45
    project_fixture(%{title: "Someone elses", owner: "other-org"})
44 46
45 47
    {:ok, _view, html} = live(conn, ~p"/OpenAgentsInc/openagents.com/projects")
46 48
47
    refute html =~ "Someone elses"
48
    assert html =~ "No projects yet"
49
    assert html =~ "Someone elses"
50
    refute html =~ "No projects yet"
49 51
  end
50 52
51
  test "submitting the form creates a project owned by the URL owner", %{conn: conn} do
53
  test "submitting the form creates a project owned by the authenticated member", %{
54
    conn: conn,
55
    user: user
56
  } do
52 57
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/projects")
53 58
54 59
    html =

@@ -61,7 +66,7 @@ defmodule OpenAgentsWeb.ProjectIndexLiveTest do

61 66
62 67
    assert [project] = Projects.list_projects()
63 68
    assert project.title == "Q3 delivery"
64
    assert project.owner == "OpenAgentsInc"
69
    assert project.owner == user.github_login
65 70
    assert project.state == "open"
66 71
  end
67 72
test/openagents_web/live/project_show_live_test.exs modified +1 -1

@@ -1,5 +1,5 @@

1 1
defmodule OpenAgentsWeb.ProjectShowLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  import Phoenix.LiveViewTest
5 5
  import OpenAgents.LabelsFixtures
test/support/conn_case.ex modified +21 -2

@@ -38,16 +38,24 @@ defmodule OpenAgentsWeb.ConnCase do

38 38
39 39
  def github_user(key) when is_binary(key) do
40 40
    digest = :crypto.hash(:sha256, key)
41
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()
42 41
    login_suffix = digest |> Base.encode16(case: :lower) |> binary_part(0, 12)
43 42
43
    github_user(key, "test-#{login_suffix}")
44
  end
45
46
  def github_user(key, login) when is_binary(key) and is_binary(login) do
47
    digest = :crypto.hash(:sha256, key)
48
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()
49
44 50
    {:ok, user} =
45 51
      OpenAgents.Accounts.upsert_github_user(%{
46 52
        github_id: github_id,
47
        github_login: "test-#{login_suffix}",
53
        github_login: login,
48 54
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
49 55
      })
50 56
57
    {:ok, _membership} = OpenAgents.Repositories.ensure_initial_membership(user)
58
51 59
    user
52 60
  end
53 61

@@ -59,6 +67,17 @@ defmodule OpenAgentsWeb.ConnCase do

59 67
  def put_forge_api_token(conn, key) when is_binary(key) do
60 68
    user = github_user("api-token-" <> key)
61 69
70
    put_forge_api_token_for_user(conn, user)
71
  end
72
73
  def put_forge_api_token(conn, key, login)
74
      when is_binary(key) and is_binary(login) do
75
    user = github_user("api-token-" <> key, login)
76
77
    put_forge_api_token_for_user(conn, user)
78
  end
79
80
  defp put_forge_api_token_for_user(conn, user) do
62 81
    {:ok, _credential, plaintext} =
63 82
      OpenAgents.ApiTokens.create(user, %{
64 83
        name: "test forge client",
test/support/fixtures/accounts_fixtures.ex added +17

@@ -0,0 +1,17 @@

1
defmodule OpenAgents.AccountsFixtures do
2
  @moduledoc "Test helpers for repository-assignable accounts."
3
4
  def repository_user_fixture(login) when is_binary(login) do
5
    github_id = System.unique_integer([:positive, :monotonic])
6
7
    {:ok, user} =
8
      OpenAgents.Accounts.upsert_github_user(%{
9
        github_id: github_id,
10
        github_login: login,
11
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
12
      })
13
14
    {:ok, _membership} = OpenAgents.Repositories.ensure_initial_membership(user)
15
    user
16
  end
17
end
test/support/fixtures/projects_fixtures.ex modified -1

@@ -11,7 +11,6 @@ defmodule OpenAgents.ProjectsFixtures do

11 11
    {:ok, project} =
12 12
      attrs
13 13
      |> Enum.into(%{
14
        number: 42,
15 14
        owner: "some owner",
16 15
        state: "some state",
17 16
        title: "some title"

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