diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml new file mode 100644 index 0000000..18188da --- /dev/null +++ b/.github/workflows/merge.yml @@ -0,0 +1,33 @@ +name: Merge + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + +jobs: + merge: + runs-on: ubuntu-latest + if: > + github.event.issue.pull_request && + github.event.comment.body == '/merge' + steps: + - uses: actions/checkout@v6 + - run: npm install js-yaml + + - name: Verify actor is authorized + uses: actions/github-script@v9 + with: + script: | + const verifyMerge = require('./scripts/verify-merge.js'); + await verifyMerge({ github, context }); + + - name: Merge PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.issue.number }} + run: | + gh pr merge "$PR_NUMBER" --squash --auto diff --git a/.github/workflows/sync-codeowners.yml b/.github/workflows/sync-codeowners.yml deleted file mode 100644 index 48833b9..0000000 --- a/.github/workflows/sync-codeowners.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Sync CODEOWNERS - -on: - push: - branches: [main] - paths: - - "gaps/*/metadata.yml" - -jobs: - sync: - name: Sync CODEOWNERS from metadata - runs-on: ubuntu-latest - - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "24" - cache: "npm" - - - name: Install dependencies - run: npm ci - - - name: Sync CODEOWNERS - run: node scripts/sync-codeowners.js - - - name: Commit if changed - run: | - git diff --quiet CODEOWNERS && exit 0 - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add CODEOWNERS - git commit -m "Sync CODEOWNERS from GAP metadata" - git push diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index 908e748..0000000 --- a/CODEOWNERS +++ /dev/null @@ -1,3 +0,0 @@ -/gaps/GAP-10/ @magicmark @rebello95 -/gaps/GAP-13/ @benjie -/gaps/GAP-7/ @magicmark diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c223c4..64e0992 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,11 +9,10 @@ that address issues outside the core GraphQL specifications. ([@graphql/gaps-editors](https://github.com/orgs/graphql/teams/gaps-editors)), approved by the TSC to administer the GAP program. - **Sponsor** — an _editor_ assigned to a GAP who is responsible for approving - the initial contents. A _sponsor_ may also be an _author_. + and merging the initial contents. A _sponsor_ may also be an _author_. - **Author** — a person (or people) who have made significant contributions to a - GAP, listed in the `authors` field of `metadata.yml`. _Authors_ are given - commit access via `CODEOWNERS` to merge their own and others' submissions to - the GAP. + GAP, listed in the `authors` field of `metadata.yml`. _Authors_ can merge PRs + that only touch their GAP directory (see [Merging](#merging) below). ## GAP Numbering @@ -62,9 +61,6 @@ gauge public interest, but doing so is not necessary. Once approved by the _authors_ and _sponsor_, the PR should be merged by the _sponsor_. -`CODEOWNERS` will automatically be updated allowing _authors_ to merge future -contributions to their GAP. - > [!IMPORTANT] > GAP numbers never change. If a proposal needs significant changes, create a > new GAP and deprecate the old one. @@ -126,6 +122,12 @@ The _sponsor_ of a GAP is responsible for ensuring changes to the GAP are approved by the _authors_ before merging, though this task may also be performed by the TSC. The _authors_ are responsible for guiding contribution to the GAP. +### Merging + +PRs that only modify files within a single `gaps/GAP-N/` directory can be merged +by any _author_ listed in that directory's `metadata.yml` by commenting `/merge` +on the PR. + ### Versioning To release a version of a GAP, copy the current `DRAFT.md` into a `versions` diff --git a/package.json b/package.json index a2621eb..8f6bf9f 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,7 @@ "suggest:format": "echo \"\nTo resolve this, run: $(tput bold)npm run format$(tput sgr0)\" && exit 1", "test:format": "prettier --check . || npm run suggest:format", "test:spelling": "cspell \"spec/**/*.md\" README.md LICENSE.md", - "test:structure": "find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} ./scripts/validate-structure.js {}", - "sync:codeowners": "node scripts/sync-codeowners.js" + "test:structure": "find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} ./scripts/validate-structure.js {}" }, "devDependencies": { "@mlarah/spec-md": "^3.1.0", diff --git a/scripts/sync-codeowners.js b/scripts/sync-codeowners.js deleted file mode 100755 index 614b40f..0000000 --- a/scripts/sync-codeowners.js +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node - -import { readdir, readFile, writeFile } from "node:fs/promises"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { parse as parseYaml } from "yaml"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const rootDir = join(__dirname, ".."); -const gapsDir = join(rootDir, "gaps"); - -async function getGapDirs() { - const entries = await readdir(gapsDir, { withFileTypes: true }); - return entries.filter((d) => d.isDirectory() && /^GAP-[1-9]\d*$/.test(d.name)); -} - -async function main() { - const dirs = await getGapDirs(); - dirs.sort(); - - const lines = await Promise.all( - dirs.map(async (dir) => { - const metadataPath = join(gapsDir, dir.name, "metadata.yml"); - const metadata = parseYaml(await readFile(metadataPath, "utf8")); - const owners = metadata.authors.map((a) => a.githubUsername.replace(/^@/, "")); - const ownerList = owners.map((o) => `@${o}`).join(" "); - return `/gaps/${dir.name}/ ${ownerList}`; - }), - ); - - await writeFile(join(rootDir, "CODEOWNERS"), lines.join("\n") + "\n"); -} - -main(); diff --git a/scripts/verify-merge.js b/scripts/verify-merge.js new file mode 100644 index 0000000..5ad8e3e --- /dev/null +++ b/scripts/verify-merge.js @@ -0,0 +1,32 @@ +const fs = require("fs"); +const yaml = require("js-yaml"); + +module.exports = async ({ github, context }) => { + const actor = context.payload.comment.user.login; + const prNumber = context.issue.number; + + const { data: files } = await github.rest.pulls.listFiles({ + ...context.repo, + pull_number: prNumber, + }); + + const gapDirs = files + .map((f) => f.filename) + .map((p) => p.split("/").slice(0, 2).join("/")); + + const gapsChanged = [...new Set(gapDirs)]; + + if (gapsChanged.length !== 1 || !gapsChanged[0].match(/^gaps\/GAP-\d+$/)) { + throw new Error("You can only run /merge for PRs that touch exactly one GAP directory and nothing else."); + } + + const metadata = yaml.load(fs.readFileSync(`${gapsChanged[0]}/metadata.yml`, "utf8")); + const authorizedMergers = new Set([ + ...metadata.authors.map(author => author.githubUsername.replace(/^@/, '')), + metadata.sponsor.replace(/^@/, ''), + ]); + + if (!authorizedMergers.has(actor)) { + throw new Error(`${actor} is not authorized to merge ${gapsChanged[0]}.`); + } +};