diff options
| -rw-r--r-- | .github/workflows/sponsor-triage.yml | 164 |
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.`); |
