Skip to content
Open
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
106 changes: 106 additions & 0 deletions .github/workflows/contributor-labels.yml
Original file line number Diff line number Diff line change
@@ -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}`);
138 changes: 138 additions & 0 deletions .github/workflows/triage-external-prs.yml
Original file line number Diff line number Diff line change
@@ -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/<NUMBER>
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}.`);
Loading