diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d4a636f..3414399 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: / schedule: interval: weekly + cooldown: + default-days: 7 labels: - dependencies ignore: @@ -12,6 +14,8 @@ updates: directory: / schedule: interval: weekly + cooldown: + default-days: 7 labels: - dependencies - github-actions diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8d53bf3..6721e1e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -31,4 +31,5 @@ ## Release - [ ] A root Changeset is included when the change affects a published package. +- [ ] If a package change intentionally needs no release, the `skip-changeset` label is applied with maintainer agreement. - [ ] No package-specific release workflow or prerelease metadata was added. diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 0000000..ec7c6ac --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,31 @@ +name: Documentation links + +on: + pull_request: + paths: + - README.md + - apps/web/** + - packages/*/README.md + - .lycheeignore + schedule: + - cron: "23 9 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + links: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check documentation links + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 + with: + args: >- + --verbose --no-progress --max-retries 3 --retry-wait-time 2 --timeout 20 --exclude-mail --exclude '^/' --accept 200,206,301,302,307,308,429 README.md 'apps/web/**/*.md' 'apps/web/**/*.mdx' 'packages/*/README.md' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml new file mode 100644 index 0000000..a4f89ed --- /dev/null +++ b/.github/workflows/pr-metadata.yml @@ -0,0 +1,37 @@ +name: PR metadata + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled] + +concurrency: + group: pr-metadata-${{ github.event.pull_request.number }}-${{ github.event.action }}-${{ github.event.label.name || 'none' }} + cancel-in-progress: ${{ github.event.action == 'synchronize' }} + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + metadata: + if: >- + (github.event.action != 'labeled' && github.event.action != 'unlabeled') || github.event.label.name == 'skip-changeset' + runs-on: ubuntu-latest + steps: + - name: Checkout trusted default-branch automation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + - name: Label pull request and validate Changeset coverage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bun scripts/pr-metadata.ts diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml new file mode 100644 index 0000000..a4378d8 --- /dev/null +++ b/.github/workflows/workflow-security.yml @@ -0,0 +1,75 @@ +name: Workflow security + +on: + pull_request: + paths: + - .github/workflows/** + - .github/actions/** + - .github/dependabot.yml + - .github/zizmor.yml + push: + branches: [main] + paths: + - .github/workflows/** + - .github/actions/** + - .github/dependabot.yml + - .github/zizmor.yml + +permissions: + contents: read + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: actionlint + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2 + with: + version: 1.7.11 + cache: false + shellcheck: true + pyflakes: false + + zizmor: + runs-on: ubuntu-latest + steps: + - name: Checkout trusted security configuration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + path: trusted + persist-credentials: false + - name: Checkout workflow changes for analysis + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: source + persist-credentials: false + - name: Select trusted zizmor configuration + id: zizmor-config + shell: bash + env: + IS_FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} + run: | + if [[ -f trusted/.github/zizmor.yml ]]; then + echo "path=trusted/.github/zizmor.yml" >> "$GITHUB_OUTPUT" + elif [[ "$IS_FORK_PR" == "true" ]]; then + echo "::error::The trusted default branch has no zizmor configuration; refusing to use fork-provided configuration." + exit 1 + else + echo "::warning::The trusted branch has no zizmor configuration yet; using same-repository configuration for this bootstrap run." + echo "path=source/.github/zizmor.yml" >> "$GITHUB_OUTPUT" + fi + - name: zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + advanced-security: false + annotations: true + config: ${{ steps.zizmor-config.outputs.path }} + inputs: source/.github + version: 1.30.1 diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000..08ed5a3 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,16 @@ +# These findings are limited to existing, intentionally reviewed workflow +# patterns. Keep the ignores line-specific so new findings remain visible. +rules: + dangerous-triggers: + ignore: + - deploy-preview.yml:3 + - deploy.yml:3 + # Required for fork-safe labeling; this workflow checks out only the trusted default branch. + - pr-metadata.yml:3 + excessive-permissions: + ignore: + - deploy-preview.yml:20 + - deploy-preview.yml:21 + adhoc-packages: + ignore: + - release.yml:35 diff --git a/AGENTS.md b/AGENTS.md index b4a0175..70e0578 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,11 @@ ## Changesets -Create Changesets at the root with `bun run changeset-add -- force-input|usage-limits patch|minor|major "summary"`. +Create Changesets at the root with `bun run changeset-add -- docs|force-input|usage-limits patch|minor|major "summary"`. Meaningful plugin changes and documentation-site content changes require a matching Changeset. Tests, package-local scripts, and listed development-only metadata/configuration changes are exempt. Apply `skip-changeset` only for a justified non-release change and with maintainer agreement. + +## Pull request automation + +PR metadata automation labels changed components from file paths and package names in changed Changesets, and reconciles size labels on every update. Documentation link checks run for relevant edits and weekly. Workflow security scans run when Actions or Dependabot configuration changes. ## Testing diff --git a/packages/opencode-force-input/scripts/test-package.ts b/packages/opencode-force-input/scripts/test-package.ts index a71a41d..9eb6d2d 100644 --- a/packages/opencode-force-input/scripts/test-package.ts +++ b/packages/opencode-force-input/scripts/test-package.ts @@ -1,3 +1,5 @@ +import { checkPackageTarball } from "../../../scripts/check-package-tarball.ts"; + const tuiEntrypoint = new URL("../dist/index.mjs", import.meta.url); const tuiModule = await import(tuiEntrypoint.href); const tuiPlugin = tuiModule.default; @@ -13,3 +15,4 @@ if (!hasValidTuiPlugin) { } console.log(`Package smoke test passed: ${tuiPlugin.id}`); +await checkPackageTarball("packages/opencode-force-input"); diff --git a/packages/opencode-usage-limits/__tests__/package-tarball.test.ts b/packages/opencode-usage-limits/__tests__/package-tarball.test.ts new file mode 100644 index 0000000..513ebed --- /dev/null +++ b/packages/opencode-usage-limits/__tests__/package-tarball.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { isUnexpectedPackagePath } from "../../../scripts/package-tarball-helpers.ts"; + +describe("package tarball paths", () => { + it("rejects package source, test, and script directories", () => { + expect(isUnexpectedPackagePath("src/index.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("__tests__/plugin.test.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("scripts/test-package.ts")).toBeTruthy(); + }); + + it("rejects root-level test and build directories", () => { + expect(isUnexpectedPackagePath("test/fixtures/sample.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("tests/fixtures/sample.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("spec/plugin.spec.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("build/output.js")).toBeTruthy(); + }); + + it("rejects root-level test and build scripts", () => { + expect(isUnexpectedPackagePath("test-package.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("build.ts")).toBeTruthy(); + expect(isUnexpectedPackagePath("plugin.test.ts")).toBeTruthy(); + }); + + it("allows intended root package files and build output", () => { + expect(isUnexpectedPackagePath("README.md")).toBeFalsy(); + expect(isUnexpectedPackagePath("dist/index.mjs")).toBeFalsy(); + expect(isUnexpectedPackagePath("usage-limits.schema.json")).toBeFalsy(); + }); +}); diff --git a/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts b/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts new file mode 100644 index 0000000..aee9902 --- /dev/null +++ b/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { + getComponentLabels, + getMissingChangesets, + getPullRequestFilePageCount, + getRequiredChangesets, + getSizeLabel, + isChangesetReleasePR, + isDuplicateLabelError, + parseChangesetEntries, + reconcileLabels, +} from "../../../scripts/pr-metadata-helpers.ts"; + +const forceInput = "@mynameistito/opencode-force-input"; +const usageLimits = "@mynameistito/opencode-usage-limits"; +const docs = "@mynameistito/opencode-plugins-docs"; +const changeset = (entries: string): string => + `---\n${entries}\n---\nSummary\n`; + +describe("PR metadata helpers", () => { + it("maps package and documentation paths to component labels", () => { + expect( + getComponentLabels( + [ + "packages/opencode-force-input/src/index.ts", + "packages/opencode-usage-limits/src/index.ts", + "apps/web/docs/index.mdx", + ], + [] + ) + ).toStrictEqual(new Set(["force-input", "usage-limits", "docs"])); + }); + + it("parses one or multiple valid Changeset package entries", () => { + expect( + parseChangesetEntries(changeset(`"${usageLimits}": patch`)) + ).toStrictEqual([usageLimits]); + expect( + parseChangesetEntries( + changeset(`"${forceInput}": minor\n"${usageLimits}": patch`) + ) + ).toStrictEqual([forceInput, usageLimits]); + }); + + it("ignores malformed or unsupported Changeset entries", () => { + expect( + parseChangesetEntries(`---\n${usageLimits}: sideways\n---`) + ).toStrictEqual([]); + expect(parseChangesetEntries("not frontmatter")).toStrictEqual([]); + }); + + it("unions path and Changeset component signals", () => { + expect( + getComponentLabels(["package.json"], [usageLimits, forceInput]) + ).toStrictEqual(new Set(["usage-limits", "force-input"])); + expect( + getComponentLabels(["packages/opencode-force-input/src/index.ts"], []) + ).toStrictEqual(new Set(["force-input"])); + }); + + it("requires Changesets for meaningful plugin and docs changes only", () => { + expect( + getRequiredChangesets([ + "packages/opencode-force-input/src/index.ts", + "packages/opencode-usage-limits/README.md", + "apps/web/docs/usage-limits.mdx", + ]) + ).toStrictEqual(new Set([forceInput, usageLimits, docs])); + expect( + getRequiredChangesets([ + "packages/opencode-force-input/__tests__/plugin.test.ts", + "packages/opencode-usage-limits/scripts/test-package.ts", + "packages/opencode-force-input/tsconfig.json", + "packages/opencode-force-input/CONTRIBUTING.md", + ]) + ).toStrictEqual(new Set()); + }); + + it("reports changed packages without a matching Changeset entry", () => { + expect( + getMissingChangesets( + new Set([forceInput, usageLimits]), + new Set([forceInput]) + ) + ).toStrictEqual([usageLimits]); + expect( + getMissingChangesets(new Set([forceInput]), new Set([forceInput])) + ).toStrictEqual([]); + }); + + it("paginates all pull request files up to the API limit", () => { + expect(getPullRequestFilePageCount(0)).toBe(0); + expect(getPullRequestFilePageCount(301)).toBe(4); + expect(getPullRequestFilePageCount(3000)).toBe(30); + expect(() => getPullRequestFilePageCount(3001)).toThrow( + "more than 3000 changed files" + ); + }); + + it("exempts generated Changesets release pull requests", () => { + expect( + isChangesetReleasePR( + "changeset-release/main", + "mynameistito/opencode-plugins", + "mynameistito/opencode-plugins" + ) + ).toBeTruthy(); + expect( + isChangesetReleasePR( + "changeset-release/main", + "fork/opencode-plugins", + "mynameistito/opencode-plugins" + ) + ).toBeFalsy(); + expect( + isChangesetReleasePR( + "feature/update-plugin", + "mynameistito/opencode-plugins", + "mynameistito/opencode-plugins" + ) + ).toBeFalsy(); + }); + + it("recognizes only GitHub's duplicate-label validation response", () => { + expect( + isDuplicateLabelError( + 422, + JSON.stringify({ errors: [{ code: "already_exists" }] }) + ) + ).toBeTruthy(); + expect( + isDuplicateLabelError( + 422, + JSON.stringify({ errors: [{ code: "invalid" }] }) + ) + ).toBeFalsy(); + expect(isDuplicateLabelError(422, "not JSON")).toBeFalsy(); + expect( + isDuplicateLabelError( + 500, + JSON.stringify({ errors: [{ code: "already_exists" }] }) + ) + ).toBeFalsy(); + }); + + it("removes stale managed labels but preserves unrelated labels", () => { + expect( + reconcileLabels( + ["force-input", "size/l", "triaged"], + new Set(["usage-limits", "size/s"]), + new Set(["force-input", "usage-limits", "size/l", "size/s"]) + ) + ).toStrictEqual({ + add: ["usage-limits", "size/s"], + remove: ["force-input", "size/l"], + }); + }); + + it("ignores lockfiles and generated output when measuring PR size", () => { + expect( + getSizeLabel([ + { additions: 50_000, deletions: 50_000, filename: "bun.lock" }, + { + additions: 5000, + deletions: 0, + filename: "apps/web/.blume/generated.ts", + }, + { + additions: 4, + deletions: 4, + filename: "packages/plugin/src/index.ts", + }, + ]) + ).toBe("size/xs"); + expect( + getSizeLabel([{ additions: 1001, deletions: 0, filename: "src/a.ts" }]) + ).toBe("size/xl"); + }); +}); diff --git a/packages/opencode-usage-limits/scripts/test-package.ts b/packages/opencode-usage-limits/scripts/test-package.ts index 1dc40cd..2eb488b 100644 --- a/packages/opencode-usage-limits/scripts/test-package.ts +++ b/packages/opencode-usage-limits/scripts/test-package.ts @@ -1,5 +1,7 @@ import { createRequire } from "node:module"; +import { checkPackageTarball } from "../../../scripts/check-package-tarball.ts"; + const expectedId = "mynameistito.usage-limits"; const entrypoint = new URL("../dist/index.mjs", import.meta.url); @@ -55,3 +57,4 @@ if (!isPlugin) { } console.log(`Package smoke test passed: ${expectedId}`); +await checkPackageTarball("packages/opencode-usage-limits"); diff --git a/scripts/check-package-tarball.ts b/scripts/check-package-tarball.ts new file mode 100644 index 0000000..11a3e2f --- /dev/null +++ b/scripts/check-package-tarball.ts @@ -0,0 +1,103 @@ +import { appendFileSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { isUnexpectedPackagePath } from "./package-tarball-helpers.ts"; + +interface PackageManifest { + name: string; + files?: string[]; +} + +interface PackedFile { + path: string; + size: number; +} + +interface PackResult { + files: PackedFile[]; + size: number; + unpackedSize: number; +} + +const npmPath = Bun.which("npm"); +if (!npmPath) { + throw new Error("npm is required to validate package tarballs."); +} + +export const checkPackageTarball = async ( + packageDirectory: string +): Promise => { + const absolutePackageDirectory = path.resolve( + import.meta.dir, + "..", + packageDirectory + ); + const manifestPath = path.join(absolutePackageDirectory, "package.json"); + // SAFETY: Package-specific manifest and required-file checks verify this structure. + const manifest = JSON.parse( + readFileSync(manifestPath, "utf-8") + ) as PackageManifest; + const childProcess = Bun.spawn( + [npmPath, "pack", "--dry-run", "--json", "--ignore-scripts"], + { cwd: absolutePackageDirectory, stderr: "inherit", stdout: "pipe" } + ); + const output = await new Response(childProcess.stdout).text(); + if ((await childProcess.exited) !== 0) { + throw new Error(`npm pack failed for ${manifest.name}.`); + } + // SAFETY: npm pack --json returns either a result array or a workspace map with the fields declared here. + const parsed = JSON.parse(output) as + | PackResult[] + | Record; + let pack: PackResult | undefined; + if (Array.isArray(parsed)) { + [pack] = parsed; + } else { + pack = parsed[manifest.name]; + } + if (!pack || !Array.isArray(pack.files)) { + throw new Error(`npm pack returned no result for ${manifest.name}`); + } + const packedPaths = new Set(pack.files.map(({ path: filePath }) => filePath)); + const requiredFiles = + manifest.name === "@mynameistito/opencode-force-input" + ? ["README.md", "LICENSE", "dist/index.mjs", "dist/index.d.mts"] + : [ + "README.md", + "LICENSE", + "dist/index.mjs", + "dist/index.d.mts", + "usage-limits.schema.json", + "examples/usage-limits.jsonc", + ]; + const missingFiles = requiredFiles.filter((file) => !packedPaths.has(file)); + const unexpectedFiles = [...packedPaths].filter(isUnexpectedPackagePath); + if (missingFiles.length > 0 || unexpectedFiles.length > 0) { + throw new Error( + [ + `npm pack validation failed for ${manifest.name}`, + ...(missingFiles.length > 0 + ? [`Missing required files: ${missingFiles.join(", ")}`] + : []), + ...(unexpectedFiles.length > 0 + ? [`Unexpected files: ${unexpectedFiles.join(", ")}`] + : []), + ].join("\n") + ); + } + + const summary = `| \`${manifest.name}\` | ${pack.files.length} | ${(pack.size / 1024).toFixed(1)} KB | ${(pack.unpackedSize / 1024).toFixed(1)} KB |`; + console.log( + `npm pack validation passed: ${manifest.name}, ${pack.files.length} files, ${(pack.size / 1024).toFixed(1)} KB packed.` + ); + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (summaryPath) { + if (readFileSync(summaryPath, "utf-8").length === 0) { + appendFileSync( + summaryPath, + "| Package | Files | Packed | Unpacked |\n| --- | ---: | ---: | ---: |\n" + ); + } + appendFileSync(summaryPath, `${summary}\n`); + } +}; diff --git a/scripts/package-tarball-helpers.ts b/scripts/package-tarball-helpers.ts new file mode 100644 index 0000000..d3a89ad --- /dev/null +++ b/scripts/package-tarball-helpers.ts @@ -0,0 +1,23 @@ +const isUnexpectedRootFile = (filePath: string): boolean => { + if (filePath.includes("/")) { + return false; + } + return ( + ["build", "script", "scripts", "test"].some( + (prefix) => filePath === prefix || filePath.startsWith(`${prefix}.`) + ) || + filePath.startsWith("test-") || + /\.(?:test|spec)\.[^/.]+$/u.test(filePath) + ); +}; + +/** + * Identify package paths that should not be included in the published tarball. + * + * @param filePath - A path reported by `npm pack --dry-run`. + * @returns Whether the path belongs to an excluded source directory or is a root-level test/script file. + */ +export const isUnexpectedPackagePath = (filePath: string): boolean => + /^(?:src|__tests__|scripts|coverage|node_modules|test|tests|spec|build)\//u.test( + filePath + ) || isUnexpectedRootFile(filePath); diff --git a/scripts/pr-metadata-helpers.ts b/scripts/pr-metadata-helpers.ts new file mode 100644 index 0000000..3f354f2 --- /dev/null +++ b/scripts/pr-metadata-helpers.ts @@ -0,0 +1,233 @@ +export const packageLabels = new Map([ + ["@mynameistito/opencode-force-input", "force-input"], + ["@mynameistito/opencode-usage-limits", "usage-limits"], + ["@mynameistito/opencode-plugins-docs", "docs"], +]); + +const pathLabels = new Map([ + ["packages/opencode-force-input/", "force-input"], + ["packages/opencode-usage-limits/", "usage-limits"], + ["apps/web/", "docs"], +]); + +const meaningfulPackageFile = (filename: string): boolean => { + const segments = filename.split("/"); + const excludedDirectory = segments.some((segment) => + ["__tests__", "scripts"].includes(segment) + ); + const excludedFile = [ + "AGENTS.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "SECURITY.md", + "tsconfig.json", + "vitest.config.ts", + "vitest.setup.ts", + ].includes(segments.at(-1) ?? ""); + return !excludedDirectory && !excludedFile; +}; + +export const parseChangesetEntries = (content: string): string[] => { + const lines = content.split(/\r?\n/u); + const opening = lines.indexOf("---"); + const closing = lines.indexOf("---", opening + 1); + if (opening !== 0 || closing === -1) { + return []; + } + const names: string[] = []; + for (const line of lines.slice(opening + 1, closing)) { + const entry = line.match( + /^\s*["'](?[^"']+)["']\s*:\s*(?patch|minor|major)\s*(?:#.*)?$/u + ); + const name = entry?.groups?.name; + if (name && packageLabels.has(name)) { + names.push(name); + } + } + return names; +}; + +export const getComponentLabels = ( + filenames: string[], + changesetNames: Iterable +): Set => { + const labels = new Set(); + for (const filename of filenames) { + for (const [path, label] of pathLabels) { + if (filename.startsWith(path)) { + labels.add(label); + } + } + } + for (const name of changesetNames) { + const label = packageLabels.get(name); + if (label) { + labels.add(label); + } + } + return labels; +}; + +const pullRequestFilesPerPage = 100; +const pullRequestFilesLimit = 3000; + +/** + * Get the number of GitHub API pages needed to inspect all changed files. + * + * @param changedFiles - The changed-file count reported by the pull request. + * @returns The number of pages at 100 files per page. + */ +export const getPullRequestFilePageCount = (changedFiles: number): number => { + if (changedFiles > pullRequestFilesLimit) { + throw new Error( + `Pull requests with more than ${pullRequestFilesLimit} changed files cannot be fully inspected by the GitHub API.` + ); + } + return Math.ceil(changedFiles / pullRequestFilesPerPage); +}; + +/** + * Identify the release PR branch created by Changesets. + * + * @param headRef - The pull request head branch name. + * @param headRepository - The repository containing the pull request head. + * @param baseRepository - The repository receiving the pull request. + * @returns Whether a same-repository branch uses Changesets' release PR prefix. + */ +export const isChangesetReleasePR = ( + headRef: string, + headRepository: string | null | undefined, + baseRepository: string +): boolean => + headRepository === baseRepository && headRef.startsWith("changeset-release/"); + +/** + * Check whether a GitHub API validation response is specifically a duplicate label. + * + * @param status - The HTTP status returned by GitHub. + * @param responseBody - The response body returned by GitHub. + * @returns Whether the response identifies an existing label by name. + */ +export const isDuplicateLabelError = ( + status: number, + responseBody: string +): boolean => { + if (status !== 422) { + return false; + } + try { + const payload: { errors?: { code?: string }[] } = JSON.parse(responseBody); + return ( + payload.errors?.some(({ code }) => code === "already_exists") ?? false + ); + } catch { + return false; + } +}; + +interface ChangedFile { + filename: string; + additions: number; + deletions: number; +} + +export const getSizeLabel = (files: ChangedFile[]): string => { + const ignoredFilenames = new Set([ + "bun.lock", + "bun.lockb", + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "CHANGELOG.md", + ]); + const ignoredDirectories = new Set([ + "coverage", + "dist", + ".blume", + ".blume-verify", + ".alchemy", + "node_modules", + ]); + let changedLines = 0; + for (const file of files) { + const segments = file.filename.split("/"); + const basename = segments.at(-1) ?? ""; + const ignored = + ignoredFilenames.has(basename) || + segments.some((segment) => ignoredDirectories.has(segment)) || + basename.endsWith(".snap") || + basename.endsWith(".tsbuildinfo"); + if (!ignored) { + changedLines += file.additions + file.deletions; + } + } + if (changedLines <= 10) { + return "size/xs"; + } + if (changedLines <= 100) { + return "size/s"; + } + if (changedLines <= 500) { + return "size/m"; + } + if (changedLines <= 1000) { + return "size/l"; + } + return "size/xl"; +}; + +export const getRequiredChangesets = (filenames: string[]): Set => { + const required = new Set(); + for (const filename of filenames) { + if ( + filename.startsWith("packages/opencode-force-input/") && + meaningfulPackageFile(filename) + ) { + required.add("@mynameistito/opencode-force-input"); + } + if ( + filename.startsWith("packages/opencode-usage-limits/") && + meaningfulPackageFile(filename) + ) { + required.add("@mynameistito/opencode-usage-limits"); + } + if ( + filename.startsWith("apps/web/docs/") || + [ + "apps/web/alchemy.run.ts", + "apps/web/blume.config.ts", + "apps/web/theme.css", + ].includes(filename) + ) { + required.add("@mynameistito/opencode-plugins-docs"); + } + } + return required; +}; + +export const getMissingChangesets = ( + requiredPackages: Iterable, + changesetPackages: Set +): string[] => + [...requiredPackages].filter((name) => !changesetPackages.has(name)); + +interface LabelReconciliation { + add: string[]; + remove: string[]; +} + +export const reconcileLabels = ( + currentLabels: string[], + desiredLabels: Set, + managedLabels: Set +): LabelReconciliation => { + const add = [...desiredLabels].filter( + (label) => !currentLabels.includes(label) + ); + const remove = currentLabels.filter( + (label) => managedLabels.has(label) && !desiredLabels.has(label) + ); + return { add, remove }; +}; diff --git a/scripts/pr-metadata.ts b/scripts/pr-metadata.ts new file mode 100644 index 0000000..d5107a8 --- /dev/null +++ b/scripts/pr-metadata.ts @@ -0,0 +1,259 @@ +import { + getComponentLabels, + getMissingChangesets, + getPullRequestFilePageCount, + getRequiredChangesets, + getSizeLabel, + isChangesetReleasePR, + isDuplicateLabelError, + packageLabels, + parseChangesetEntries, + reconcileLabels, +} from "./pr-metadata-helpers.ts"; + +const owner = process.env.GITHUB_REPOSITORY?.split("/")[0]; +const repository = process.env.GITHUB_REPOSITORY?.split("/")[1]; +const token = process.env.GITHUB_TOKEN; +const pullRequestNumber = Number(process.env.PR_NUMBER); +const headSha = process.env.PR_HEAD_SHA; + +if (!owner || !repository || !token || !Number.isInteger(pullRequestNumber)) { + throw new Error( + "Missing GitHub repository, token, or pull request metadata." + ); +} + +class GitHubApiError extends Error { + override readonly name = "GitHubApiError"; + readonly status: number; + readonly responseBody: string; + + constructor(status: number, responseBody: string) { + super(`GitHub API ${status}: ${responseBody}`); + this.status = status; + this.responseBody = responseBody; + } +} + +const api = async (endpoint: string, init?: RequestInit): Promise => { + const response = await fetch(`https://api.github.com${endpoint}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + ...init?.headers, + }, + }); + if (!response.ok) { + throw new GitHubApiError(response.status, await response.text()); + } + if (response.status === 204) { + // SAFETY: The DELETE label endpoint returns no response body. + return undefined as T; + } + // SAFETY: Each caller pairs this response with the schema for the requested GitHub API endpoint. + return (await response.json()) as T; +}; + +interface PullRequestFile { + filename: string; + additions: number; + deletions: number; +} + +interface PullRequest { + head: { + ref: string; + sha: string; + repo?: { full_name?: string } | null; + }; + labels: { name: string }[]; + changed_files: number; +} + +const pullRequest = await api( + `/repos/${owner}/${repository}/pulls/${pullRequestNumber}` +); +if (headSha && pullRequest.head.sha !== headSha) { + console.log( + `Skipping stale event for ${headSha}; pull request now points to ${pullRequest.head.sha}.` + ); + process.exit(0); +} +const pageCount = getPullRequestFilePageCount(pullRequest.changed_files); +const filePages = await Promise.all( + Array.from({ length: pageCount }, (_, index) => + api( + `/repos/${owner}/${repository}/pulls/${pullRequestNumber}/files?per_page=100&page=${index + 1}` + ) + ) +); +const files = filePages.flat(); + +const componentLabels = new Set(packageLabels.values()); +const managedLabels = new Set([ + ...componentLabels, + "release", + "dependencies", + "github-actions", + "size/xs", + "size/s", + "size/m", + "size/l", + "size/xl", +]); + +const changesetFiles = files.filter(({ filename }) => + /^\.changeset\/(?!README\.md$)[^/]+\.md$/u.test(filename) +); +const headRepository = + pullRequest.head.repo?.full_name ?? `${owner}/${repository}`; +const changesetContents = await Promise.all( + changesetFiles.map(async ({ filename }) => { + try { + const encodedPath = filename.split("/").map(encodeURIComponent).join("/"); + const file = await api<{ content?: string; encoding?: string }>( + `/repos/${headRepository}/contents/${encodedPath}?ref=${encodeURIComponent(headSha ?? pullRequest.head.sha)}` + ); + if (file.encoding !== "base64" || !file.content) { + return []; + } + const content = Buffer.from(file.content, "base64").toString("utf-8"); + return parseChangesetEntries(content); + } catch (error) { + console.warn( + `Could not read Changeset ${filename}; ignoring it: ${String(error)}` + ); + return []; + } + }) +); +const changesetNames = new Set(changesetContents.flat()); +const components = getComponentLabels( + files.map(({ filename }) => filename), + changesetNames +); + +const labelSet = new Set(components); +if (files.some(({ filename }) => filename.startsWith(".changeset/"))) { + labelSet.add("release"); +} +if ( + files.some( + ({ filename }) => + filename === "package.json" || + filename.endsWith("/package.json") || + ["bun.lock", "bun.lockb"].includes(filename) + ) +) { + labelSet.add("dependencies"); +} +if (files.some(({ filename }) => filename.startsWith(".github/"))) { + labelSet.add("github-actions"); +} + +const sizeLabel = getSizeLabel(files); +labelSet.add(sizeLabel); + +const currentLabels = pullRequest.labels.map(({ name }) => name); +const skipChangesetLabel = "skip-changeset"; +const ensureLabels = new Set([ + ...labelSet, + ...(currentLabels.includes(skipChangesetLabel) ? [] : [skipChangesetLabel]), +]); +const labelsEndpoint = `/repos/${owner}/${repository}/labels`; +const getLabelColor = (name: string): string => { + if (name === skipChangesetLabel) { + return "d4c5f9"; + } + if (name.startsWith("size/")) { + return "ededed"; + } + return "1d76db"; +}; +await Promise.all( + [...ensureLabels].map(async (name) => { + const color = getLabelColor(name); + const description = + name === skipChangesetLabel + ? "Use only for justified changes that do not require a release." + : "Automatically managed pull request metadata"; + try { + await api(labelsEndpoint, { + body: JSON.stringify({ + color, + description, + name, + }), + method: "POST", + }); + } catch (error) { + if ( + !(error instanceof GitHubApiError) || + !isDuplicateLabelError(error.status, error.responseBody) + ) { + throw error; + } + await api(`${labelsEndpoint}/${encodeURIComponent(name)}`, { + body: JSON.stringify({ color, description, name }), + method: "PATCH", + }); + } + }) +); + +const { add: labelsToAdd, remove: labelsToRemove } = reconcileLabels( + currentLabels, + labelSet, + managedLabels +); +if (labelsToAdd.length > 0) { + await api( + `/repos/${owner}/${repository}/issues/${pullRequestNumber}/labels`, + { + body: JSON.stringify({ labels: labelsToAdd }), + method: "POST", + } + ); +} +if (labelsToRemove.length > 0) { + await Promise.all( + labelsToRemove.map((name) => + api( + `/repos/${owner}/${repository}/issues/${pullRequestNumber}/labels/${encodeURIComponent(name)}`, + { method: "DELETE" } + ) + ) + ); +} + +const skipChangeset = currentLabels.includes(skipChangesetLabel); +const requiredPackages = + skipChangeset || + isChangesetReleasePR( + pullRequest.head.ref, + pullRequest.head.repo?.full_name, + `${owner}/${repository}` + ) + ? new Set() + : getRequiredChangesets(files.map(({ filename }) => filename)); + +const missingPackages = getMissingChangesets(requiredPackages, changesetNames); +if (missingPackages.length > 0) { + throw new Error( + `Changeset required for changed published/deployed package(s):\n${missingPackages + .map( + (name) => + `- ${name} (missing a valid entry in a changed .changeset/*.md file)` + ) + .join( + "\n" + )}\nAdd a Changeset or apply the skip-changeset label for a justified non-release change.` + ); +} + +console.log( + `PR metadata reconciled: ${[...labelSet].join(", ")}; Changeset coverage passed${skipChangeset ? " (skip-changeset label)" : ""}.` +);