summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhathach <[email protected]>2026-06-04 12:16:14 +0700
committerhathach <[email protected]>2026-06-04 12:16:14 +0700
commit5e3a56a38731e21a46c9df121530644745d55685 (patch)
treede23e5ba5abc9cdc6675194696d42cbf3b686290
parent824a2d6d8567deb163ecae79bf7cccfa59159c6a (diff)
ci: add Sponsor Triage board sync workflow
Cron (6h) + manual workflow that adds open issues opened by GitHub sponsors (public and private) and Adafruit org members across the adafruit org and the maintainer's repos to the private Sponsor Triage project board, setting Tier and Visibility. Logs counts only to avoid leaking private sponsor logins. Needs the SPONSOR_TOKEN PAT secret. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
-rw-r--r--.github/workflows/sponsor-triage.yml164
1 files changed, 164 insertions, 0 deletions
diff --git a/.github/workflows/sponsor-triage.yml b/.github/workflows/sponsor-triage.yml
new file mode 100644
index 000000000..19c1a6105
--- /dev/null
+++ b/.github/workflows/sponsor-triage.yml
@@ -0,0 +1,164 @@
+name: Sponsor Triage
+
+# Periodically add open issues 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 in the search scopes ---
+ const found = new Map(); // contentId -> { tier, visibility }
+ for (const s of sponsors.values()) {
+ for (const scope of SEARCH_SCOPES) {
+ const q = `${scope} is:open is:issue author:${s.login}`;
+ try {
+ const res = await github.graphql(`
+ query($q: String!) {
+ search(query: $q, type: ISSUE, first: 100) {
+ nodes { ... on Issue { 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.`);