diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a2ba389 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ + + +Fixes # + +## Summary + +## Checklist + +- [ ] The issue above is assigned to me +- [ ] Tests under `tests/` cover the change +- [ ] A changelog fragment `changelog.d/.
.md` (required for any change under `fastapi_cachex/`) +- [ ] `uv run pre-commit run --all-files` and `uv run pytest` pass diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 0000000..6298e74 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,136 @@ +name: PR gate + +# Filters pull requests from outside contributors before a maintainer reads +# them. Many arrive from accounts that open hundreds of unrequested PRs a +# month; the rules below are the ones in docs/CONTRIBUTING.md, so a PR that +# follows the guide passes and one that ignores it is closed with a comment. +# +# pull_request_target runs in the context of the base branch, with a token +# that can comment and close. That is safe only because this workflow never +# checks out or runs code from the pull request: it reads the PR through the +# API and nothing else. Keep it that way. +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] -- no PR code is checked out or run + types: [opened, reopened, edited, synchronize, labeled] + +permissions: {} + +# Serialize runs per PR, so two quick events (opened + edited) do not both +# find no earlier comment and post two. +concurrency: + group: pr-gate-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + gate: + # Maintainers, collaborators and bots (Renovate, Dependabot) are exempt. + if: >- + github.event.pull_request.user.type != 'Bot' && + !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) + runs-on: ubuntu-latest + permissions: + issues: read + pull-requests: write + + steps: + - name: Check the contribution rules + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + const author = pr.user.login.toLowerCase(); + const marker = ''; + const guide = `https://github.com/${owner}/${repo}/blob/master/docs/CONTRIBUTING.md`; + + if (pr.state !== 'open') return; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pr.number, per_page: 100, + }); + const previous = comments.find( + (c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(marker), + ); + const upsert = async (body) => { + if (previous) { + await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); + } + }; + + if (pr.labels.some((label) => label.name === 'skip-pr-gate')) { + core.info('skip-pr-gate label present; not checking.'); + if (previous) await upsert(`${marker}\nA maintainer waived this check.`); + return; + } + + // 1. The PR must close an issue that a maintainer assigned to its author. + const escaped = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const closing = new RegExp( + `\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\\s+(?:#|${escaped}#|https?://github\\.com/${escaped}/issues/)(\\d+)\\b`, + 'gi', + ); + // Capped: each reference costs an API call from the repository's shared quota. + const linked = [...new Set([...(pr.body ?? '').matchAll(closing)].map((m) => Number(m[1])))].slice(0, 10); + const blocking = []; + if (linked.length === 0) { + blocking.push('The description does not close an issue (`Fixes #123`).'); + } else { + let assigned = false; + for (const number of linked) { + if (assigned) break; + try { + const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: number }); + if (!issue.pull_request && issue.assignees.some((a) => a.login.toLowerCase() === author)) { + assigned = true; + } + } catch (error) { + if (error.status !== 404 && error.status !== 410) throw error; + } + } + if (!assigned) { + blocking.push( + `You are not assigned to ${linked.map((n) => `#${n}`).join(', ')}. ` + + 'Ask on the issue first; a maintainer assigns it to you before you open a PR.', + ); + } + } + + // 2. A change to the package needs tests and a changelog fragment. + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + const changed = files.filter((f) => f.status !== 'removed').map((f) => f.filename); + const missing = []; + if (files.some((f) => f.filename.startsWith('fastapi_cachex/'))) { + if (!changed.some((name) => name.startsWith('tests/'))) { + missing.push('The change to `fastapi_cachex/` comes without a test under `tests/`.'); + } + if (!changed.some((name) => /^changelog\.d\/\d+\.[a-z]+(\.\d+)?\.md$/.test(name))) { + missing.push('The change to `fastapi_cachex/` comes without a changelog fragment (`changelog.d/.
.md`).'); + } + } + + if (blocking.length === 0 && missing.length === 0) { + if (previous) await upsert(`${marker}\nThe contribution rules are met now. Thanks!`); + return; + } + + const list = [...blocking, ...missing].map((line) => `- ${line}`).join('\n'); + if (blocking.length > 0) { + await upsert( + `${marker}\nThanks for the pull request. This project only takes pull requests for ` + + `issues that have been assigned to the author, so I am closing this one:\n\n${list}\n\n` + + `See the [contributing guide](${guide}). Once a maintainer has assigned the issue to you, ` + + 'open a new pull request.', + ); + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' }); + core.setFailed('Closed: the pull request does not close an issue assigned to its author.'); + } else { + await upsert( + `${marker}\nThanks for the pull request. Before review it still needs:\n\n${list}\n\n` + + `See the [contributing guide](${guide}). Push the missing pieces to this branch and the check reruns.`, + ); + core.setFailed('The pull request is missing tests or a changelog fragment.'); + } diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 9b974d2..bce6254 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -16,6 +16,30 @@ Report them privately through instead. The [security policy](https://github.com/allen0099/FastAPI-CacheX/blob/master/SECURITY.md) lists the supported versions and what to include in a report. +## Before You Open a Pull Request + +Pull requests from outside contributors start from an issue: + +1. Find or open an issue for the change, and say on it that you would like to + work on it. +2. Wait until a maintainer assigns the issue to you. +3. Open the pull request with `Fixes #` in its description. + +A change under `fastapi_cachex/` also needs a test under `tests/` and a +changelog fragment (see [Pull Request Process](#pull-request-process)). + +A check, **PR gate**, enforces this for outside contributors; maintainers, +collaborators and bots such as Renovate are exempt. It closes a pull request +that does not close an issue assigned to its author. Editing a closed pull +request does not reopen it, so open a new one once the issue is assigned to +you. When only the tests or the fragment are missing, the pull request stays +open, the check fails with a comment listing what is missing, and it runs +again on every push or edit. The check asks for a fragment on every change +under `fastapi_cachex/`; when a change needs none, such as a refactor, a +maintainer waives the check with the `skip-pr-gate` label. A maintainer who +reopens a pull request the check closed adds that label first, or the check +closes it again. + ## Development Process 1. Fork the project diff --git a/i18n/zh-TW/docs/CONTRIBUTING.md b/i18n/zh-TW/docs/CONTRIBUTING.md index 23ad494..ef4663c 100644 --- a/i18n/zh-TW/docs/CONTRIBUTING.md +++ b/i18n/zh-TW/docs/CONTRIBUTING.md @@ -12,6 +12,18 @@ 請不要在公開的 issue 或 Pull Request 中回報安全性問題,而是透過 [GitHub 私下漏洞回報](https://github.com/allen0099/FastAPI-CacheX/security/advisories/new)私下回報。支援的版本以及回報應包含的內容,請見[安全性政策](https://github.com/allen0099/FastAPI-CacheX/blob/master/SECURITY.md)(英文)。 +## 開啟 Pull Request 之前 {#before-you-open-a-pull-request} + +外部貢獻者的 Pull Request 要從 issue 開始: + +1. 找到或開一個對應這項變更的 issue,並在上面留言說明你想處理它。 +2. 等維護者把該 issue 指派給你。 +3. 開啟 Pull Request,並在描述中寫上 `Fixes #`。 + +修改 `fastapi_cachex/` 底下的程式時,還需要在 `tests/` 底下加上測試,並附上 changelog 片段(見 [Pull Request 流程](#pull-request-process))。 + +名為 **PR gate** 的檢查會對外部貢獻者執行這些規則;維護者、協作者,以及 Renovate 等機器人不受此限制。Pull Request 沒有關閉一個指派給作者本人的 issue 時,它會關閉該 Pull Request。編輯已關閉的 Pull Request 不會讓它重新開啟,因此請在 issue 指派給你之後開一個新的 Pull Request。如果只缺少測試或 changelog 片段,Pull Request 會保持開啟,檢查會失敗並留言列出缺少的項目,之後每次推送或編輯都會重新檢查。只要修改了 `fastapi_cachex/` 底下的程式,這項檢查就會要求 changelog 片段;若變更不需要片段(例如重構),維護者可以加上 `skip-pr-gate` 標籤略過檢查。維護者要重新開啟被檢查關閉的 Pull Request 時,請先加上該標籤,否則檢查會再次關閉它。 + ## 開發流程 {#development-process} 1. Fork 這個專案