Merge pull request #3670 from hathach/update-labeler

ci: sponsor/Adafruit labeling + Sponsor Triage board sync
This commit is contained in:
Ha Thach
2026-06-04 13:44:30 +07:00
committed by GitHub
2 changed files with 277 additions and 23 deletions

View File

@ -5,6 +5,8 @@ on:
types: [opened]
pull_request_target:
types: [opened]
discussion:
types: [created]
jobs:
label-priority:
@ -12,15 +14,17 @@ jobs:
permissions:
issues: write
pull-requests: write
discussions: write
steps:
- name: Label New Issue or PR
- name: Label New Issue, PR or Discussion
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
let label = '';
let labels = [];
let username = '';
let issueOrPrNumber = 0;
let discussionNodeId = '';
if (context.eventName === 'issues') {
username = context.payload.issue.user.login;
@ -28,25 +32,85 @@ jobs:
} else if (context.eventName === 'pull_request_target') {
username = context.payload.pull_request.user.login;
issueOrPrNumber = context.payload.pull_request.number;
} else if (context.eventName === 'discussion') {
username = context.payload.discussion.user.login;
discussionNodeId = context.payload.discussion.node_id;
}
// Check if an Adafruit member
try {
const adafruitResponse = await github.rest.orgs.checkMembershipForUser({
org: 'adafruit',
username: username
});
// Maintainer is an Adafruit member; skip the Adafruit perks for their own
// issues/PRs and treat them as a plain contributor (Prio only).
const isOwner = username.toLowerCase() === 'hathach';
if (adafruitResponse.status === 204) {
console.log('Adafruit Member');
label = 'Prio Urgent';
// Check if an Adafruit member: Adafruit + Sponsor + top priority
if (!isOwner) {
try {
const adafruitResponse = await github.rest.orgs.checkMembershipForUser({
org: 'adafruit',
username: username
});
if (adafruitResponse.status === 204) {
console.log('Adafruit Member');
labels = ['Adafruit', 'Sponsor', 'Prio Top'];
}
} catch (error) {
console.log('Not an Adafruit member');
}
} catch (error) {
console.log('Not an Adafruit member');
}
// Check if a contributor
if (label == '') {
// Check if a public GitHub Sponsor of the repo owner.
// Word ($32) tier and up get triage priority; DWORD/QWORD ($128+) go to the top.
// Private sponsorships are not visible to GITHUB_TOKEN, so only public sponsors are detected.
if (labels.length === 0) {
try {
const result = await github.graphql(`
query($sponsorable: String!, $sponsor: String!) {
user(login: $sponsorable) {
isSponsoredBy(accountLogin: $sponsor)
sponsorshipsAsMaintainer(includePrivate: false, first: 100) {
nodes {
sponsorEntity {
... on User { login }
... on Organization { login }
}
tier { monthlyPriceInDollars }
}
}
}
}`, { sponsorable: context.repo.owner, sponsor: username });
const owner = result.user;
if (owner && owner.isSponsoredBy) {
let monthly = 0;
const nodes = (owner.sponsorshipsAsMaintainer && owner.sponsorshipsAsMaintainer.nodes) || [];
for (const node of nodes) {
const login = node.sponsorEntity && node.sponsorEntity.login;
if (login && login.toLowerCase() === username.toLowerCase()) {
monthly = (node.tier && node.tier.monthlyPriceInDollars) || 0;
break;
}
}
if (monthly >= 128) {
console.log('Sponsor (DWORD/QWORD tier)');
labels = ['Sponsor', 'Prio Top'];
} else if (monthly >= 32) {
console.log('Sponsor (Word tier)');
labels = ['Sponsor', 'Prio'];
} else {
console.log('Sponsor (below Word tier or tier not visible)');
labels = ['Sponsor'];
}
} else {
console.log('Not a public sponsor');
}
} catch (error) {
console.log('Sponsor lookup failed: ' + error.message);
}
}
// Check if a contributor: prioritized in triage queue
if (labels.length === 0) {
try {
const collaboratorResponse = await github.rest.repos.checkCollaborator({
owner: context.repo.owner,
@ -56,18 +120,43 @@ jobs:
if (collaboratorResponse.status === 204) {
console.log('Contributor');
label = 'Prio Higher';
labels = ['Prio'];
}
} catch (error) {
console.log('Not a contributor');
}
}
if (label !== '') {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueOrPrNumber,
labels: [label]
});
if (labels.length !== 0) {
if (context.eventName === 'discussion') {
// Discussions are not covered by the REST issues API; resolve the label
// names to node IDs and attach them with the GraphQL labelable mutation.
const labelIds = [];
for (const name of labels) {
const res = await github.graphql(`
query($owner: String!, $repo: String!, $name: String!) {
repository(owner: $owner, name: $repo) {
label(name: $name) { id }
}
}`, { owner: context.repo.owner, repo: context.repo.repo, name: name });
if (res.repository.label) {
labelIds.push(res.repository.label.id);
}
}
if (labelIds.length !== 0) {
await github.graphql(`
mutation($labelableId: ID!, $labelIds: [ID!]!) {
addLabelsToLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) {
clientMutationId
}
}`, { labelableId: discussionNodeId, labelIds: labelIds });
}
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueOrPrNumber,
labels: labels
});
}
}

165
.github/workflows/sponsor-triage.yml vendored Normal file
View File

@ -0,0 +1,165 @@
name: Sponsor Triage
# Periodically add open issues and PRs opened by sponsors (public and private) to the private "Sponsor Triage" project board
# Requires a PAT in secret SPONSOR_TOKEN with scopes:
# - project (write project items / fields)
# - read:org (search org issues, check Adafruit membership)
# - read:user / sponsors (read own sponsorships incl. private)
on:
schedule:
- cron: '0 */6 * * *' # every 6 hours
workflow_dispatch:
concurrency:
group: sponsor-triage
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Sync sponsor issues to project board
uses: actions/github-script@v7
with:
github-token: ${{ secrets.SPONSOR_TOKEN }}
script: |
const PROJECT_OWNER = 'hathach';
const PROJECT_NUMBER = 3;
// Where to look for sponsor-authored open issues.
const SEARCH_SCOPES = ['org:adafruit', 'user:hathach'];
const tierFromMonthly = (m) => {
if (m >= 512) return 'QWORD';
if (m >= 128) return 'DWORD';
if (m >= 32) return 'Word';
if (m >= 8) return 'Byte';
if (m >= 2) return 'Bit';
return null;
};
// --- 1. Resolve project id + field/option ids (by name, never hardcoded) ---
const proj = await github.graphql(`
query($owner: String!, $number: Int!) {
user(login: $owner) {
projectV2(number: $number) {
id
fields(first: 50) {
nodes {
... on ProjectV2SingleSelectField {
id name options { id name }
}
}
}
}
}
}`, { owner: PROJECT_OWNER, number: PROJECT_NUMBER });
const project = proj.user.projectV2;
const fieldByName = {};
for (const f of project.fields.nodes) {
if (f && f.name) {
fieldByName[f.name] = { id: f.id, options: {} };
for (const o of (f.options || [])) fieldByName[f.name].options[o.name] = o.id;
}
}
const tierField = fieldByName['Tier'];
const visField = fieldByName['Visibility'];
// --- 2. Build sponsor map: loginLower -> { tier, visibility } ---
const sponsors = new Map();
// 2a. GitHub Sponsors of the owner, including private ones.
let after = null;
for (let page = 0; page < 20; page++) {
const res = await github.graphql(`
query($owner: String!, $after: String) {
user(login: $owner) {
sponsorshipsAsMaintainer(includePrivate: true, first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
privacyLevel
tier { monthlyPriceInDollars }
sponsorEntity {
... on User { login }
... on Organization { login }
}
}
}
}
}`, { owner: PROJECT_OWNER, after });
const conn = res.user.sponsorshipsAsMaintainer;
for (const n of conn.nodes) {
const login = n.sponsorEntity && n.sponsorEntity.login;
if (!login) continue;
const tier = tierFromMonthly((n.tier && n.tier.monthlyPriceInDollars) || 0);
const visibility = n.privacyLevel === 'PRIVATE' ? 'Private' : 'Public';
sponsors.set(login.toLowerCase(), { login, tier, visibility });
}
if (!conn.pageInfo.hasNextPage) break;
after = conn.pageInfo.endCursor;
}
core.info(`Resolved ${sponsors.size} sponsor account(s).`);
if (sponsors.size === 0) return;
// 2b. Adafruit org members get the Adafruit tier regardless of $ amount.
for (const s of sponsors.values()) {
try {
const m = await github.rest.orgs.checkMembershipForUser({ org: 'adafruit', username: s.login });
if (m.status === 204) s.tier = 'Adafruit';
} catch (e) { /* not an Adafruit member */ }
}
// --- 3. Find each sponsor's open issues and PRs in the search scopes ---
// (search type ISSUE returns both issues and pull requests)
const found = new Map(); // contentId -> { tier, visibility }
for (const s of sponsors.values()) {
for (const scope of SEARCH_SCOPES) {
const q = `${scope} is:open author:${s.login}`;
try {
const res = await github.graphql(`
query($q: String!) {
search(query: $q, type: ISSUE, first: 100) {
nodes { ... on Issue { id } ... on PullRequest { id } }
}
}`, { q });
for (const node of res.search.nodes) {
if (node && node.id && !found.has(node.id)) {
found.set(node.id, { tier: s.tier, visibility: s.visibility });
}
}
} catch (e) {
core.warning(`Search failed for one scope: ${e.message}`);
}
}
}
core.info(`Found ${found.size} open sponsor issue(s) across scopes.`);
// --- 4. Add to board + set Tier / Visibility (idempotent) ---
const setField = async (itemId, field, optionName) => {
if (!field || !optionName) return;
const optId = field.options[optionName];
if (!optId) return;
await github.graphql(`
mutation($p: ID!, $i: ID!, $f: ID!, $o: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $p, itemId: $i, fieldId: $f, value: { singleSelectOptionId: $o }
}) { projectV2Item { id } }
}`, { p: project.id, i: itemId, f: field.id, o: optId });
};
let added = 0;
for (const [contentId, meta] of found) {
const res = await github.graphql(`
mutation($p: ID!, $c: ID!) {
addProjectV2ItemById(input: { projectId: $p, contentId: $c }) {
item { id }
}
}`, { p: project.id, c: contentId });
const itemId = res.addProjectV2ItemById.item.id;
await setField(itemId, tierField, meta.tier);
await setField(itemId, visField, meta.visibility);
added++;
}
core.info(`Synced ${added} item(s) to the board.`);