Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!--
Outside contributors: this project only takes pull requests for issues that a
maintainer has assigned to you. See docs/CONTRIBUTING.md, "Before You Open a
Pull Request". A pull request without an assigned issue is closed automatically.
-->

Fixes #

## Summary

## Checklist

- [ ] The issue above is assigned to me
- [ ] Tests under `tests/` cover the change
- [ ] A changelog fragment `changelog.d/<issue>.<section>.md` (required for any change under `fastapi_cachex/`)
- [ ] `uv run pre-commit run --all-files` and `uv run pytest` pass
136 changes: 136 additions & 0 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
@@ -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 = '<!-- pr-gate -->';
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/<issue>.<section>.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.');
}
24 changes: 24 additions & 0 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 #<issue>` 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
Expand Down
12 changes: 12 additions & 0 deletions i18n/zh-TW/docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 #<issue>`。

修改 `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 這個專案
Expand Down
Loading