diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td new file mode 100644 index 000000000..846508d12 --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,16 @@ +# Vouched contributors for this project. +# +# See https://github.com/mitchellh/vouch for details. +# +# Syntax: +# - One handle per line (without @), sorted alphabetically. +# - Optional platform prefix: platform:username (e.g., github:user). +# - Denounce with minus prefix: -username or -platform:username. +# - Optional details after a space following the handle. +DIYgod +HenryQW +hyoban +NeverBehave +pseudoyu +TonyRL +zhenlonghe diff --git a/.github/workflows/issue-command.yml b/.github/workflows/issue-command.yml index cd19cd68c..9fbd56ad9 100644 --- a/.github/workflows/issue-command.yml +++ b/.github/workflows/issue-command.yml @@ -36,6 +36,29 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} trigger: '/wip' + vouch-manage: + name: Vouch Manage + if: ${{ startsWith(github.event.comment.body, 'vouch') || startsWith(github.event.comment.body, 'unvouch') || startsWith(github.event.comment.body, 'denounce') }} + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + contents: write + issues: write + pull-requests: read + concurrency: + group: vouch-manage + cancel-in-progress: false + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + with: + issue-id: ${{ github.event.issue.number }} + comment-id: ${{ github.event.comment.id }} + roles: admin,maintain,write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + test-on-demand: name: Test route on demand if: startsWith(github.event.comment.body, '/test') diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7deb51f0c..7e7f3994e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -68,3 +68,23 @@ jobs: - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} + + vouch-check-pr: + name: Vouch check PR + if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.repository == 'DIYgod/RSSHub' }} + permissions: + contents: read + issues: write + pull-requests: write + runs-on: ubuntu-slim + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check if PR author is denounced + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { default: checkPr } = await import('${{ github.workspace }}/scripts/workflow/vouch/check-pr.mjs') + await checkPr({ github, context, core }) diff --git a/package.json b/package.json index 8617306eb..9f28dd2ab 100644 --- a/package.json +++ b/package.json @@ -206,13 +206,17 @@ "yaml-eslint-parser": "2.0.0" }, "lint-staged": { - "!(*.ts|*.tsx|*.js|*.yml)": [ + "!(*.ts|*.tsx|*.js|*.cjs|*.mjs|*.yml)": [ "oxfmt --no-error-on-unmatched-pattern" ], - "*.{ts,tsx,js,yml}": [ + "*.{ts,tsx,js,cjs,mjs}": [ "oxlint --type-aware --fix", "eslint --cache --fix --concurrency auto", "oxfmt --no-error-on-unmatched-pattern" + ], + "*.yml": [ + "eslint --cache --fix --concurrency auto", + "oxfmt --no-error-on-unmatched-pattern" ] }, "engines": { diff --git a/scripts/workflow/vouch/check-pr.mjs b/scripts/workflow/vouch/check-pr.mjs new file mode 100644 index 000000000..254e8b200 --- /dev/null +++ b/scripts/workflow/vouch/check-pr.mjs @@ -0,0 +1,115 @@ +/** + * @param {{ github: ReturnType, context: typeof import('@actions/github').context, core: typeof import('@actions/core') }} githubScript + * @returns {Promise} + */ +export default async function checkPr({ github, context, core }) { + const author = context.payload.pull_request.user.login; + const prNumber = context.payload.pull_request.number; + + // Skip bots + if (author.endsWith('[bot]')) { + core.info(`Skipping bot: ${author}`); + return; + } + + // Read the VOUCHED.td file via API (no checkout needed) + let content; + try { + const response = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/VOUCHED.td', + }); + content = Buffer.from(response.data.content, 'base64').toString('utf-8'); + } catch (error) { + if (error.status === 404) { + core.info('No .github/VOUCHED.td file found, skipping check.'); + return; + } + throw error; + } + + // Parse the .td file for vouched and denounced users + const vouched = new Set(); + const denounced = new Map(); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const isDenounced = trimmed.startsWith('-'); + const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; + if (!rest) { + continue; + } + + const spaceIdx = rest.indexOf(' '); + const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); + const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); + + // Handle platform:username or bare username + // Only match bare usernames or github: prefix (skip other platforms) + const colonIdx = handle.indexOf(':'); + if (colonIdx !== -1) { + const platform = handle.slice(0, colonIdx).toLowerCase(); + if (platform !== 'github') { + continue; + } + } + const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); + if (!username) { + continue; + } + + if (isDenounced) { + denounced.set(username.toLowerCase(), reason); + continue; + } + + vouched.add(username.toLowerCase()); + } + + // Check if the author is denounced + const reason = denounced.get(author.toLowerCase()); + if (reason !== undefined) { + // Author is denounced — close the PR + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: 'This pull request has been automatically closed.', + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed', + }); + + core.info(`Closed PR #${prNumber} from denounced user ${author}`); + return; + } + + // Author is positively vouched — add label + if (!vouched.has(author.toLowerCase())) { + core.info(`User ${author} is not denounced or vouched. Allowing PR.`); + return; + } + + const association = context.payload.pull_request.author_association; + if (association === 'OWNER' || association === 'MEMBER' || association === 'COLLABORATOR') { + core.info(`Skipping vouched label for collaborator ${author} (${association}).`); + return; + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['vouched'], + }); + + core.info(`Added vouched label to PR #${prNumber} from ${author}`); +}