From 1adfa5d9f8161df093110aa90562962a0e754e02 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 10 Jun 2026 23:54:43 +0800 Subject: [PATCH] feat(ci): add Project Triage automation workflow and script --- .github/scripts/sync-project-triage.mjs | 320 ++++++++++++++++++++++++ .github/workflows/project-triage.yml | 47 ++++ 2 files changed, 367 insertions(+) create mode 100644 .github/scripts/sync-project-triage.mjs create mode 100644 .github/workflows/project-triage.yml diff --git a/.github/scripts/sync-project-triage.mjs b/.github/scripts/sync-project-triage.mjs new file mode 100644 index 000000000..1958fdf50 --- /dev/null +++ b/.github/scripts/sync-project-triage.mjs @@ -0,0 +1,320 @@ +#!/usr/bin/env node + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const DEFAULT_PROJECT_OWNER = "t8y2"; +const DEFAULT_PROJECT_NUMBER = 1; +const DEFAULT_REPO = "t8y2/dbx"; +const execFileAsync = promisify(execFile); + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith("--")) continue; + const key = arg.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + args[key] = "true"; + continue; + } + args[key] = next; + i++; + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); +const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || ""; +const projectOwner = args["project-owner"] || process.env.PROJECT_OWNER || DEFAULT_PROJECT_OWNER; +const projectNumber = Number(args["project-number"] || process.env.PROJECT_NUMBER || DEFAULT_PROJECT_NUMBER); +const repo = args.repo || process.env.GITHUB_REPOSITORY || DEFAULT_REPO; +const [repoOwner, repoName] = repo.split("/"); +const issueNumber = args["issue-number"] ? Number(args["issue-number"]) : null; +const mode = args.backfill === "true" ? "backfill" : "issue"; + +if (!token) { + throw new Error("GH_TOKEN or GITHUB_TOKEN is required"); +} + +if (!repoOwner || !repoName) { + throw new Error(`Invalid repo: ${repo}`); +} + +if (mode === "issue" && !issueNumber) { + throw new Error("--issue-number is required unless --backfill true is set"); +} + +function gqlString(value) { + return JSON.stringify(value); +} + +function gqlNumber(value) { + return Number(value); +} + +function gqlNullableString(value) { + return value == null ? "null" : JSON.stringify(value); +} + +async function graphql(query) { + const args = [ + "api", + "graphql", + "-f", + `query=${query}`, + ]; + + try { + const { stdout } = await execFileAsync("gh", args, { + env: { + ...process.env, + GH_TOKEN: token, + GITHUB_TOKEN: token, + }, + maxBuffer: 10 * 1024 * 1024, + }); + + const payload = JSON.parse(stdout); + if (payload.errors) { + throw new Error(`GraphQL request failed: ${JSON.stringify(payload.errors)}`); + } + return payload.data; + } catch (error) { + const details = error.stderr?.trim() || error.stdout?.trim() || error.message; + throw new Error(`GraphQL request failed: ${details}`); + } +} + +function triageName(issue) { + const labels = new Set(issue.labels.nodes.map((label) => label.name)); + if (labels.has("question")) return "Needs Info"; + if (issue.assignees.nodes.length > 0) return "Ready"; + return "Inbox"; +} + +async function getProjectConfig() { + const query = ` + query { + user(login: ${gqlString(projectOwner)}) { + projectV2(number: ${gqlNumber(projectNumber)}) { + id + title + fields(first: 50) { + nodes { + ... on ProjectV2Field { + id + name + dataType + } + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + } + `; + + const data = await graphql(query); + const project = data.user?.projectV2; + if (!project) { + throw new Error(`Project ${projectOwner}#${projectNumber} not found`); + } + + const triageField = project.fields.nodes.find((field) => field.name === "Triage"); + if (!triageField) { + throw new Error(`Project ${project.title} is missing a Triage field`); + } + + return { + id: project.id, + title: project.title, + triageFieldId: triageField.id, + triageOptions: Object.fromEntries(triageField.options.map((option) => [option.name, option.id])), + }; +} + +async function fetchIssue(number) { + const query = ` + query { + repository(owner: ${gqlString(repoOwner)}, name: ${gqlString(repoName)}) { + issue(number: ${gqlNumber(number)}) { + id + number + title + url + state + assignees(first: 20) { + nodes { + login + } + } + labels(first: 50) { + nodes { + name + } + } + projectItems(first: 50) { + nodes { + id + project { + id + number + title + owner { + __typename + ... on User { + login + } + ... on Organization { + login + } + } + } + fieldValueByName(name: "Triage") { + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + } + } + } + } + } + } + } + `; + + const data = await graphql(query); + const issue = data.repository?.issue; + if (!issue) { + throw new Error(`Issue #${number} not found in ${repo}`); + } + return issue; +} + +async function addItemToProject(projectId, contentId) { + const mutation = ` + mutation { + addProjectV2ItemById(input: { projectId: ${gqlString(projectId)}, contentId: ${gqlString(contentId)} }) { + item { + id + } + } + } + `; + + const data = await graphql(mutation); + + return data.addProjectV2ItemById.item.id; +} + +async function updateTriage({ projectId, itemId, fieldId, optionId }) { + const mutation = ` + mutation { + updateProjectV2ItemFieldValue( + input: { + projectId: ${gqlString(projectId)} + itemId: ${gqlString(itemId)} + fieldId: ${gqlString(fieldId)} + value: { singleSelectOptionId: ${gqlString(optionId)} } + } + ) { + projectV2Item { + id + } + } + } + `; + + await graphql(mutation); +} + +async function syncIssue(projectConfig, number) { + const issue = await fetchIssue(number); + const targetTriage = triageName(issue); + const optionId = projectConfig.triageOptions[targetTriage]; + if (!optionId) { + throw new Error(`Missing Triage option: ${targetTriage}`); + } + + let projectItem = issue.projectItems.nodes.find( + (item) => + item.project.id === projectConfig.id || + (item.project.number === projectNumber && item.project.owner?.login === projectOwner), + ); + + if (!projectItem) { + const itemId = await addItemToProject(projectConfig.id, issue.id); + projectItem = { + id: itemId, + fieldValueByName: null, + }; + console.log(`Added issue #${issue.number} to ${projectConfig.title}`); + } + + const currentOptionId = projectItem.fieldValueByName?.optionId || ""; + if (currentOptionId !== optionId) { + await updateTriage({ + projectId: projectConfig.id, + itemId: projectItem.id, + fieldId: projectConfig.triageFieldId, + optionId, + }); + console.log(`Set issue #${issue.number} triage to ${targetTriage}`); + } else { + console.log(`Issue #${issue.number} triage already ${targetTriage}`); + } +} + +async function fetchOpenIssueNumbers() { + const numbers = []; + let cursor = null; + while (true) { + const query = ` + query { + repository(owner: ${gqlString(repoOwner)}, name: ${gqlString(repoName)}) { + issues(first: 100, after: ${gqlNullableString(cursor)}, states: OPEN, orderBy: { field: CREATED_AT, direction: ASC }) { + nodes { + number + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + `; + const data = await graphql(query); + const issues = data.repository?.issues; + if (!issues) break; + numbers.push(...issues.nodes.map((issue) => issue.number)); + if (!issues.pageInfo.hasNextPage) break; + cursor = issues.pageInfo.endCursor; + } + return numbers; +} + +async function main() { + const projectConfig = await getProjectConfig(); + if (mode === "backfill") { + const numbers = await fetchOpenIssueNumbers(); + console.log(`Backfilling ${numbers.length} open issues into ${projectConfig.title}`); + for (const number of numbers) { + await syncIssue(projectConfig, number); + } + return; + } + + await syncIssue(projectConfig, issueNumber); +} + +await main(); diff --git a/.github/workflows/project-triage.yml b/.github/workflows/project-triage.yml new file mode 100644 index 000000000..7463b2122 --- /dev/null +++ b/.github/workflows/project-triage.yml @@ -0,0 +1,47 @@ +name: Project Triage + +on: + issues: + types: [opened, reopened, labeled, unlabeled, assigned, unassigned] + workflow_dispatch: + inputs: + backfill: + description: Sync all open issues into DBX Issue Triage + required: false + default: "false" + +permissions: + contents: read + issues: read + +jobs: + sync-project-triage: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.PROJECT_AUTOMATION_TOKEN }} + PROJECT_OWNER: t8y2 + PROJECT_NUMBER: "1" + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.13.0 + + - name: Validate project token + run: | + if [ -z "$GH_TOKEN" ]; then + echo "Missing PROJECT_AUTOMATION_TOKEN secret" + exit 1 + fi + + - name: Sync current issue + if: ${{ github.event_name == 'issues' }} + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: node .github/scripts/sync-project-triage.mjs --issue-number "$ISSUE_NUMBER" + + - name: Backfill open issues + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.backfill == 'true' }} + run: node .github/scripts/sync-project-triage.mjs --backfill true