Add GitHub namespace discovery adapter

cf3d9591cc31 · AtlantisPleb · · parent 6fb2c9cf69a9

Add GitHub namespace discovery adapter

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 lib/openagents/github.ex
  • modified lib/openagents/github_oauth.ex
  • modified test/openagents/github_oauth_test.exs
  • modified test/openagents/github_test.exs

Diff

4 files changed, +916 -58

lib/openagents/github.ex modified +547 -40

@@ -4,28 +4,154 @@ defmodule OpenAgents.GitHub do

4 4
  @github_api_version "2022-11-28"
5 5
  @user_agent "OpenAgents"
6 6
  @full_name_regex ~r/\A[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+\z/
7
  @object_id_regex ~r/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
7 8
  @maximum_file_bytes 65_536
9
  @maximum_page 1_000
10
  @maximum_per_page 100
11
  @maximum_reference_pages 20
12
  @maximum_tree_entries 100_000
13
  @maximum_attribute_files 100
14
  @large_blob_bytes 100_000_000
8 15
16
  @typedoc "A bounded page projected from a GitHub REST collection."
17
  @type page(item) :: %{
18
          required(String.t()) => [item] | pos_integer() | boolean() | nil
19
        }
20
21
  @typedoc "A normalized current-user identity keyed by GitHub's immutable account ID."
22
  @type user_identity :: %{required(String.t()) => String.t() | pos_integer()}
23
24
  @typedoc "A normalized repository used for namespace and import-source discovery."
25
  @type repository :: %{required(String.t()) => term()}
26
27
  @doc "Returns the immutable GitHub identity associated with a retained OAuth token."
28
  @spec current_user(String.t()) :: {:ok, user_identity()} | {:error, atom()}
29
  def current_user(token) when is_binary(token) do
30
    with {:ok, body} <- request(token, "/user", []),
31
         {:ok, identity} <- project_user(body) do
32
      {:ok, identity}
33
    end
34
  end
35
36
  def current_user(_token), do: {:error, :invalid_token}
37
38
  @doc "Lists a bounded page of repositories visible to the retained GitHub grant."
39
  @spec list_repository_page(String.t(), keyword()) ::
40
          {:ok, page(repository())} | {:error, atom()}
41
  def list_repository_page(token, options \\ []) when is_binary(token) do
42
    with {:ok, page, per_page} <- pagination(options),
43
         {:ok, raw_page} <- repository_page(token, page, per_page),
44
         {:ok, repositories} <- traverse(raw_page.items, &project_repository/1) do
45
      {:ok, page_projection(repositories, page, per_page, raw_page.has_next_page)}
46
    end
47
  end
48
49
  @doc "Lists active GitHub organization memberships and their current roles."
50
  @spec list_active_organization_memberships(String.t(), keyword()) ::
51
          {:ok, page(map())} | {:error, atom()}
52
  def list_active_organization_memberships(token, options \\ []) when is_binary(token) do
53
    with {:ok, page, per_page} <- pagination(options),
54
         {:ok, response} <-
55
           request_response(token, "/user/memberships/orgs",
56
             params: %{"state" => "active", "page" => page, "per_page" => per_page}
57
           ),
58
         memberships when is_list(memberships) <- response.body,
59
         {:ok, projected} <- traverse(memberships, &project_organization_membership/1) do
60
      {:ok, page_projection(projected, page, per_page, has_next_page?(response))}
61
    else
62
      {:error, reason} -> {:error, reason}
63
      _invalid -> {:error, :github_response_invalid}
64
    end
65
  end
66
67
  @doc "Returns normalized metadata and permissions for one GitHub repository."
68
  @spec get_repository(String.t(), String.t()) :: {:ok, repository()} | {:error, atom()}
69
  def get_repository(token, full_name) when is_binary(token) do
70
    with :ok <- validate_full_name(full_name),
71
         {:ok, body} <- request(token, "/repos/#{full_name}", []),
72
         {:ok, repository} <- project_repository(body) do
73
      {:ok, repository}
74
    end
75
  end
76
77
  @doc "Returns a repository only when the retained grant can read its Git data."
78
  @spec get_import_source(String.t(), String.t()) :: {:ok, repository()} | {:error, atom()}
79
  def get_import_source(token, full_name) when is_binary(token) do
80
    with {:ok, repository} <- get_repository(token, full_name),
81
         true <- repository["readable"] do
82
      {:ok, repository}
83
    else
84
      false -> {:error, :github_permission_denied}
85
      {:error, reason} -> {:error, reason}
86
    end
87
  end
88
89
  @doc "Lists a bounded page of branch names and their current commit object IDs."
90
  @spec list_branch_page(String.t(), String.t(), keyword()) ::
91
          {:ok, page(map())} | {:error, atom()}
92
  def list_branch_page(token, full_name, options \\ []) when is_binary(token) do
93
    list_named_ref_page(token, full_name, "branches", options, &project_branch/1)
94
  end
95
96
  @doc "Lists a bounded page of tag names and their current commit object IDs."
97
  @spec list_tag_page(String.t(), String.t(), keyword()) ::
98
          {:ok, page(map())} | {:error, atom()}
99
  def list_tag_page(token, full_name, options \\ []) when is_binary(token) do
100
    list_named_ref_page(token, full_name, "tags", options, &project_tag/1)
101
  end
102
103
  @doc "Returns the complete bounded branch and tag ref projection for an import snapshot."
104
  @spec list_references(String.t(), String.t(), keyword()) ::
105
          {:ok, map()} | {:error, atom()}
106
  def list_references(token, full_name, options \\ []) when is_binary(token) do
107
    per_page = Keyword.get(options, :per_page, @maximum_per_page)
108
    max_pages = Keyword.get(options, :max_pages, 10)
109
110
    with :ok <- validate_full_name(full_name),
111
         :ok <- validate_reference_pagination(per_page, max_pages),
112
         {:ok, heads} <- collect_references(token, full_name, "heads", per_page, max_pages),
113
         {:ok, tags} <- collect_references(token, full_name, "tags", per_page, max_pages) do
114
      refs = Enum.sort_by(heads ++ tags, & &1["name"])
115
116
      {:ok,
117
       %{
118
         "count" => length(refs),
119
         "digest" => reference_digest(refs),
120
         "refs" => refs
121
       }}
122
    end
123
  end
124
125
  @doc "Returns conservative, bounded inputs for the one-time Git LFS import warning."
126
  @spec lfs_warning_inputs(String.t(), String.t(), String.t()) ::
127
          {:ok, map()} | {:error, atom()}
128
  def lfs_warning_inputs(token, full_name, ref)
129
      when is_binary(token) and is_binary(ref) do
130
    with :ok <- validate_full_name(full_name),
131
         :ok <- validate_ref(ref),
132
         encoded_ref <- encode_path_segment(ref),
133
         {:ok, body} <-
134
           request(token, "/repos/#{full_name}/git/trees/#{encoded_ref}",
135
             params: %{"recursive" => "1"}
136
           ),
137
         {:ok, inputs} <- project_lfs_warning_inputs(body) do
138
      {:ok, inputs}
139
    end
140
  end
141
142
  def lfs_warning_inputs(_token, _full_name, _ref), do: {:error, :invalid_ref}
143
144
  @doc "Lists compact repository summaries for the existing signed-in GitHub tool."
9 145
  @spec list_repositories(String.t(), keyword()) :: {:ok, [map()]} | {:error, atom()}
10 146
  def list_repositories(token, options \\ []) when is_binary(token) do
11 147
    first = Keyword.get(options, :first, 30)
12 148
13
    request(token, "/user/repos",
14
      params: %{
15
        "per_page" => first,
16
        "sort" => "pushed",
17
        "affiliation" => "owner,collaborator,organization_member"
18
      }
19
    )
20
    |> case do
21
      {:ok, repositories} when is_list(repositories) ->
22
        {:ok, Enum.map(repositories, &repository_summary/1)}
23
24
      {:ok, _body} ->
25
        {:error, :github_response_invalid}
26
27
      {:error, reason} ->
28
        {:error, reason}
149
    with true <- is_integer(first) and first in 1..50,
150
         {:ok, raw_page} <- repository_page(token, 1, first) do
151
      {:ok, Enum.map(raw_page.items, &repository_summary/1)}
152
    else
153
      false -> {:error, :invalid_pagination}
154
      {:error, reason} -> {:error, reason}
29 155
    end
30 156
  end
31 157

@@ -46,34 +172,308 @@ defmodule OpenAgents.GitHub do

46 172
    end
47 173
  end
48 174
175
  defp repository_page(token, page, per_page) do
176
    with {:ok, response} <-
177
           request_response(token, "/user/repos",
178
             params: %{
179
               "page" => page,
180
               "per_page" => per_page,
181
               "sort" => "pushed",
182
               "affiliation" => "owner,collaborator,organization_member"
183
             }
184
           ),
185
         repositories when is_list(repositories) <- response.body do
186
      {:ok, %{items: repositories, has_next_page: has_next_page?(response)}}
187
    else
188
      {:error, reason} -> {:error, reason}
189
      _invalid -> {:error, :github_response_invalid}
190
    end
191
  end
192
193
  defp list_named_ref_page(token, full_name, collection, options, projector) do
194
    with :ok <- validate_full_name(full_name),
195
         {:ok, page, per_page} <- pagination(options),
196
         {:ok, response} <-
197
           request_response(token, "/repos/#{full_name}/#{collection}",
198
             params: %{"page" => page, "per_page" => per_page}
199
           ),
200
         entries when is_list(entries) <- response.body,
201
         {:ok, projected} <- traverse(entries, projector) do
202
      {:ok, page_projection(projected, page, per_page, has_next_page?(response))}
203
    else
204
      {:error, reason} -> {:error, reason}
205
      _invalid -> {:error, :github_response_invalid}
206
    end
207
  end
208
209
  defp collect_references(token, full_name, kind, per_page, max_pages) do
210
    collect_references(token, full_name, kind, per_page, max_pages, 1, [])
211
  end
212
213
  defp collect_references(token, full_name, kind, per_page, max_pages, page, acc) do
214
    path = "/repos/#{full_name}/git/matching-refs/#{kind}/"
215
216
    with {:ok, response} <-
217
           request_response(token, path, params: %{"page" => page, "per_page" => per_page}),
218
         entries when is_list(entries) <- response.body,
219
         {:ok, projected} <- traverse(entries, &project_reference(&1, kind)) do
220
      next_acc = Enum.reverse(projected, acc)
221
222
      cond do
223
        not has_next_page?(response) ->
224
          {:ok, Enum.reverse(next_acc)}
225
226
        page >= max_pages ->
227
          {:error, :github_pagination_limit_exceeded}
228
229
        true ->
230
          collect_references(
231
            token,
232
            full_name,
233
            kind,
234
            per_page,
235
            max_pages,
236
            page + 1,
237
            next_acc
238
          )
239
      end
240
    else
241
      {:error, reason} -> {:error, reason}
242
      _invalid -> {:error, :github_response_invalid}
243
    end
244
  end
245
49 246
  defp request(token, api_path, options) do
50
    settings = Application.get_env(:openagents, :github_api, [])
51
    base_url = settings[:base_url] || "https://api.github.com"
52
53
    request_options =
54
      [
55
        auth: {:bearer, token},
56
        params: Keyword.get(options, :params, %{}),
57
        headers: [
58
          {"accept", "application/vnd.github+json"},
59
          {"x-github-api-version", @github_api_version},
60
          {"user-agent", @user_agent}
61
        ],
62
        receive_timeout: 10_000,
63
        retry: false
64
      ]
65
      |> Keyword.merge(settings[:request_options] || [])
66
67
    case Req.get(base_url <> api_path, request_options) do
68
      {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, body}
69
      {:ok, %Req.Response{status: 401}} -> {:error, :github_token_rejected}
70
      {:ok, %Req.Response{status: 403}} -> {:error, :github_permission_denied}
71
      {:ok, %Req.Response{status: 404}} -> {:error, :github_not_found}
72
      {:ok, %Req.Response{}} -> {:error, :github_request_failed}
73
      {:error, _transport_error} -> {:error, :github_unavailable}
247
    with {:ok, response} <- request_response(token, api_path, options), do: {:ok, response.body}
248
  end
249
250
  defp request_response(token, api_path, options) do
251
    with :ok <- validate_token(token) do
252
      settings = Application.get_env(:openagents, :github_api, [])
253
      base_url = settings[:base_url] || "https://api.github.com"
254
255
      request_options =
256
        [
257
          params: Keyword.get(options, :params, %{}),
258
          headers: [
259
            {"accept", "application/vnd.github+json"},
260
            {"x-github-api-version", @github_api_version},
261
            {"user-agent", @user_agent}
262
          ],
263
          receive_timeout: 10_000,
264
          retry: false
265
        ]
266
        |> Keyword.merge(settings[:request_options] || [])
267
        |> Keyword.put(:auth, {:bearer, token})
268
269
      case Req.get(base_url <> api_path, request_options) do
270
        {:ok, %Req.Response{status: status} = response} when status in 200..299 ->
271
          {:ok, response}
272
273
        {:ok, %Req.Response{status: 401}} ->
274
          {:error, :github_token_rejected}
275
276
        {:ok, %Req.Response{status: 403}} ->
277
          {:error, :github_permission_denied}
278
279
        {:ok, %Req.Response{status: 404}} ->
280
          {:error, :github_not_found}
281
282
        {:ok, %Req.Response{}} ->
283
          {:error, :github_request_failed}
284
285
        {:error, _transport_error} ->
286
          {:error, :github_unavailable}
287
      end
288
    end
289
  end
290
291
  defp project_user(
292
         %{
293
           "id" => id,
294
           "node_id" => node_id,
295
           "login" => login,
296
           "avatar_url" => avatar_url
297
         } = user
298
       )
299
       when is_integer(id) and id > 0 and is_binary(node_id) and is_binary(login) and
300
              is_binary(avatar_url) do
301
    if valid_login?(login) do
302
      {:ok,
303
       %{
304
         "id" => id,
305
         "node_id" => bounded_string(node_id, 128),
306
         "login" => bounded_string(login, 100),
307
         "name" => bounded_string(user["name"], 255),
308
         "avatar_url" => github_avatar_url(avatar_url),
309
         "type" => normalize_account_type(user["type"], "User")
310
       }}
311
    else
312
      {:error, :github_response_invalid}
313
    end
314
  end
315
316
  defp project_user(_body), do: {:error, :github_response_invalid}
317
318
  defp project_repository(
319
         %{
320
           "id" => id,
321
           "node_id" => node_id,
322
           "name" => name,
323
           "full_name" => full_name,
324
           "private" => private,
325
           "default_branch" => default_branch,
326
           "owner" => owner
327
         } = repository
328
       )
329
       when is_integer(id) and id > 0 and is_binary(node_id) and is_binary(name) and
330
              is_binary(full_name) and is_boolean(private) and is_binary(default_branch) and
331
              is_map(owner) do
332
    with :ok <- validate_full_name(full_name),
333
         {:ok, owner_projection} <- project_account(owner, "User") do
334
      permissions = project_permissions(repository["permissions"])
335
      readable = permissions["pull"] or not private
336
337
      {:ok,
338
       %{
339
         "id" => id,
340
         "node_id" => bounded_string(node_id, 128),
341
         "name" => bounded_string(name, 100),
342
         "full_name" => bounded_string(full_name, 140),
343
         "owner" => owner_projection,
344
         "description" => bounded_string(repository["description"], 350),
345
         "private" => private,
346
         "fork" => repository["fork"] == true,
347
         "archived" => repository["archived"] == true,
348
         "default_branch" => bounded_string(default_branch, 255),
349
         "language" => bounded_string(repository["language"], 60),
350
         "pushed_at" => bounded_string(repository["pushed_at"], 32),
351
         "size_kb" => bounded_size(repository["size"]),
352
         "permissions" => permissions,
353
         "readable" => readable
354
       }}
355
    end
356
  end
357
358
  defp project_repository(_body), do: {:error, :github_response_invalid}
359
360
  defp project_organization_membership(%{
361
         "state" => "active",
362
         "role" => role,
363
         "organization" => organization
364
       })
365
       when role in ["admin", "member"] and is_map(organization) do
366
    with {:ok, projected} <- project_account(organization, "Organization") do
367
      {:ok, %{"state" => "active", "role" => role, "organization" => projected}}
74 368
    end
75 369
  end
76 370
371
  defp project_organization_membership(_body), do: {:error, :github_response_invalid}
372
373
  defp project_account(
374
         %{"id" => id, "node_id" => node_id, "login" => login, "avatar_url" => avatar_url} =
375
           account,
376
         default_type
377
       )
378
       when is_integer(id) and id > 0 and is_binary(node_id) and is_binary(login) and
379
              is_binary(avatar_url) do
380
    if valid_login?(login) do
381
      {:ok,
382
       %{
383
         "id" => id,
384
         "node_id" => bounded_string(node_id, 128),
385
         "login" => bounded_string(login, 100),
386
         "avatar_url" => github_avatar_url(avatar_url),
387
         "type" => normalize_account_type(account["type"], default_type)
388
       }}
389
    else
390
      {:error, :github_response_invalid}
391
    end
392
  end
393
394
  defp project_account(_account, _default_type), do: {:error, :github_response_invalid}
395
396
  defp project_branch(%{"name" => name, "commit" => %{"sha" => sha}} = branch)
397
       when is_binary(name) and is_binary(sha) do
398
    if valid_object_id?(sha) do
399
      {:ok,
400
       %{
401
         "name" => bounded_string(name, 255),
402
         "sha" => sha,
403
         "protected" => branch["protected"] == true
404
       }}
405
    else
406
      {:error, :github_response_invalid}
407
    end
408
  end
409
410
  defp project_branch(_body), do: {:error, :github_response_invalid}
411
412
  defp project_tag(%{"name" => name, "commit" => %{"sha" => sha}})
413
       when is_binary(name) and is_binary(sha) do
414
    if valid_object_id?(sha),
415
      do: {:ok, %{"name" => bounded_string(name, 255), "sha" => sha}},
416
      else: {:error, :github_response_invalid}
417
  end
418
419
  defp project_tag(_body), do: {:error, :github_response_invalid}
420
421
  defp project_reference(
422
         %{"ref" => name, "object" => %{"type" => object_type, "sha" => sha}},
423
         kind
424
       )
425
       when is_binary(name) and is_binary(object_type) and is_binary(sha) do
426
    expected_prefix = "refs/#{kind}/"
427
428
    if String.starts_with?(name, expected_prefix) and byte_size(name) <= 512 and
429
         object_type in ["blob", "commit", "tag", "tree"] and valid_object_id?(sha) do
430
      {:ok, %{"name" => name, "object_type" => object_type, "sha" => sha}}
431
    else
432
      {:error, :github_response_invalid}
433
    end
434
  end
435
436
  defp project_reference(_body, _kind), do: {:error, :github_response_invalid}
437
438
  defp project_lfs_warning_inputs(%{"tree" => tree} = body) when is_list(tree) do
439
    entries = Enum.take(tree, @maximum_tree_entries)
440
441
    attributes_files =
442
      entries
443
      |> Enum.filter(fn entry ->
444
        is_map(entry) and entry["type"] == "blob" and is_binary(entry["path"]) and
445
          Path.basename(entry["path"]) == ".gitattributes"
446
      end)
447
      |> Enum.map(&bounded_string(&1["path"], 500))
448
      |> Enum.sort()
449
      |> Enum.take(@maximum_attribute_files)
450
451
    lfs_config_present =
452
      Enum.any?(entries, fn entry ->
453
        is_map(entry) and entry["type"] == "blob" and entry["path"] == ".lfsconfig"
454
      end)
455
456
    large_blob_count =
457
      Enum.count(entries, fn entry ->
458
        is_map(entry) and entry["type"] == "blob" and is_integer(entry["size"]) and
459
          entry["size"] >= @large_blob_bytes
460
      end)
461
462
    tree_truncated = body["truncated"] == true or length(tree) > @maximum_tree_entries
463
464
    {:ok,
465
     %{
466
       "attributes_files" => attributes_files,
467
       "large_blob_count" => large_blob_count,
468
       "lfs_config_present" => lfs_config_present,
469
       "tree_truncated" => tree_truncated,
470
       "warning_recommended" =>
471
         attributes_files != [] or lfs_config_present or large_blob_count > 0 or tree_truncated
472
     }}
473
  end
474
475
  defp project_lfs_warning_inputs(_body), do: {:error, :github_response_invalid}
476
77 477
  defp repository_summary(repository) when is_map(repository) do
78 478
    %{
79 479
      "full_name" => bounded_string(repository["full_name"], 140),

@@ -85,6 +485,80 @@ defmodule OpenAgents.GitHub do

85 485
    }
86 486
  end
87 487
488
  defp project_permissions(permissions) when is_map(permissions) do
489
    %{
490
      "admin" => permissions["admin"] == true,
491
      "maintain" => permissions["maintain"] == true,
492
      "pull" => permissions["pull"] == true,
493
      "push" => permissions["push"] == true,
494
      "triage" => permissions["triage"] == true
495
    }
496
  end
497
498
  defp project_permissions(_permissions) do
499
    %{"admin" => false, "maintain" => false, "pull" => false, "push" => false, "triage" => false}
500
  end
501
502
  defp page_projection(items, page, per_page, has_next_page) do
503
    %{
504
      "items" => items,
505
      "page" => page,
506
      "per_page" => per_page,
507
      "has_next_page" => has_next_page,
508
      "next_page" => if(has_next_page, do: page + 1, else: nil)
509
    }
510
  end
511
512
  defp pagination(options) when is_list(options) do
513
    page = Keyword.get(options, :page, 1)
514
    per_page = Keyword.get(options, :per_page, @maximum_per_page)
515
516
    if is_integer(page) and page in 1..@maximum_page and is_integer(per_page) and
517
         per_page in 1..@maximum_per_page do
518
      {:ok, page, per_page}
519
    else
520
      {:error, :invalid_pagination}
521
    end
522
  end
523
524
  defp pagination(_options), do: {:error, :invalid_pagination}
525
526
  defp validate_reference_pagination(per_page, max_pages)
527
       when is_integer(per_page) and per_page in 1..@maximum_per_page and
528
              is_integer(max_pages) and max_pages in 1..@maximum_reference_pages,
529
       do: :ok
530
531
  defp validate_reference_pagination(_per_page, _max_pages),
532
    do: {:error, :invalid_pagination}
533
534
  defp has_next_page?(response) do
535
    response
536
    |> Req.Response.get_header("link")
537
    |> Enum.any?(&Regex.match?(~r/<[^>]+>;\s*rel="next"/, &1))
538
  end
539
540
  defp reference_digest(refs) do
541
    refs
542
    |> Enum.map_join("\n", fn ref ->
543
      Enum.join([ref["name"], ref["object_type"], ref["sha"]], "\0")
544
    end)
545
    |> then(&:crypto.hash(:sha256, &1))
546
    |> Base.encode16(case: :lower)
547
  end
548
549
  defp traverse(entries, projector) do
550
    Enum.reduce_while(entries, {:ok, []}, fn entry, {:ok, acc} ->
551
      case projector.(entry) do
552
        {:ok, projected} -> {:cont, {:ok, [projected | acc]}}
553
        {:error, reason} -> {:halt, {:error, reason}}
554
      end
555
    end)
556
    |> case do
557
      {:ok, reversed} -> {:ok, Enum.reverse(reversed)}
558
      {:error, reason} -> {:error, reason}
559
    end
560
  end
561
88 562
  defp bounded_size(size) when is_integer(size) and size >= 0, do: size
89 563
  defp bounded_size(_size), do: 0
90 564

@@ -155,6 +629,9 @@ defmodule OpenAgents.GitHub do

155 629
    end
156 630
  end
157 631
632
  defp validate_token(token) when is_binary(token) and byte_size(token) in 1..512, do: :ok
633
  defp validate_token(_token), do: {:error, :invalid_token}
634
158 635
  defp validate_full_name(full_name)
159 636
       when is_binary(full_name) and byte_size(full_name) in 3..140 do
160 637
    if Regex.match?(@full_name_regex, full_name), do: :ok, else: {:error, :invalid_repository}

@@ -172,6 +649,36 @@ defmodule OpenAgents.GitHub do

172 649
173 650
  defp validate_path(_path), do: {:error, :invalid_repository_path}
174 651
652
  defp validate_ref(ref) when is_binary(ref) and byte_size(ref) in 1..255 do
653
    if String.valid?(ref) and not String.contains?(ref, ["\0", ".."]),
654
      do: :ok,
655
      else: {:error, :invalid_ref}
656
  end
657
658
  defp validate_ref(_ref), do: {:error, :invalid_ref}
659
660
  defp valid_login?(login) do
661
    is_binary(login) and byte_size(login) in 1..100 and
662
      Regex.match?(~r/\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,98}[A-Za-z0-9])?\z/, login)
663
  end
664
665
  defp valid_object_id?(sha), do: is_binary(sha) and Regex.match?(@object_id_regex, sha)
666
667
  defp normalize_account_type(type, _default) when type in ["User", "Organization"], do: type
668
  defp normalize_account_type(_type, default), do: default
669
670
  defp github_avatar_url(value) when is_binary(value) do
671
    case URI.new(value) do
672
      {:ok, %URI{scheme: "https", host: "avatars.githubusercontent.com"}} ->
673
        bounded_string(value, 500)
674
675
      _invalid ->
676
        ""
677
    end
678
  end
679
680
  defp encode_path_segment(value), do: URI.encode(value, &URI.char_unreserved?/1)
681
175 682
  defp bounded_string(value, maximum) when is_binary(value), do: String.slice(value, 0, maximum)
176 683
  defp bounded_string(_value, _maximum), do: ""
177 684
end
lib/openagents/github_oauth.ex modified +24 -12

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

11 11
  @default_attempt_ttl_seconds 600
12 12
  @github_api_version "2022-11-28"
13 13
  @user_agent "OpenAgents"
14
  @required_scopes ["repo", "read:org"]
14 15
15 16
  @type attempt :: %{required(String.t()) => String.t() | integer()}
16 17

@@ -80,13 +81,13 @@ defmodule OpenAgents.GitHubOAuth do

80 81
    with {:ok, config} <- config() do
81 82
      request_options =
82 83
        [
83
          auth: {:basic, config.client_id <> ":" <> config.client_secret},
84
          json: %{"access_token" => access_token},
85 84
          headers: api_headers(),
86 85
          receive_timeout: 10_000,
87 86
          retry: false
88 87
        ]
89 88
        |> Keyword.merge(config.request_options)
89
        |> Keyword.put(:auth, {:basic, config.client_id <> ":" <> config.client_secret})
90
        |> Keyword.put(:json, %{"access_token" => access_token})
90 91
91 92
      case Req.delete(config.revoke_url, request_options) do
92 93
        {:ok, %Req.Response{status: 204}} ->

@@ -108,23 +109,27 @@ defmodule OpenAgents.GitHubOAuth do

108 109
109 110
  @doc "The exact OAuth scopes retained with each connected GitHub grant."
110 111
  @spec requested_scopes() :: [String.t()]
111
  def requested_scopes, do: Application.fetch_env!(:openagents, :github_oauth_scopes)
112
  def requested_scopes, do: @required_scopes
113
114
  @doc "The only OAuth scopes admitted by the GitHub-backed namespace and import model."
115
  @spec required_scopes() :: [String.t()]
116
  def required_scopes, do: @required_scopes
112 117
113 118
  defp exchange_code(config, code, verifier) do
114 119
    request_options =
115 120
      [
116
        form: [
117
          client_id: config.client_id,
118
          client_secret: config.client_secret,
119
          code: code,
120
          redirect_uri: config.redirect_uri,
121
          code_verifier: verifier
122
        ],
123 121
        headers: oauth_headers(),
124 122
        receive_timeout: 10_000,
125 123
        retry: false
126 124
      ]
127 125
      |> Keyword.merge(config.request_options)
126
      |> Keyword.put(:form,
127
        client_id: config.client_id,
128
        client_secret: config.client_secret,
129
        code: code,
130
        redirect_uri: config.redirect_uri,
131
        code_verifier: verifier
132
      )
128 133
129 134
    case Req.post(config.token_url, request_options) do
130 135
      {:ok,

@@ -150,12 +155,12 @@ defmodule OpenAgents.GitHubOAuth do

150 155
  defp fetch_profile(config, access_token) do
151 156
    request_options =
152 157
      [
153
        auth: {:bearer, access_token},
154 158
        headers: api_headers(),
155 159
        receive_timeout: 10_000,
156 160
        retry: false
157 161
      ]
158 162
      |> Keyword.merge(config.request_options)
163
      |> Keyword.put(:auth, {:bearer, access_token})
159 164
160 165
    case Req.get(config.user_url, request_options) do
161 166
      {:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->

@@ -209,7 +214,8 @@ defmodule OpenAgents.GitHubOAuth do

209 214
  defp config do
210 215
    settings = Application.get_env(:openagents, :github_oauth, [])
211 216
212
    with client_id when is_binary(client_id) and client_id != "" <- settings[:client_id],
217
    with :ok <- validate_scope_configuration(),
218
         client_id when is_binary(client_id) and client_id != "" <- settings[:client_id],
213 219
         client_secret when is_binary(client_secret) and client_secret != "" <-
214 220
           settings[:client_secret],
215 221
         redirect_uri when is_binary(redirect_uri) and redirect_uri != "" <-

@@ -245,6 +251,12 @@ defmodule OpenAgents.GitHubOAuth do

245 251
      else: {:error, :invalid_oauth_callback}
246 252
  end
247 253
254
  defp validate_scope_configuration do
255
    if Application.get_env(:openagents, :github_oauth_scopes) == @required_scopes,
256
      do: :ok,
257
      else: {:error, :github_oauth_scope_configuration_invalid}
258
  end
259
248 260
  defp oauth_headers do
249 261
    [
250 262
      {"accept", "application/json"},
test/openagents/github_oauth_test.exs modified +25 -6

@@ -5,13 +5,24 @@ defmodule OpenAgents.GitHubOAuthTest do

5 5
6 6
  setup {Req.Test, :verify_on_exit!}
7 7
8
  setup do
9
    original_scopes = Application.fetch_env!(:openagents, :github_oauth_scopes)
10
    Application.put_env(:openagents, :github_oauth_scopes, ["repo", "read:org"])
11
12
    on_exit(fn ->
13
      Application.put_env(:openagents, :github_oauth_scopes, original_scopes)
14
    end)
15
16
    :ok
17
  end
18
8 19
  test "authorization attempts use state, S256 PKCE, bounded scope, and one-time receipts" do
9 20
    assert {:ok, attempt, authorization_url} = GitHubOAuth.begin_authorization()
10 21
    query = authorization_url |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query()
11 22
12 23
    assert query["client_id"] == "test-github-client-id"
13 24
    assert query["redirect_uri"] == "http://127.0.0.1:4002/auth/github/callback"
14
    assert query["scope"] == "repo"
25
    assert query["scope"] == "repo read:org"
15 26
    assert query["state"] == attempt["state"]
16 27
    assert query["code_challenge_method"] == "S256"
17 28
    assert byte_size(query["code_challenge"]) == 43

@@ -54,7 +65,7 @@ defmodule OpenAgents.GitHubOAuthTest do

54 65
      Req.Test.json(conn, %{
55 66
        "access_token" => "short-lived-token",
56 67
        "token_type" => "bearer",
57
        "scope" => "repo"
68
        "scope" => "read:org,repo"
58 69
      })
59 70
    end)
60 71

@@ -73,7 +84,7 @@ defmodule OpenAgents.GitHubOAuthTest do

73 84
74 85
    verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
75 86
76
    assert {:ok, profile, access_token, ["repo"]} =
87
    assert {:ok, profile, access_token, ["repo", "read:org"]} =
77 88
             GitHubOAuth.exchange_and_fetch("github-code", verifier)
78 89
79 90
    assert access_token == "short-lived-token"

@@ -96,7 +107,7 @@ defmodule OpenAgents.GitHubOAuthTest do

96 107
      Req.Test.expect(__MODULE__, fn conn ->
97 108
        Req.Test.json(conn, %{
98 109
          "access_token" => "short-lived-token",
99
          "scope" => "repo"
110
          "scope" => "repo read:org"
100 111
        })
101 112
      end)
102 113

@@ -127,7 +138,10 @@ defmodule OpenAgents.GitHubOAuthTest do

127 138
    setup_req_test()
128 139
129 140
    Req.Test.expect(__MODULE__, fn conn ->
130
      Req.Test.json(conn, %{"access_token" => "provider-token", "scope" => "repo"})
141
      Req.Test.json(conn, %{
142
        "access_token" => "provider-token",
143
        "scope" => "repo,read:org"
144
      })
131 145
    end)
132 146
133 147
    Req.Test.expect(__MODULE__, fn conn ->

@@ -143,7 +157,7 @@ defmodule OpenAgents.GitHubOAuthTest do

143 157
  end
144 158
145 159
  test "a missing or broadened granted scope fails before profile lookup" do
146
    for scope <- [nil, "", "read:user", "repo,admin:org"] do
160
    for scope <- [nil, "", "repo", "read:org", "read:user", "repo,read:org,admin:org"] do
147 161
      setup_req_test()
148 162
149 163
      Req.Test.expect(__MODULE__, fn conn ->

@@ -158,6 +172,11 @@ defmodule OpenAgents.GitHubOAuthTest do

158 172
    end
159 173
  end
160 174
175
  test "the retained grant model requires exactly repository and organization read scopes" do
176
    assert GitHubOAuth.required_scopes() == ["repo", "read:org"]
177
    assert GitHubOAuth.requested_scopes() == ["repo", "read:org"]
178
  end
179
161 180
  defp setup_req_test do
162 181
    original = Application.fetch_env!(:openagents, :github_oauth)
163 182
test/openagents/github_test.exs modified +320

@@ -55,6 +55,291 @@ defmodule OpenAgents.GitHubTest do

55 55
    refute inspect(summary) =~ "leaky"
56 56
  end
57 57
58
  test "current user projects the immutable GitHub identity without provider URLs" do
59
    Req.Test.expect(__MODULE__, fn conn ->
60
      assert conn.request_path == "/user"
61
      assert ["Bearer gho_identity-token"] = Plug.Conn.get_req_header(conn, "authorization")
62
63
      Req.Test.json(conn, %{
64
        "id" => 7_654,
65
        "node_id" => "MDQ6VXNlcjc2NTQ=",
66
        "login" => "octo-user",
67
        "name" => "Octavia Example",
68
        "avatar_url" => "https://avatars.githubusercontent.com/u/7654?v=4",
69
        "type" => "User",
70
        "html_url" => "https://github.com/octo-user"
71
      })
72
    end)
73
74
    assert {:ok, identity} = GitHub.current_user("gho_identity-token")
75
76
    assert identity == %{
77
             "id" => 7_654,
78
             "node_id" => "MDQ6VXNlcjc2NTQ=",
79
             "login" => "octo-user",
80
             "name" => "Octavia Example",
81
             "avatar_url" => "https://avatars.githubusercontent.com/u/7654?v=4",
82
             "type" => "User"
83
           }
84
85
    refute Map.has_key?(identity, "html_url")
86
  end
87
88
  test "repository discovery returns bounded pages with immutable owners and permissions" do
89
    Req.Test.expect(__MODULE__, fn conn ->
90
      assert conn.request_path == "/user/repos"
91
92
      query = URI.decode_query(conn.query_string)
93
      assert query["page"] == "2"
94
      assert query["per_page"] == "2"
95
      assert query["affiliation"] == "owner,collaborator,organization_member"
96
97
      conn
98
      |> Plug.Conn.put_resp_header(
99
        "link",
100
        ~s(<https://api.github.test/user/repos?page=3&per_page=2>; rel="next")
101
      )
102
      |> Req.Test.json([
103
        repository_payload(%{
104
          "id" => 10,
105
          "full_name" => "acme/widgets",
106
          "permissions" => %{"admin" => false, "push" => false, "pull" => true}
107
        })
108
      ])
109
    end)
110
111
    assert {:ok, page} =
112
             GitHub.list_repository_page("gho_discovery", page: 2, per_page: 2)
113
114
    assert page["page"] == 2
115
    assert page["per_page"] == 2
116
    assert page["has_next_page"] == true
117
    assert page["next_page"] == 3
118
119
    assert [repository] = page["items"]
120
    assert repository["id"] == 10
121
    assert repository["owner"]["id"] == 9
122
    assert repository["owner"]["login"] == "acme"
123
124
    assert repository["permissions"] == %{
125
             "admin" => false,
126
             "maintain" => false,
127
             "pull" => true,
128
             "push" => false,
129
             "triage" => false
130
           }
131
  end
132
133
  test "active organization memberships expose stable organization IDs and roles" do
134
    Req.Test.expect(__MODULE__, fn conn ->
135
      assert conn.request_path == "/user/memberships/orgs"
136
137
      query = URI.decode_query(conn.query_string)
138
      assert query == %{"page" => "1", "per_page" => "100", "state" => "active"}
139
140
      Req.Test.json(conn, [
141
        %{
142
          "state" => "active",
143
          "role" => "admin",
144
          "organization" => %{
145
            "id" => 42,
146
            "node_id" => "MDEyOk9yZ2FuaXphdGlvbjQy",
147
            "login" => "open-agents",
148
            "avatar_url" => "https://avatars.githubusercontent.com/u/42?v=4"
149
          },
150
          "url" => "https://api.github.com/user/memberships/orgs/open-agents"
151
        }
152
      ])
153
    end)
154
155
    assert {:ok, page} = GitHub.list_active_organization_memberships("gho_orgs")
156
157
    assert page["has_next_page"] == false
158
159
    assert page["items"] == [
160
             %{
161
               "state" => "active",
162
               "role" => "admin",
163
               "organization" => %{
164
                 "id" => 42,
165
                 "node_id" => "MDEyOk9yZ2FuaXphdGlvbjQy",
166
                 "login" => "open-agents",
167
                 "avatar_url" => "https://avatars.githubusercontent.com/u/42?v=4",
168
                 "type" => "Organization"
169
               }
170
             }
171
           ]
172
  end
173
174
  test "an import source requires read access but does not require administrator access" do
175
    Req.Test.expect(__MODULE__, fn conn ->
176
      assert conn.request_path == "/repos/acme/widgets"
177
178
      Req.Test.json(
179
        conn,
180
        repository_payload(%{
181
          "id" => 100,
182
          "permissions" => %{"admin" => false, "push" => false, "pull" => true}
183
        })
184
      )
185
    end)
186
187
    assert {:ok, source} = GitHub.get_import_source("gho_reader", "acme/widgets")
188
    assert source["readable"] == true
189
    assert source["permissions"]["admin"] == false
190
    assert source["permissions"]["pull"] == true
191
192
    Req.Test.expect(__MODULE__, fn conn ->
193
      Req.Test.json(
194
        conn,
195
        repository_payload(%{
196
          "private" => true,
197
          "permissions" => %{"admin" => true, "push" => true, "pull" => false}
198
        })
199
      )
200
    end)
201
202
    assert {:error, :github_permission_denied} =
203
             GitHub.get_import_source("gho_no_read", "acme/widgets")
204
  end
205
206
  test "branch and tag pages contain only names and commit object IDs" do
207
    Req.Test.expect(__MODULE__, fn conn ->
208
      assert conn.request_path == "/repos/acme/widgets/branches"
209
210
      Req.Test.json(conn, [
211
        %{
212
          "name" => "main",
213
          "protected" => true,
214
          "commit" => %{"sha" => String.duplicate("a", 40)}
215
        }
216
      ])
217
    end)
218
219
    Req.Test.expect(__MODULE__, fn conn ->
220
      assert conn.request_path == "/repos/acme/widgets/tags"
221
222
      Req.Test.json(conn, [
223
        %{"name" => "v1.0.0", "commit" => %{"sha" => String.duplicate("b", 40)}}
224
      ])
225
    end)
226
227
    assert {:ok, branches} = GitHub.list_branch_page("gho_refs", "acme/widgets")
228
229
    assert branches["items"] == [
230
             %{"name" => "main", "protected" => true, "sha" => String.duplicate("a", 40)}
231
           ]
232
233
    assert {:ok, tags} = GitHub.list_tag_page("gho_refs", "acme/widgets")
234
    assert tags["items"] == [%{"name" => "v1.0.0", "sha" => String.duplicate("b", 40)}]
235
  end
236
237
  test "reference discovery follows bounded pages and returns a stable digest" do
238
    main_sha = String.duplicate("a", 40)
239
    release_sha = String.duplicate("b", 40)
240
    tag_sha = String.duplicate("c", 40)
241
242
    Req.Test.expect(__MODULE__, fn conn ->
243
      assert conn.request_path == "/repos/acme/widgets/git/matching-refs/heads/"
244
      assert URI.decode_query(conn.query_string)["page"] == "1"
245
246
      conn
247
      |> Plug.Conn.put_resp_header(
248
        "link",
249
        ~s(<https://api.github.test/repos/acme/widgets/git/matching-refs/heads/?page=2>; rel="next")
250
      )
251
      |> Req.Test.json([
252
        %{"ref" => "refs/heads/main", "object" => %{"type" => "commit", "sha" => main_sha}}
253
      ])
254
    end)
255
256
    Req.Test.expect(__MODULE__, fn conn ->
257
      assert URI.decode_query(conn.query_string)["page"] == "2"
258
259
      Req.Test.json(conn, [
260
        %{"ref" => "refs/heads/release", "object" => %{"type" => "commit", "sha" => release_sha}}
261
      ])
262
    end)
263
264
    Req.Test.expect(__MODULE__, fn conn ->
265
      assert conn.request_path == "/repos/acme/widgets/git/matching-refs/tags/"
266
267
      Req.Test.json(conn, [
268
        %{"ref" => "refs/tags/v1", "object" => %{"type" => "tag", "sha" => tag_sha}}
269
      ])
270
    end)
271
272
    assert {:ok, snapshot} =
273
             GitHub.list_references("gho_refs", "acme/widgets", per_page: 2, max_pages: 2)
274
275
    assert Enum.map(snapshot["refs"], & &1["name"]) == [
276
             "refs/heads/main",
277
             "refs/heads/release",
278
             "refs/tags/v1"
279
           ]
280
281
    assert snapshot["count"] == 3
282
    assert snapshot["digest"] =~ ~r/\A[0-9a-f]{64}\z/
283
  end
284
285
  test "reference discovery fails closed instead of silently truncating" do
286
    Req.Test.expect(__MODULE__, fn conn ->
287
      conn
288
      |> Plug.Conn.put_resp_header(
289
        "link",
290
        ~s(<https://api.github.test/repos/acme/widgets/git/matching-refs/heads/?page=2>; rel="next")
291
      )
292
      |> Req.Test.json([
293
        %{
294
          "ref" => "refs/heads/main",
295
          "object" => %{"type" => "commit", "sha" => String.duplicate("a", 40)}
296
        }
297
      ])
298
    end)
299
300
    assert {:error, :github_pagination_limit_exceeded} =
301
             GitHub.list_references("gho_refs", "acme/widgets", max_pages: 1)
302
  end
303
304
  test "LFS warning inputs are conservative, bounded, and omit raw tree data" do
305
    Req.Test.expect(__MODULE__, fn conn ->
306
      assert conn.request_path == "/repos/acme/widgets/git/trees/main"
307
      assert URI.decode_query(conn.query_string) == %{"recursive" => "1"}
308
309
      Req.Test.json(conn, %{
310
        "truncated" => false,
311
        "tree" => [
312
          %{"path" => ".gitattributes", "type" => "blob", "size" => 200},
313
          %{"path" => "assets/.gitattributes", "type" => "blob", "size" => 300},
314
          %{"path" => ".lfsconfig", "type" => "blob", "size" => 100},
315
          %{"path" => "movie.bin", "type" => "blob", "size" => 120_000_000},
316
          %{"path" => "src", "type" => "tree", "size" => nil}
317
        ]
318
      })
319
    end)
320
321
    assert {:ok, signals} = GitHub.lfs_warning_inputs("gho_lfs", "acme/widgets", "main")
322
323
    assert signals == %{
324
             "attributes_files" => [".gitattributes", "assets/.gitattributes"],
325
             "large_blob_count" => 1,
326
             "lfs_config_present" => true,
327
             "tree_truncated" => false,
328
             "warning_recommended" => true
329
           }
330
  end
331
332
  test "pagination and retained tokens are validated before a provider request" do
333
    assert {:error, :invalid_pagination} =
334
             GitHub.list_repository_page("gho_token", page: 0, per_page: 10)
335
336
    assert {:error, :invalid_pagination} =
337
             GitHub.list_repository_page("gho_token", page: 1, per_page: 101)
338
339
    assert {:error, :invalid_token} = GitHub.current_user("")
340
    assert {:error, :invalid_token} = GitHub.current_user(String.duplicate("x", 513))
341
  end
342
58 343
  test "file reads decode contents, honor the ref, and mark truncation" do
59 344
    contents = String.duplicate("x", 70_000)
60 345

@@ -141,4 +426,39 @@ defmodule OpenAgents.GitHubTest do

141 426
    assert {:error, :github_file_not_text} =
142 427
             GitHub.read_path("gho_t", "octo/widgets", "logo.png")
143 428
  end
429
430
  defp repository_payload(overrides) do
431
    Map.merge(
432
      %{
433
        "id" => 100,
434
        "node_id" => "R_kgDOExample",
435
        "name" => "widgets",
436
        "full_name" => "acme/widgets",
437
        "description" => "Repository description",
438
        "private" => false,
439
        "fork" => false,
440
        "archived" => false,
441
        "default_branch" => "main",
442
        "language" => "Elixir",
443
        "pushed_at" => "2026-08-16T12:00:00Z",
444
        "size" => 42,
445
        "owner" => %{
446
          "id" => 9,
447
          "node_id" => "MDEyOk9yZ2FuaXphdGlvbjk=",
448
          "login" => "acme",
449
          "avatar_url" => "https://avatars.githubusercontent.com/u/9?v=4",
450
          "type" => "Organization"
451
        },
452
        "permissions" => %{
453
          "admin" => false,
454
          "maintain" => false,
455
          "pull" => true,
456
          "push" => false,
457
          "triage" => false
458
        },
459
        "clone_url" => "https://x-access-token:must-not-leak@github.com/acme/widgets.git"
460
      },
461
      overrides
462
    )
463
  end
144 464
end

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