Skip to content

Commit cede6d2

Browse files
author
settings-as-code-release
committed
feat(snapshot): collaborators and teams read back
Both membership sections declare snapshot(). Collaborators reads the direct collaborators and the pending username invitations back as entries under the delete default; teams lists the teams with access through a new GET /repos/{owner}/{repo}/teams endpoint and reads each team's role through the same probe plan() compares against, so a custom repository role reads back by name. Both read roles through the shared permissionForRole lens. Where it answers undefined (a custom role named "push" or "pull", which the settings vocabulary reads as write or read), collaborators fails the snapshot loudly, since dropping the entry would plan a removal, and teams omits the team with a note, since the section never removes an undeclared team. The enrolment rows prove plan() converges on every emitted value. Not declared, each with a note: the repository owner (plan() never manages them), email invitations (plan() leaves them alone), expired invitations (a declared one would be cancelled and re-sent), team access granted at the organization or enterprise level (declaring it would grant direct access), and a team whose role the probe cannot read. The e2e mock serves the team listing from the seeded team access; the trimmed OpenAPI spec picks the path up through USED_PATHS. No e2e mode issues snapshot reads yet, so the listing has no scenario and the endpoint-coverage tripwire reports it cold until one exists.
1 parent 92e27bd commit cede6d2

15 files changed

Lines changed: 508 additions & 49 deletions

File tree

‎COVERAGE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ The tenet: this action can control everything about a repository and nothing abo
3535
| [GitHub Pages](https://docs.github.com/en/rest/pages/pages) (build_type, source, cname, https_enforced and any PUT field GitHub adds; pages: null disables the site) | `pages` | POST /repos/{owner}/{repo}/pages (create accepts only build_type/source) then PUT for the rest; existing sites get straight PUT passthrough; pages: null issues DELETE, mirroring branches' protection: null. In a mode: merge fold, pages: null over a lower layer's pages declaration removes the key from the merged document instead (the layering guide owns that rule); a multi-repo defaults-file never merges into a target's file. The Enterprise Cloud site-visibility boolean `public` (public vs repo-members-only) rides the PUT passthrough too - the dotcom docs omit it but the GHEC flavor documents it, and the GET echoes it in both, so declaring it applies and check-verifies today. Snapshot reads the live site back on the declared keys; a repository without Pages is omitted rather than declared null. |
3636
| [Code scanning default setup](https://docs.github.com/en/rest/code-scanning/code-scanning) | `code_scanning_default_setup` | GET/PATCH /repos/{owner}/{repo}/code-scanning/default-setup, PATCH body verbatim (state, query_suite, languages, runner_type, runner_label, threat_model). A 202 answer means GitHub rolls the change out in a configuration run, which the log names. Check compares declared keys only, languages as a set. Needs GitHub Advanced Security on private repositories; a 403 can mean that (or an archived repository) rather than a missing permission. Snapshot reads the GET body back on the declared keys; a null the slice does not take (a not-configured setup's runner_type) is omitted. |
3737
| [Code quality setup](https://docs.github.com/en/rest/code-quality/code-quality) | `code_quality_setup` | GET/PATCH /repos/{owner}/{repo}/code-quality/setup, PATCH body verbatim (state, languages, runner_type, runner_label, ai_findings_option), the near field-for-field mirror of code_scanning_default_setup. A 202 answer means GitHub rolls the change out in a configuration run, which the log names; a 409 means a configuration run is already in progress (re-run the workflow after it finishes); a 403 can mean code quality is unavailable on the repository or the repository is archived, rather than a missing permission, and a 422 (the change cannot be applied) carries GitHub's message verbatim. Check compares declared keys only, languages as a set. ASSUMPTION: the GET is graded as a plain read under the Administration permission, mirroring code scanning - the fine-grained grading of these endpoints is not verified against production. The sibling /code-quality/findings endpoints are read-only and stay out of scope. Snapshot reads the GET body back on the declared keys; a null the slice does not take (a not-configured setup's runner_type) is omitted. |
38-
| [Collaborators](https://docs.github.com/en/rest/collaborators/collaborators) (direct) | `collaborators` | PUT/DELETE /repos/{owner}/{repo}/collaborators/{username} (affiliation=direct); pending invitations reconciled alongside via GET /repos/{owner}/{repo}/invitations and PATCH/DELETE /repos/{owner}/{repo}/invitations/{invitation_id}: a declared user's matching pending invitation converges, a stale permission is PATCHed in place (a declared custom role cannot be verified against an invitation - the standard-roles enum - so the invitation is kept with a note), an expired invitation is cancelled and re-sent, and undeclared pending invitations follow the section's one undeclared policy (email invitations, which no username can declare, are noted and left untouched); vocabulary mapping push<->write, pull<->read for check mode; custom org role names pass through; undeclared direct collaborators REMOVED by default (kept as notes under the wrapped `_undeclared: keep` form), owner never touched; new users get invitations. |
39-
| [Team repository permissions](https://docs.github.com/en/rest/teams/teams) (org repos) | `teams` | PUT /orgs/{org}/teams/{slug}/repos/{owner}/{repo}; probes GET /orgs/{owner} and no-ops with a note on personal accounts (404 only; 403/5xx still fail). Both modes probe each team's access with the v3.repository media type to read role_name, and the grant is issued only when the team lacks access or its live role diverges. |
38+
| [Collaborators](https://docs.github.com/en/rest/collaborators/collaborators) (direct) | `collaborators` | PUT/DELETE /repos/{owner}/{repo}/collaborators/{username} (affiliation=direct); pending invitations reconciled alongside via GET /repos/{owner}/{repo}/invitations and PATCH/DELETE /repos/{owner}/{repo}/invitations/{invitation_id}: a declared user's matching pending invitation converges, a stale permission is PATCHed in place (a declared custom role cannot be verified against an invitation - the standard-roles enum - so the invitation is kept with a note), an expired invitation is cancelled and re-sent, and undeclared pending invitations follow the section's one undeclared policy (email invitations, which no username can declare, are noted and left untouched); vocabulary mapping push<->write, pull<->read for check mode; custom org role names pass through; undeclared direct collaborators REMOVED by default (kept as notes under the wrapped `_undeclared: keep` form), owner never touched; new users get invitations. Snapshot reads the direct collaborators and the pending username invitations back as entries under the delete default, each role through the inverse mapping (write -> push, read -> pull, custom role names verbatim); the repository owner, email invitations, and expired invitations are noted, not declared, and a live role no declaration plans as (a custom role named "push" or "pull") fails the snapshot loudly. |
39+
| [Team repository permissions](https://docs.github.com/en/rest/teams/teams) (org repos) | `teams` | PUT /orgs/{org}/teams/{slug}/repos/{owner}/{repo}; probes GET /orgs/{owner} and no-ops with a note on personal accounts (404 only; 403/5xx still fail). Both modes probe each team's access with the v3.repository media type to read role_name, and the grant is issued only when the team lacks access or its live role diverges. Snapshot lists the teams with access (GET /repos/{owner}/{repo}/teams) and reads each team's role through the same probe, so a custom role reads back by name; noted, not declared: access granted at the organization or enterprise level, a role the probe does not report, and a role no declaration plans as (a custom role named "push" or "pull"). A personal account snapshots nothing. |
4040
| [Milestones](https://docs.github.com/en/rest/issues/milestones) | `milestones` | POST/PATCH/DELETE /repos/{owner}/{repo}/milestones, matched by title, declared-keys-only (description/state untouched unless declared); undeclared milestones kept by default and surfaced as notes, because deleting a milestone DETACHES it from every issue carrying it - the wrapped `_undeclared: delete` form opts into exactly that. Snapshot reads every milestone, open and closed, back as entries under the keep default. |
4141
| [Interaction limits](https://docs.github.com/en/rest/interactions/repos) | `interaction_limits` | GET/PUT/DELETE /repos/{owner}/{repo}/interaction-limits, plus the two routed keys below. Limits self-expire (expiry tops out at six_months), so apply re-arms the declared limit on EVERY run and check mode reports drift once it lapses - schedule apply more often than the chosen expiry to keep it armed. The declared expiry is write-only (GitHub reads back only the computed expires_at), so check verifies the limit value, not the duration. `interaction_limits: null` clears a live repo-level limit - the base limit only, never the cap or bypass list below. In a mode: merge fold, interaction_limits: null over a lower layer's declaration removes the key from the merged document instead, mirroring pages: null. A 409 means an organization- or user-level limit overrides the repository's; writes surface that as a note, not a failure, while check mode still reports a mismatched declaration as drift - the org is the place to change it. `pull_request_creation_cap` routes to GET/PATCH /repos/{owner}/{repo}/interaction-limits/pulls/creation-cap: persistent desired state with no self-expiry (enabled plus max_open_pull_requests 1-1000, read back verbatim), so check diffs it exactly and apply PATCHes only on divergence - no re-arm. Where the cap is unavailable the endpoints answer 405, which apply surfaces as a note (like the 409) and check as honest drift. `pull_request_creation_bypass` routes to GET/PUT/DELETE /repos/{owner}/{repo}/interaction-limits/pulls/bypass-list and reconciles: apply DELETEs only the undeclared logins and then PUTs only the missing ones ({users: [logins]}, compared case-insensitively; removals go first because the list holds at most 100 users, which also caps the declaration), never a wholesale replace; a declared empty list removes everyone. Snapshot reads back the repository-level limit (an inherited one is noted, not declared, and the expiry duration is not readable), the cap when the feature exists and is enabled, and the bypass list when it has members. |
4242
| [Actions variables](https://docs.github.com/en/rest/actions/variables) | `actions_variables` | GET/POST /repos/{owner}/{repo}/actions/variables and PATCH/DELETE /repos/{owner}/{repo}/actions/variables/{name}: plain-text repository variables, upserted by name. Names are case-insensitive (GitHub stores them uppercased regardless of how they are entered), so matching and duplicate rejection compare uppercased names. Values read back in full, which is what makes exact check-mode diffing possible; undeclared variables are DELETED by default, kept as notes under the wrapped `_undeclared: keep` form. The list endpoint caps per_page at 30 (not the usual 100), which the page loop honors so a large inventory is never truncated. Secrets are write-only material and deliberately NOT this section: they live in the `actions_secrets` section above. Snapshot reads the variables back as name and value entries under the delete default. |

‎docs/reference/sections.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Every top-level key a settings file can declare, one row per section: the endpoi
2626
| `code_scanning_default_setup` | code scanning default setup | Administration or Code scanning alerts: write | untouched | `state`, `query_suite`, `languages`; needs Advanced Security on private repositories |
2727
| `code_quality_setup` | code-quality setup | Administration: write | untouched | `state`, `languages`, runner and AI-findings options; a 202 means GitHub rolls the change out in a configuration run; needs code quality available on the repository |
2828
| `collaborators` | direct collaborators + pending invitations | Administration: write | deleted (settable) | invitations for new users, pending ones reconciled (stale permission updated, expired re-sent, undeclared cancelled); the repository owner is never touched |
29-
| `teams` | org team repo permissions | Members: read (org permission) + Administration: write | untouched | org repos only, skipped with a notice on personal accounts |
29+
| `teams` | org team repo permissions + repo team list | Members: read (org permission) + Administration: write | untouched | org repos only, skipped with a notice on personal accounts |
3030
| `milestones` | milestones | Issues: write | kept (settable) | upsert by title; deleting a milestone detaches it from every issue carrying it, which is why keep is the default |
3131
| `interaction_limits` | interaction-limits + pulls creation-cap/bypass-list | Administration: write | untouched | re-arms the self-expiring limit every apply run; `null` clears it (base limit only); a 409 (org/user-level limit overrides) becomes a note; the PR creation cap is persistent (PATCHed only on divergence, 405 where unavailable) and its bypass logins reconcile add/remove |
3232
| `actions_variables` | Actions variables CRUD | Variables: write | deleted (settable) | plain-text variables upserted by name (case-insensitive); values read back in full, so check mode diffs them exactly |

‎src/sections/collaborators/collaborators.docs.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ coverage:
1818
custom org role names pass through; undeclared direct collaborators REMOVED by default (kept
1919
as notes under the wrapped `_undeclared: keep` form), owner never touched; new users get
2020
invitations.
21+
Snapshot reads the direct collaborators and the pending username invitations back as entries
22+
under the delete default, each role through the inverse mapping (write -> push, read -> pull,
23+
custom role names verbatim); the repository owner, email invitations, and expired invitations
24+
are noted, not declared, and a live role no declaration plans as (a custom role named "push"
25+
or "pull") fails the snapshot loudly.
2126
schema:
2227
SettingsFile.collaborators: >-
2328
Direct collaborators, with pending invitations reconciled alongside; undeclared ones are REMOVED

‎src/sections/collaborators/collaborators.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const LIST = "GET /repos/o/r/collaborators?affiliation=direct&per_page=100&page=
1313
const INVITATIONS = "GET /repos/o/r/invitations?per_page=100&page=1";
1414
const plan = (api: MockApi, desired: Parameters<typeof collaboratorsSection.plan>[1]) =>
1515
collaboratorsSection.plan(planContext(collaboratorsSection, api, REPO), desired);
16+
const snapshot = (api: MockApi) =>
17+
collaboratorsSection.snapshot(planContext(collaboratorsSection, api, REPO));
1618
const NO_SECRETS = {
1719
resolveSecret: (): string => {
1820
throw new Error("no secrets");
@@ -326,4 +328,88 @@ describe("collaborators", () => {
326328
ctx.read.list.probeAbsent;
327329
expect(typeof ctx.read.listInvitations.listAll).toBe("function");
328330
});
331+
332+
describe("snapshot", () => {
333+
test("reads collaborators then pending invitations back under the delete default; the owner, an expired invitation, and an email invitation are noted, not declared", async () => {
334+
const api = new MockApi({
335+
[LIST]: {
336+
data: [
337+
{ login: "O", role_name: "admin" },
338+
{ login: "alice", role_name: "write" },
339+
{ login: "bob", role_name: "read" },
340+
{ login: "carol", role_name: "security-team" },
341+
],
342+
},
343+
[INVITATIONS]: {
344+
data: [
345+
{ id: 7, invitee: { login: "dave" }, permissions: "write", expired: false },
346+
{ id: 8, invitee: { login: "erin" }, permissions: "read", expired: true },
347+
{ id: 9, invitee: null, permissions: "read", expired: false },
348+
],
349+
},
350+
});
351+
expect(await snapshot(api)).toEqual({
352+
value: {
353+
_undeclared: "delete",
354+
entries: [
355+
{ username: "alice", permission: "push" },
356+
{ username: "bob", permission: "pull" },
357+
{ username: "carol", permission: "security-team" },
358+
{ username: "dave", permission: "push" },
359+
],
360+
},
361+
notes: [
362+
"collaborators[O]: the repository owner's access is implicit and never managed, so it is not declared",
363+
"collaborators[erin]: the pending invitation has expired, so it is not declared; apply cancels it - add the entry to re-invite them",
364+
"invitation 9 was sent by email, so no username can declare it; not declared, and apply leaves it untouched",
365+
],
366+
});
367+
expect(api.calls.map((c) => `${c.method} ${c.path}`)).toEqual([LIST, INVITATIONS]);
368+
});
369+
370+
test("only the owner, an expired invitation, and an email invitation snapshot as nothing to declare; the expired note says apply leaves it", async () => {
371+
const api = new MockApi({
372+
[LIST]: { data: [{ login: "o", role_name: "admin" }] },
373+
[INVITATIONS]: {
374+
data: [
375+
{ id: 8, invitee: { login: "erin" }, permissions: "read", expired: true },
376+
{ id: 9, invitee: null, permissions: "read", expired: false },
377+
],
378+
},
379+
});
380+
expect(await snapshot(api)).toEqual({
381+
value: undefined,
382+
notes: [
383+
"collaborators[o]: the repository owner's access is implicit and never managed, so it is not declared",
384+
"collaborators[erin]: the pending invitation has expired, so it is not declared; nothing else is declared, so the section is omitted and apply leaves it - declare the entry to re-invite them",
385+
"invitation 9 was sent by email, so no username can declare it; not declared, and apply leaves it untouched",
386+
],
387+
});
388+
});
389+
390+
test("a collaborator or invitation without a role, or with a role no declaration plans as, fails loudly instead of declaring a guess", async () => {
391+
const roleless = new MockApi({
392+
[LIST]: { data: [{ login: "alice" }] },
393+
[INVITATIONS]: { data: [] },
394+
});
395+
await expect(snapshot(roleless)).rejects.toThrow(
396+
"collaborators[alice]: GitHub reported no role_name for this collaborator, so their permission cannot be read back",
397+
);
398+
const blankInvitation = new MockApi({
399+
[LIST]: { data: [] },
400+
[INVITATIONS]: { data: [{ id: 7, invitee: { login: "dave" }, expired: false }] },
401+
});
402+
await expect(snapshot(blankInvitation)).rejects.toThrow(
403+
"collaborators[dave]: GitHub reported no permissions on the pending invitation, so it cannot be read back",
404+
);
405+
// A custom role named "push": declaring "push" plans the write role, so the entry can never converge.
406+
const collision = new MockApi({
407+
[LIST]: { data: [{ login: "frank", role_name: "push" }] },
408+
[INVITATIONS]: { data: [] },
409+
});
410+
await expect(snapshot(collision)).rejects.toThrow(
411+
'collaborators[frank]: the live role "push" has no declaration that plans as itself ("push" in a settings file means the "write" role), so it cannot be read back',
412+
);
413+
});
414+
});
329415
});

0 commit comments

Comments
 (0)