summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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.`);