summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHa Thach <[email protected]>2026-06-04 13:44:30 +0700
committerGitHub <[email protected]>2026-06-04 13:44:30 +0700
commitec95d9a577ee9fe2e86a07ab4555ca51abe1d1e9 (patch)
treef65d6db87720ff93d6fe8f9697d3274c7e174469
parent35d67a28403fc12970aa39866ff1c24ab41698e6 (diff)
parent10e57012078f68752699e896478bcf3d99dc391c (diff)
Merge pull request #3670 from hathach/update-labeler
ci: sponsor/Adafruit labeling + Sponsor Triage board sync
-rw-r--r--.github/workflows/labeler.yml135
-rw-r--r--.github/workflows/sponsor-triage.yml165
2 files changed, 277 insertions, 23 deletions
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
index c3cc59d0d..1fdd24bf8 100644
--- a/.github/workflows/labeler.yml
+++ b/.github/workflows/labeler.yml
@@ -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');
+ }
+ }
+
+ // 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);
}
- } catch (error) {
- console.log('Not an Adafruit member');
}
- // Check if a contributor
- if (label == '') {
+ // 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
+ });
+ }
}
diff --git a/.github/workflows/sponsor-triage.yml b/.github/workflows/sponsor-triage.yml
new file mode 100644
index 000000000..728503532
--- /dev/null
+++ b/.github/workflows/sponsor-triage.yml
@@ -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.`);