diff --git a/README.md b/README.md index 780a47b..f17b4d1 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Apply repeatable GitHub repository defaults and branch rulesets. `github-sane-defaults` is a small CLI for keeping repository settings -consistent across one GitHub repository or an entire organization. It previews -the changes it would make, then applies the same policy through GitHub's REST -API when you are ready. +consistent across one GitHub repository, an organization, or repositories owned +by a user account. It previews the changes it would make, then applies the same +policy through GitHub's REST API when you are ready. The default policy is intentionally narrow. It turns on the GitHub merge and cleanup settings that keep pull request history tidy, and it creates a default @@ -14,7 +14,7 @@ history. The CLI manages its own named ruleset, so repeated runs are idempotent and easy to review. Use `plan` first to see drift, then switch to `apply` to make the changes. -Both commands accept `owner/repo` targets and organization-wide `--all` runs. +Both commands accept `owner/repo` targets and owner-wide `--all` runs. ## Install @@ -38,7 +38,7 @@ Use github-sane-defaults to inspect my existing GitHub repositories. Attention agent: start here: https://github.com/dutifuldev/github-sane-defaults#readme -Ask me for the target org or owner/repo list, then run plan only. Show which +Ask me for the target owner or owner/repo list, then run plan only. Show which repos need branch protection rulesets, deletion/force-push protection, linear history, or GitHub merge and cleanup settings enabled. Do not apply changes unless I ask. @@ -64,10 +64,10 @@ Preview changes for one repository: github-sane-defaults plan example-org/example-repo ``` -Preview changes for every non-archived repository in an organization: +Preview changes for every non-archived repository owned by an organization or user: ```sh -github-sane-defaults plan example-org --all +github-sane-defaults plan example-owner --all ``` Apply changes to one repository: @@ -76,12 +76,16 @@ Apply changes to one repository: github-sane-defaults apply example-org/example-repo ``` -Apply changes to every non-archived repository in an organization: +Apply changes to every non-archived repository owned by an organization or user: ```sh -github-sane-defaults apply example-org --all +github-sane-defaults apply example-owner --all ``` +For user account targets, `--all` uses repositories visible through the +authenticated token and filters them to the requested owner. Use a token that has +explicit access to the personal repositories you want to audit or update. + `apply` prints the plan and asks for confirmation before changing repository settings or rulesets. Use `-y` or `--yes` to skip the prompt in automation: @@ -89,7 +93,8 @@ settings or rulesets. Use `-y` or `--yes` to skip the prompt in automation: github-sane-defaults apply example-org/example-repo --yes ``` -The legacy `--org example-org --repo example-repo` form is still accepted. +The `--owner example-owner --repo example-repo` form is also accepted. The legacy +`--org example-org --repo example-repo` form remains supported. ## Defaults diff --git a/src/app/apply.ts b/src/app/apply.ts index de52177..894e726 100644 --- a/src/app/apply.ts +++ b/src/app/apply.ts @@ -10,7 +10,7 @@ export async function applyDefaults( ): Promise { const planned = await buildPlan(client, selection); - await applyPlannedDefaults(client, selection.org, planned); + await applyPlannedDefaults(client, selection.owner, planned); return { planned, diff --git a/src/app/planner.ts b/src/app/planner.ts index bd87d3e..05025fc 100644 --- a/src/app/planner.ts +++ b/src/app/planner.ts @@ -14,7 +14,7 @@ export async function buildPlan( const plans: RepoPlan[] = []; for (const repo of activeRepos) { - plans.push(await buildRepoPlan(client, selection.org, repo)); + plans.push(await buildRepoPlan(client, selection.owner, repo)); } return plans; @@ -25,10 +25,10 @@ async function selectRepos( selection: TargetSelection ): Promise { if (selection.all) { - return client.listOrgRepos(selection.org); + return client.listOwnerRepos(selection.owner); } - return Promise.all(selection.repos.map((repo) => client.getRepo(selection.org, repo))); + return Promise.all(selection.repos.map((repo) => client.getRepo(selection.owner, repo))); } async function buildRepoPlan( diff --git a/src/app/types.ts b/src/app/types.ts index 96c8907..96957ca 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -1,7 +1,7 @@ import type { RepoSettingChange } from "../policy/types.js"; export type TargetSelection = { - org: string; + owner: string; repos: string[]; all: boolean; }; diff --git a/src/cli/args.ts b/src/cli/args.ts index 722c4bc..f99eb4a 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -21,7 +21,7 @@ export function parseArgs(args: string[]): CliOptions { const options: CliOptions = { command, - org: parsed.org, + owner: parsed.owner, repos: parsed.repos, all: parsed.all, yes: parsed.yes @@ -34,11 +34,11 @@ export function parseArgs(args: string[]): CliOptions { return options; } -function validateFlags(parsed: ParsedFlags): asserts parsed is ParsedFlags & { org: string } { +function validateFlags(parsed: ParsedFlags): asserts parsed is ParsedFlags & { owner: string } { applyPositionalTargets(parsed); - if (parsed.org === undefined) { - throw new Error("Missing required --org option.\n\n" + usage()); + if (parsed.owner === undefined) { + throw new Error("Missing required owner target.\n\n" + usage()); } if (!parsed.all && parsed.repos.length === 0) { @@ -51,7 +51,7 @@ function validateFlags(parsed: ParsedFlags): asserts parsed is ParsedFlags & { o } type ParsedFlags = { - org?: string; + owner?: string; token?: string; repos: string[]; targets: string[]; @@ -101,7 +101,7 @@ function parseFlag(args: string[], index: number, flags: ParsedFlags): number { } function parseValueFlag(args: string[], index: number, flags: ParsedFlags, arg: string): number { - if (arg !== "--org" && arg !== "--repo" && arg !== "--token") { + if (arg !== "--org" && arg !== "--owner" && arg !== "--repo" && arg !== "--token") { throw new Error(`Unknown option: ${arg}`); } @@ -116,8 +116,8 @@ function parseValueFlag(args: string[], index: number, flags: ParsedFlags, arg: } function setFlag(flags: ParsedFlags, arg: string, value: string): void { - if (arg === "--org") { - flags.org = value; + if (arg === "--org" || arg === "--owner") { + flags.owner = value; return; } @@ -135,7 +135,7 @@ function applyPositionalTargets(flags: ParsedFlags): void { } if (flags.all) { - applyOrgTarget(flags); + applyOwnerTarget(flags); return; } @@ -144,52 +144,52 @@ function applyPositionalTargets(flags: ParsedFlags): void { } } -function applyOrgTarget(flags: ParsedFlags): void { +function applyOwnerTarget(flags: ParsedFlags): void { if (flags.targets.length !== 1 || flags.targets[0]?.includes("/") === true) { - throw new Error("Use an organization name with --all, for example: example-org --all."); + throw new Error("Use an owner name with --all, for example: example-owner --all."); } - setTargetOrg(flags, flags.targets[0]); + setTargetOwner(flags, flags.targets[0]); } function applyRepoTarget(flags: ParsedFlags, target: string): void { - const [org, repo, extra] = target.split("/"); + const [owner, repo, extra] = target.split("/"); if ( - org === undefined || + owner === undefined || repo === undefined || - org.length === 0 || + owner.length === 0 || repo.length === 0 || extra !== undefined ) { - throw new Error(`Repository targets must look like /: ${target}`); + throw new Error(`Repository targets must look like /: ${target}`); } - setTargetOrg(flags, org); + setTargetOwner(flags, owner); flags.repos.push(repo); } -function setTargetOrg(flags: ParsedFlags, org: string | undefined): void { - if (org === undefined) { - throw new Error("Missing organization target."); +function setTargetOwner(flags: ParsedFlags, owner: string | undefined): void { + if (owner === undefined) { + throw new Error("Missing owner target."); } - if (flags.org !== undefined && flags.org !== org) { - throw new Error(`Conflicting organization targets: ${flags.org} and ${org}.`); + if (flags.owner !== undefined && flags.owner !== owner) { + throw new Error(`Conflicting owner targets: ${flags.owner} and ${owner}.`); } - flags.org = org; + flags.owner = owner; } export function usage(): string { return [ "Usage:", - " github-sane-defaults plan /", - " github-sane-defaults apply /", - " github-sane-defaults plan --all", - " github-sane-defaults apply --all", - " github-sane-defaults plan --org --repo ", - " github-sane-defaults apply --org --repo ", + " github-sane-defaults plan /", + " github-sane-defaults apply /", + " github-sane-defaults plan --all", + " github-sane-defaults apply --all", + " github-sane-defaults plan --owner --repo ", + " github-sane-defaults apply --owner --repo ", "", "Options:", " -y, --yes Skip apply confirmation" diff --git a/src/cli/main.ts b/src/cli/main.ts index e3c1981..96fad42 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -38,7 +38,7 @@ async function main(): Promise { return; } - await applyPlannedDefaults(client, options.org, planned); + await applyPlannedDefaults(client, options.owner, planned); const summary: ApplySummary = { planned, applied: countChangedRepos(planned) }; console.log(""); diff --git a/src/github/client.ts b/src/github/client.ts index c66b452..9576f9a 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -2,9 +2,21 @@ import { execFileSync } from "node:child_process"; import { DESIRED_REPO_SETTINGS } from "../policy/defaults.js"; import type { RulesetPayload } from "../policy/types.js"; -import { parseRepo, parseRepoNames, parseRuleset, parseRulesetSummaries } from "./parse.js"; +import { + parseOwnerType, + parseRepo, + parseRepoListItems, + parseRepoNames, + parseRuleset, + parseRulesetSummaries +} from "./parse.js"; import type { GitHubErrorContext, GitHubRepo, GitHubRuleset, RulesetSummary } from "./types.js"; +type GitHubResponse = { + body: unknown; + headers: Headers; +}; + export class GitHubApiError extends Error { public readonly context: GitHubErrorContext; @@ -18,7 +30,7 @@ export class GitHubApiError extends Error { export type GitHubClient = { getRepo(owner: string, repo: string): Promise; - listOrgRepos(org: string): Promise; + listOwnerRepos(owner: string): Promise; updateRepoDefaults(owner: string, repo: string): Promise; listRepoRulesets(owner: string, repo: string): Promise; getRepoRuleset(owner: string, repo: string, id: number): Promise; @@ -42,19 +54,36 @@ export class RestGitHubClient implements GitHubClient { return parseRepo(await this.request("GET", `/repos/${owner}/${repo}`)); } - public async listOrgRepos(org: string): Promise { - const repos: GitHubRepo[] = []; - - for (let page = 1; ; page += 1) { - const path = `/orgs/${org}/repos?type=all&per_page=100&page=${String(page)}`; - const repoNames = parseRepoNames(await this.request("GET", path)); - const pageRepos = await Promise.all(repoNames.map((repo) => this.getRepo(org, repo))); - repos.push(...pageRepos); + public async listOwnerRepos(owner: string): Promise { + const ownerType = parseOwnerType(await this.request("GET", `/users/${owner}`)); - if (repoNames.length < 100) { - return repos; - } + if (ownerType === "Organization") { + return this.listOrganizationRepos(owner); } + + return this.listUserOwnedRepos(owner); + } + + private async listOrganizationRepos(org: string): Promise { + const repoNames = await this.paginate( + `/orgs/${org}/repos?type=all&per_page=100`, + parseRepoNames + ); + + return Promise.all(repoNames.map((repo) => this.getRepo(org, repo))); + } + + private async listUserOwnedRepos(owner: string): Promise { + const normalizedOwner = owner.toLowerCase(); + const repoItems = await this.paginate( + "/user/repos?visibility=all&affiliation=owner,collaborator&per_page=100", + parseRepoListItems + ); + const ownedRepoNames = repoItems + .filter((repo) => repo.owner_login.toLowerCase() === normalizedOwner) + .map((repo) => repo.name); + + return Promise.all(ownedRepoNames.map((repo) => this.getRepo(owner, repo))); } public async updateRepoDefaults(owner: string, repo: string): Promise { @@ -62,17 +91,10 @@ export class RestGitHubClient implements GitHubClient { } public async listRepoRulesets(owner: string, repo: string): Promise { - const rulesets: RulesetSummary[] = []; - - for (let page = 1; ; page += 1) { - const path = `/repos/${owner}/${repo}/rulesets?includes_parents=false&per_page=100&page=${String(page)}`; - const pageRulesets = parseRulesetSummaries(await this.request("GET", path)); - rulesets.push(...pageRulesets); - - if (pageRulesets.length < 100) { - return rulesets; - } - } + return this.paginate( + `/repos/${owner}/${repo}/rulesets?includes_parents=false&per_page=100`, + parseRulesetSummaries + ); } public async getRepoRuleset(owner: string, repo: string, id: number): Promise { @@ -98,7 +120,28 @@ export class RestGitHubClient implements GitHubClient { await this.request("PUT", `/repos/${owner}/${repo}/rulesets/${String(id)}`, payload); } + private async paginate(firstPath: string, parseItems: (value: unknown) => T[]): Promise { + const items: T[] = []; + let path: string | undefined = firstPath; + + while (path !== undefined) { + const response = await this.requestWithHeaders("GET", path); + items.push(...parseItems(response.body)); + path = nextPagePath(response.headers.get("link")); + } + + return items; + } + private async request(method: string, path: string, body?: unknown): Promise { + return (await this.requestWithHeaders(method, path, body)).body; + } + + private async requestWithHeaders( + method: string, + path: string, + body?: unknown + ): Promise { const init: RequestInit = { method, headers: { @@ -125,11 +168,39 @@ export class RestGitHubClient implements GitHubClient { } if (response.status === 204) { - return null; + return { body: null, headers: response.headers }; + } + + return { body: await response.json(), headers: response.headers }; + } +} + +function nextPagePath(linkHeader: string | null): string | undefined { + if (linkHeader === null) { + return undefined; + } + + for (const link of linkHeader.split(/,\s*(?=<)/)) { + const match = /^<([^>]+)>(.*)$/.exec(link.trim()); + + if (match?.[1] === undefined || match[2] === undefined) { + continue; } - return response.json(); + const params = match[2] + .split(";") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + if (!params.includes('rel="next"')) { + continue; + } + + const url = new URL(match[1]); + return `${url.pathname}${url.search}`; } + + return undefined; } export function resolveToken(explicitToken?: string): string { diff --git a/src/github/parse.ts b/src/github/parse.ts index ac1080c..f0b167c 100644 --- a/src/github/parse.ts +++ b/src/github/parse.ts @@ -1,4 +1,10 @@ -import type { GitHubRepo, GitHubRuleset, RulesetSummary } from "./types.js"; +import type { + GitHubOwnerType, + GitHubRepo, + GitHubRepoListItem, + GitHubRuleset, + RulesetSummary +} from "./types.js"; import type { BypassActor, ExistingRulesetRule, RepoSettings } from "../policy/types.js"; type JsonRecord = Record; @@ -63,6 +69,14 @@ function asStringArray(value: unknown, label: string): string[] { return value.map((item) => String(item)); } +function asArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array`); + } + + return value; +} + function asRepoSettings(record: JsonRecord, label: string): RepoSettings { const squashTitle = asString(record, "squash_merge_commit_title", label); const squashMessage = asString(record, "squash_merge_commit_message", label); @@ -104,12 +118,34 @@ export function parseRepo(value: unknown): GitHubRepo { }; } -export function parseRepoNames(value: unknown): string[] { - if (!Array.isArray(value)) { - throw new Error("repos response must be an array"); +export function parseOwnerType(value: unknown): GitHubOwnerType { + const record = asRecord(value, "owner"); + const type = asString(record, "type", "owner"); + + if (type !== "Organization" && type !== "User") { + throw new Error(`unsupported owner type: ${type}`); } - return value.map((item) => asString(asRecord(item, "repo"), "name", "repo")); + return type; +} + +export function parseRepoNames(value: unknown): string[] { + return asArray(value, "repos response").map((item) => + asString(asRecord(item, "repo"), "name", "repo") + ); +} + +export function parseRepoListItems(value: unknown): GitHubRepoListItem[] { + return asArray(value, "repos response").map((item) => { + const record = asRecord(item, "repo"); + const owner = asRecord(record["owner"], "repo.owner"); + + return { + name: asString(record, "name", "repo"), + full_name: asString(record, "full_name", "repo"), + owner_login: asString(owner, "login", "repo.owner") + }; + }); } export function parseRulesetSummaries(value: unknown): RulesetSummary[] { diff --git a/src/github/types.ts b/src/github/types.ts index 3026f2c..a7a8684 100644 --- a/src/github/types.ts +++ b/src/github/types.ts @@ -13,6 +13,14 @@ export type GitHubRepo = RepoSettings & { default_branch: string; }; +export type GitHubOwnerType = "Organization" | "User"; + +export type GitHubRepoListItem = { + name: string; + full_name: string; + owner_login: string; +}; + export type RulesetSummary = { id: number; name: string; diff --git a/tests/apply.test.ts b/tests/apply.test.ts index c68b962..b6153dd 100644 --- a/tests/apply.test.ts +++ b/tests/apply.test.ts @@ -20,7 +20,7 @@ describe("applyDefaults", () => { }); await expect( - applyDefaults(client, { org: "dutifuldev", repos: ["scratch"], all: false }) + applyDefaults(client, { owner: "dutifuldev", repos: ["scratch"], all: false }) ).resolves.toMatchObject({ applied: 1 }); expect(calls).toEqual([ @@ -50,7 +50,7 @@ describe("applyDefaults", () => { ruleset: currentRuleset }); - await applyDefaults(client, { org: "dutifuldev", repos: ["scratch"], all: false }); + await applyDefaults(client, { owner: "dutifuldev", repos: ["scratch"], all: false }); expect(calls).toEqual([ "listRepoRulesets:scratch", @@ -85,7 +85,7 @@ describe("applyDefaults", () => { rulesetsAfterPlan: [] }); - await applyDefaults(client, { org: "dutifuldev", repos: ["scratch"], all: false }); + await applyDefaults(client, { owner: "dutifuldev", repos: ["scratch"], all: false }); expect(calls).toEqual([ "listRepoRulesets:scratch", @@ -110,7 +110,7 @@ function fakeClient(options: FakeClientOptions): GitHubClient { return { getRepo: () => Promise.resolve(options.repo), - listOrgRepos: () => Promise.resolve([options.repo]), + listOwnerRepos: () => Promise.resolve([options.repo]), updateRepoDefaults: (_owner: string, repo: string) => { options.calls.push(`updateRepoDefaults:${repo}`); return Promise.resolve(); diff --git a/tests/args.test.ts b/tests/args.test.ts index 6da8965..e13e08a 100644 --- a/tests/args.test.ts +++ b/tests/args.test.ts @@ -6,7 +6,17 @@ describe("parseArgs", () => { it("parses a positional repository plan command", () => { expect(parseArgs(["plan", "dutifuldev/scratch"])).toEqual({ command: "plan", - org: "dutifuldev", + owner: "dutifuldev", + repos: ["scratch"], + all: false, + yes: false + }); + }); + + it("parses an owner targeted plan command", () => { + expect(parseArgs(["plan", "--owner", "dutifuldev", "--repo", "scratch"])).toEqual({ + command: "plan", + owner: "dutifuldev", repos: ["scratch"], all: false, yes: false @@ -16,17 +26,17 @@ describe("parseArgs", () => { it("parses a legacy targeted plan command", () => { expect(parseArgs(["plan", "--org", "dutifuldev", "--repo", "scratch"])).toEqual({ command: "plan", - org: "dutifuldev", + owner: "dutifuldev", repos: ["scratch"], all: false, yes: false }); }); - it("parses an org-wide apply command", () => { + it("parses an owner-wide apply command", () => { expect(parseArgs(["apply", "dutifuldev", "--all"])).toEqual({ command: "apply", - org: "dutifuldev", + owner: "dutifuldev", repos: [], all: true, yes: false @@ -36,7 +46,7 @@ describe("parseArgs", () => { it("parses apply confirmation bypass flags", () => { expect(parseArgs(["apply", "dutifuldev/scratch", "--yes"])).toMatchObject({ command: "apply", - org: "dutifuldev", + owner: "dutifuldev", repos: ["scratch"], all: false, yes: true @@ -58,13 +68,13 @@ describe("parseArgs", () => { it("rejects malformed positional repository targets", () => { expect(() => parseArgs(["plan", "scratch"])).toThrow( - "Repository targets must look like /" + "Repository targets must look like /" ); }); - it("rejects conflicting organization targets", () => { - expect(() => parseArgs(["plan", "--org", "other", "dutifuldev/scratch"])).toThrow( - "Conflicting organization targets" + it("rejects conflicting owner targets", () => { + expect(() => parseArgs(["plan", "--owner", "other", "dutifuldev/scratch"])).toThrow( + "Conflicting owner targets" ); }); @@ -81,7 +91,7 @@ describe("parseArgs", () => { it("parses an explicit token", () => { expect(parseArgs(["plan", "dutifuldev/scratch", "--token", "t"])).toEqual({ command: "plan", - org: "dutifuldev", + owner: "dutifuldev", repos: ["scratch"], all: false, yes: false, diff --git a/tests/client.test.ts b/tests/client.test.ts new file mode 100644 index 0000000..e1abe3d --- /dev/null +++ b/tests/client.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { RestGitHubClient } from "../src/github/client.js"; +import { DESIRED_REPO_SETTINGS } from "../src/policy/defaults.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("RestGitHubClient.listOwnerRepos", () => { + it("lists organization repositories through the organization endpoint", async () => { + const requests = stubFetch( + new Map([ + ["https://api.github.com/users/dutifuldev", { type: "Organization" }], + [ + "https://api.github.com/orgs/dutifuldev/repos?type=all&per_page=100", + [repoListItem("dutifuldev", "scratch")] + ], + [ + "https://api.github.com/orgs/dutifuldev/repos?type=all&per_page=100&page=2", + [repoListItem("dutifuldev", "tools")] + ], + ["https://api.github.com/repos/dutifuldev/tools", repo("dutifuldev", "tools")], + ["https://api.github.com/repos/dutifuldev/scratch", repo("dutifuldev", "scratch")] + ]), + new Map>([ + [ + "https://api.github.com/orgs/dutifuldev/repos?type=all&per_page=100", + { + link: '; rel="next"' + } + ] + ]) + ); + + await expect(new RestGitHubClient("token").listOwnerRepos("dutifuldev")).resolves.toEqual([ + repo("dutifuldev", "scratch"), + repo("dutifuldev", "tools") + ]); + expect(requests).toEqual([ + "https://api.github.com/users/dutifuldev", + "https://api.github.com/orgs/dutifuldev/repos?type=all&per_page=100", + "https://api.github.com/orgs/dutifuldev/repos?type=all&per_page=100&page=2", + "https://api.github.com/repos/dutifuldev/scratch", + "https://api.github.com/repos/dutifuldev/tools" + ]); + }); + + it("lists user-owned repositories through the authenticated user endpoint", async () => { + const requests = stubFetch( + new Map([ + ["https://api.github.com/users/osolmaz", { type: "User" }], + [ + "https://api.github.com/user/repos?visibility=all&affiliation=owner,collaborator&per_page=100", + [repoListItem("osolmaz", "brokerkit"), repoListItem("dutifuldev", "tools")] + ], + [ + "https://api.github.com/user/repos?visibility=all&affiliation=owner,collaborator&per_page=100&page=2", + [repoListItem("osolmaz", "sudo-broker")] + ], + ["https://api.github.com/repos/osolmaz/sudo-broker", repo("osolmaz", "sudo-broker")], + ["https://api.github.com/repos/osolmaz/brokerkit", repo("osolmaz", "brokerkit")] + ]), + new Map>([ + [ + "https://api.github.com/user/repos?visibility=all&affiliation=owner,collaborator&per_page=100", + { + link: '; rel="next"' + } + ] + ]) + ); + + await expect(new RestGitHubClient("token").listOwnerRepos("osolmaz")).resolves.toEqual([ + repo("osolmaz", "brokerkit"), + repo("osolmaz", "sudo-broker") + ]); + expect(requests).toEqual([ + "https://api.github.com/users/osolmaz", + "https://api.github.com/user/repos?visibility=all&affiliation=owner,collaborator&per_page=100", + "https://api.github.com/user/repos?visibility=all&affiliation=owner,collaborator&per_page=100&page=2", + "https://api.github.com/repos/osolmaz/brokerkit", + "https://api.github.com/repos/osolmaz/sudo-broker" + ]); + }); +}); + +function stubFetch( + responses: Map, + headers = new Map>() +): string[] { + const requests: string[] = []; + const fetchMock: typeof fetch = (input) => { + const url = requestUrl(input); + requests.push(url); + + if (!responses.has(url)) { + return Promise.resolve(new Response("not found", { status: 404 })); + } + + return Promise.resolve(jsonResponse(responses.get(url), headers.get(url))); + }; + + vi.stubGlobal("fetch", fetchMock); + return requests; +} + +function requestUrl(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + + if (input instanceof URL) { + return input.toString(); + } + + return input.url; +} + +function jsonResponse(body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json", ...headers }, + status: 200 + }); +} + +function repoListItem(owner: string, name: string): unknown { + return { name, full_name: `${owner}/${name}`, owner: { login: owner } }; +} + +function repo(owner: string, name: string): unknown { + return { + ...DESIRED_REPO_SETTINGS, + name, + full_name: `${owner}/${name}`, + archived: false, + disabled: false, + default_branch: "main" + }; +} diff --git a/tests/parse.test.ts b/tests/parse.test.ts index ae329ed..8e0c131 100644 --- a/tests/parse.test.ts +++ b/tests/parse.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + parseOwnerType, parseRepo, + parseRepoListItems, parseRepoNames, parseRuleset, parseRulesetSummaries @@ -59,6 +61,17 @@ describe("parseRepo", () => { }); }); +describe("parseOwnerType", () => { + it("parses supported owner types", () => { + expect(parseOwnerType({ type: "User" })).toBe("User"); + expect(parseOwnerType({ type: "Organization" })).toBe("Organization"); + }); + + it("rejects unsupported owner types", () => { + expect(() => parseOwnerType({ type: "Bot" })).toThrow("unsupported owner type"); + }); +}); + describe("parseRepoNames", () => { it("parses repository names from GitHub list responses", () => { expect( @@ -74,6 +87,26 @@ describe("parseRepoNames", () => { }); }); +describe("parseRepoListItems", () => { + it("parses repository owner logins from list responses", () => { + expect( + parseRepoListItems([ + { name: "scratch", full_name: "dutifuldev/scratch", owner: { login: "dutifuldev" } }, + { name: "bob", full_name: "osolmaz/bob", owner: { login: "osolmaz" } } + ]) + ).toEqual([ + { name: "scratch", full_name: "dutifuldev/scratch", owner_login: "dutifuldev" }, + { name: "bob", full_name: "osolmaz/bob", owner_login: "osolmaz" } + ]); + }); + + it("rejects list responses without owner logins", () => { + expect(() => + parseRepoListItems([{ name: "scratch", full_name: "dutifuldev/scratch" }]) + ).toThrow("repo.owner"); + }); +}); + describe("parseRulesetSummaries", () => { it("parses summary responses", () => { expect( diff --git a/tests/planner.test.ts b/tests/planner.test.ts index 4e2a0f3..8ac9849 100644 --- a/tests/planner.test.ts +++ b/tests/planner.test.ts @@ -21,7 +21,7 @@ describe("buildPlan", () => { }); await expect( - buildPlan(client, { org: "dutifuldev", repos: ["scratch"], all: false }) + buildPlan(client, { owner: "dutifuldev", repos: ["scratch"], all: false }) ).resolves.toEqual([ { name: "scratch", @@ -45,7 +45,7 @@ describe("buildPlan", () => { }); await expect( - buildPlan(client, { org: "dutifuldev", repos: ["scratch"], all: false }) + buildPlan(client, { owner: "dutifuldev", repos: ["scratch"], all: false }) ).resolves.toMatchObject([{ ruleset: { action: "none", coveredBy: "Protect main" } }]); }); @@ -58,7 +58,7 @@ describe("buildPlan", () => { }); await expect( - buildPlan(client, { org: "dutifuldev", repos: ["scratch"], all: false }) + buildPlan(client, { owner: "dutifuldev", repos: ["scratch"], all: false }) ).resolves.toMatchObject([{ ruleset: { action: "create" } }]); }); @@ -71,7 +71,7 @@ describe("buildPlan", () => { }); await expect( - buildPlan(client, { org: "dutifuldev", repos: ["scratch"], all: false }) + buildPlan(client, { owner: "dutifuldev", repos: ["scratch"], all: false }) ).resolves.toMatchObject([{ ruleset: { action: "update" } }]); }); @@ -117,7 +117,7 @@ type FakeClientOptions = { function fakeClient(options: FakeClientOptions): GitHubClient { return { getRepo: () => Promise.resolve(options.repo), - listOrgRepos: () => Promise.resolve([options.repo]), + listOwnerRepos: () => Promise.resolve([options.repo]), updateRepoDefaults: () => Promise.resolve(), listRepoRulesets: () => Promise.resolve(options.rulesets), getRepoRuleset: () => {