diff --git a/.github/workflows/contributor-labels.yml b/.github/workflows/contributor-labels.yml new file mode 100644 index 0000000000..e0cfe24b0c --- /dev/null +++ b/.github/workflows/contributor-labels.yml @@ -0,0 +1,106 @@ +name: Contributor Labels + +# Assigns a single contributor:* label to the PR author based on how many +# pull requests they have merged into this repository. +# +# contributor:first-time -> 0 merged PRs (this is their first PR) +# contributor:new -> 1..10 merged PRs +# contributor:regular -> 11..30 merged PRs +# contributor:established -> more than 30 merged PRs +# +# Runs when a PR is opened/reopened (to label newcomers) and when a PR is +# merged (to promote the author to the next tier). + +on: + pull_request_target: + types: [opened, reopened, closed] + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: contributor-labels-${{ github.event.pull_request.user.login }} + cancel-in-progress: false + +jobs: + label-contributor: + runs-on: ubuntu-latest + # On "closed" only proceed if the PR was actually merged. + if: github.event.action != 'closed' || github.event.pull_request.merged == true + steps: + - name: Assign contributor tier label + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const author = context.payload.pull_request.user.login; + const prNumber = context.payload.pull_request.number; + + // Bots don't get contributor tiers. + if (context.payload.pull_request.user.type === 'Bot') { + core.info(`Skipping bot author: ${author}`); + return; + } + + // Definition of the contributor tier labels (created if missing). + const LABELS = { + 'contributor:first-time': { color: '0E8A16', description: 'First pull request to this repository' }, + 'contributor:new': { color: '5319E7', description: '1-10 merged pull requests' }, + 'contributor:regular': { color: '1D76DB', description: '11-30 merged pull requests' }, + 'contributor:established': { color: 'B60205', description: 'More than 30 merged pull requests' }, + }; + + // Count merged PRs authored by this user in this repo. + const q = `repo:${owner}/${repo} type:pr author:${author} is:merged`; + const search = await github.rest.search.issuesAndPullRequests({ q, per_page: 1 }); + const mergedCount = search.data.total_count; + core.info(`${author} has ${mergedCount} merged PR(s) in ${owner}/${repo}`); + + // Pick the tier. + let target; + if (mergedCount <= 0) { + target = 'contributor:first-time'; + } else if (mergedCount <= 10) { + target = 'contributor:new'; + } else if (mergedCount <= 30) { + target = 'contributor:regular'; + } else { + target = 'contributor:established'; + } + core.info(`Target label: ${target}`); + + // Ensure the target label exists in the repo. + const spec = LABELS[target]; + try { + await github.rest.issues.getLabel({ owner, repo, name: target }); + } catch (err) { + if (err.status === 404) { + core.info(`Creating missing label: ${target}`); + await github.rest.issues.createLabel({ + owner, repo, name: target, color: spec.color, description: spec.description, + }); + } else { + throw err; + } + } + + // Remove any other contributor:* labels currently on the PR. + const current = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: prNumber, + }); + for (const label of current.data) { + if (label.name.startsWith('contributor:') && label.name !== target) { + core.info(`Removing stale label: ${label.name}`); + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: label.name, + }); + } + } + + // Add the target label (no-op if already present). + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [target], + }); + core.info(`Applied ${target} to PR #${prNumber}`); diff --git a/.github/workflows/triage-external-prs.yml b/.github/workflows/triage-external-prs.yml new file mode 100644 index 0000000000..b3735f5ad9 --- /dev/null +++ b/.github/workflows/triage-external-prs.yml @@ -0,0 +1,138 @@ +name: Triage External PRs + +# Adds pull requests opened by NON-collaborators (people who are not invited to +# the repository as collaborators/maintainers and are not org members/owners) +# to the "triage" project and sets their status to "new". +# +# Requires a token with access to GitHub Projects (v2). The default GITHUB_TOKEN +# cannot manage org/user projects, so provide a PAT in the PROJECTS_TOKEN secret +# (classic PAT with `project` + `repo` scopes, or a fine-grained token with +# read/write access to Projects and Pull requests). + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: triage-external-pr-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + add-external-pr-to-triage: + runs-on: ubuntu-latest + steps: + - name: Add external PR to triage project + uses: actions/github-script@v7 + env: + # Login of the org or user that OWNS the project (not necessarily the repo owner). + PROJECT_OWNER: ${{ github.repository_owner }} + # The project's number, visible in the project URL: .../projects/ + PROJECT_NUMBER: "7" + # Single-select field and option to set on the added item (matched case-insensitively). + STATUS_FIELD_NAME: "Status" + STATUS_VALUE: "new" + with: + github-token: ${{ secrets.PROJECTS_TOKEN }} + script: | + const author = context.payload.pull_request.user.login; + const association = context.payload.pull_request.author_association; + + // Internal people: repo owner, org member, or invited collaborator. + const INTERNAL = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + if (INTERNAL.has(association)) { + core.info(`${author} is ${association}; skipping triage.`); + return; + } + core.info(`${author} is external (${association}); adding PR to triage project.`); + + const projectOwner = process.env.PROJECT_OWNER; + const projectNumber = parseInt(process.env.PROJECT_NUMBER, 10); + const statusFieldName = process.env.STATUS_FIELD_NAME; + const statusValue = process.env.STATUS_VALUE; + const contentId = context.payload.pull_request.node_id; + + // Look up the project (try organization first, then user) and its fields. + const projectQuery = ` + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + user(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + }`; + + const result = await github.graphql(projectQuery, { + owner: projectOwner, + number: projectNumber, + }); + + const project = result.organization?.projectV2 || result.user?.projectV2; + if (!project) { + core.setFailed(`Project #${projectNumber} not found for owner "${projectOwner}".`); + return; + } + + // Add the PR to the project (idempotent: returns existing item if already added). + const addRes = await github.graphql(` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + }`, { projectId: project.id, contentId }); + + const itemId = addRes.addProjectV2ItemById.item.id; + core.info(`PR added to project as item ${itemId}.`); + + // Resolve the status field and its "new" option (both case-insensitive) and set it. + const field = (project.fields?.nodes || []).find( + f => f && f.name && f.name.toLowerCase() === statusFieldName.toLowerCase() + ); + if (!field || !field.options) { + core.warning(`Single-select field "${statusFieldName}" not found on project; item added without status.`); + return; + } + const option = field.options.find( + o => o.name.toLowerCase() === statusValue.toLowerCase() + ); + if (!option) { + core.warning(`Option "${statusValue}" not found on field "${statusFieldName}"; item added without status.`); + return; + } + + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + }`, { + projectId: project.id, + itemId, + fieldId: field.id, + optionId: option.id, + }); + + core.info(`Set ${statusFieldName}="${statusValue}" on item ${itemId}.`);