From b3b3853db0c988e074c88296be476dbba8982d2c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 27 Mar 2026 17:52:34 -0400 Subject: [PATCH] chore: automate CCS backlog project sync --- .../workflows/sync-ccs-backlog-project.yml | 36 ++++ CLAUDE.md | 104 ++++++++++ scripts/github/ccs-backlog-sync.mjs | 192 ++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 .github/workflows/sync-ccs-backlog-project.yml create mode 100644 scripts/github/ccs-backlog-sync.mjs diff --git a/.github/workflows/sync-ccs-backlog-project.yml b/.github/workflows/sync-ccs-backlog-project.yml new file mode 100644 index 00000000..14a57da6 --- /dev/null +++ b/.github/workflows/sync-ccs-backlog-project.yml @@ -0,0 +1,36 @@ +name: Sync CCS Backlog Project + +on: + issues: + types: + - opened + - reopened + - closed + - labeled + - unlabeled + workflow_dispatch: + schedule: + - cron: '17 3 * * *' + +permissions: + contents: read + issues: read + +jobs: + sync-project: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Sync CCS Backlog project + env: + GH_TOKEN: ${{ secrets.CCS_PROJECT_AUTOMATION_TOKEN }} + CCS_PROJECT_OWNER: kaitranntt + CCS_PROJECT_NUMBER: '3' + run: node scripts/github/ccs-backlog-sync.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 6cdab761..7a98ed6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,110 @@ CLI wrapper for instant switching between multiple provider accounts and alterna | Forgetting `--help` update | CLI docs out of sync | Update `src/commands/help-command.ts` | | Forgetting docs update | User docs out of sync | Update `docs/` and CCS docs submodule | +## GitHub Issue Operations (CCS-Specific) + +These rules apply when the task is issue triage, backlog cleanup, labels, comments, Projects, or milestones for this repo. + +### Scope Boundary + +- Treat issue triage as a **GitHub-only workflow** unless the user explicitly asks for implementation. +- Do **NOT** create a worktree, branch, PR, or run `/fix`, `/cook`, or `kai:maintainer` just to tag issues, post follow-up comments, close duplicates, or clean up backlog state. +- Escalate into code workflow only when: + - the user explicitly asks to fix/implement an issue, or + - triage proves the same task now requires code changes. + +### Read Before Mutating + +- Always inspect live issue state first with `gh issue view --json ...` or `gh api`. +- Never rely on stale memory, screenshots, or issue titles alone. +- Before closing as resolved, cross-check repo evidence in at least one of: + - `README.md` + - `docs/` + - `CHANGELOG.md` + - relevant source/help handlers +- If the `gh` query would touch Projects fields, verify token scope first. Missing `read:project` is a real blocker, not something to hand-wave around. + +### Labeling Standard + +- Every **open** issue should end triage with: + - one primary type label: `bug`, `enhancement`, `question`, `documentation`, `duplicate`, `invalid`, or `wontfix` + - one area label: + - `area:cli-runtime` + - `area:dashboard-ui` + - `area:config-auth` + - `area:provider-integration` + - `area:install-packaging` + - `area:documentation` + - `area:contributor-workflow` +- Add routing labels only when they materially change handling: + - `upstream-blocked` + - `needs-repro` + - `needs-split` + - `docs-gap` +- Use release-state labels for shipped work: + - `pending-release` + - `released-dev` + - `released` +- Do **NOT** create or use status labels like `todo`, `doing`, `blocked`, `done`. +- Do **NOT** create provider-name labels unless there is a proven long-term need. Provider names belong in titles/issues, not label spam. + +### Commenting Rules + +- Keep issue comments short, technical, and neutral. +- State the decision plainly: close, keep open, retag, needs repro, duplicate, blocked upstream. +- Include exact evidence when relevant: version, doc path, changelog release, canonical issue, upstream link. +- Do **NOT** reference internal plans, local report files, agent prompts, or private reasoning. +- Post **one** maintainer follow-up comment per triage pass. If accidental duplicates are created, delete them with `gh api repos///issues/comments/ -X DELETE`. + +### Closure Rules + +- Close immediately when: + - the issue is an obvious duplicate and you can point to the canonical issue + - the feature/fix is clearly shipped and documented + - a previously `pending-release` issue is now clearly past release and no longer needs tracking +- Keep open and retag when: + - upstream dependency still blocks CCS adoption -> `upstream-blocked` + - latest-release behavior is unclear -> `needs-repro` + - issue contains multiple independent asks -> `needs-split` + - feature likely exists but discoverability/docs are weak -> `docs-gap` +- Do **NOT** close just because an issue is old, vague, or inconvenient. Close only with evidence. + +### Projects And Milestones + +- Preferred project model for this repo: one project, `CCS Backlog`. +- Use Projects for workflow state and priority. Use labels for meaning and routing. +- Milestones are for real ship windows only, not generic categorization buckets. +- If `gh` token lacks `read:project`, say so explicitly and stop short of pretending Projects data is available. +- Active project: + - owner: `kaitranntt` + - number: `3` + - URL: `https://github.com/users/kaitranntt/projects/3` +- Active project fields: + - `Status` -> use for work state (`Todo`, `In Progress`, `Done`) + - `Priority` -> `P1` for bugs, `P2` default backlog, `P3` for broad `needs-split` buckets unless explicitly reprioritized + - `Follow-up` -> `Ready`, `Needs repro`, `Blocked upstream`, `Needs split`, `Docs follow-up` + - `Next review` -> date only for issues that need a follow-up checkpoint +- When triaging an open issue, make sure it exists in `CCS Backlog` and the project fields match the routing labels. +- Do **NOT** create a second backlog project unless the user explicitly wants a project split and gives a reason. +- Current automation path: + - workflow file: `.github/workflows/sync-ccs-backlog-project.yml` + - sync script: `scripts/github/ccs-backlog-sync.mjs` + - required Actions secret: `CCS_PROJECT_AUTOMATION_TOKEN` +- Automation mapping must stay aligned with labels: + - `upstream-blocked` -> `Follow-up=Blocked upstream` + - `needs-repro` -> `Follow-up=Needs repro` + - `needs-split` -> `Follow-up=Needs split` + - `docs-gap` -> `Follow-up=Docs follow-up` + - otherwise -> `Follow-up=Ready` + +### New Or Updated Issue Creation + +- When creating issues for this repo: + - assign `@kaitranntt` + - use conventional issue titles: `bug: ...`, `feat: ...`, `docs: ...` + - keep bodies factual and technical + - avoid personal info and internal-only context + ## Quality Gates (MANDATORY) Quality gates MUST pass before pushing. **Both projects have identical workflow.** diff --git a/scripts/github/ccs-backlog-sync.mjs b/scripts/github/ccs-backlog-sync.mjs new file mode 100644 index 00000000..68cc6faf --- /dev/null +++ b/scripts/github/ccs-backlog-sync.mjs @@ -0,0 +1,192 @@ +const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; +if (!token) { + console.error('Missing GH_TOKEN or GITHUB_TOKEN'); + process.exit(1); +} +const owner = process.env.CCS_PROJECT_OWNER || 'kaitranntt'; +const projectNumber = Number(process.env.CCS_PROJECT_NUMBER || '3'); +const repoFullName = process.env.GITHUB_REPOSITORY || 'kaitranntt/ccs'; +const [repoOwner, repoName] = repoFullName.split('/'); +const PRIORITY_FOR = { bug: 'P1', default: 'P2', split: 'P3' }; +const FOLLOW_UP_FOR = { + ready: 'Ready', + repro: 'Needs repro', + upstream: 'Blocked upstream', + split: 'Needs split', + docs: 'Docs follow-up', +}; +const PROJECT_QUERY = `query($owner: String!, $number: Int!) { + user(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { nodes { __typename ... on ProjectV2Field { id name } ... on ProjectV2SingleSelectField { id name options { id name } } } } + items(first: 100) { nodes { id content { __typename ... on Issue { number id repository { nameWithOwner } } } } } + } + } +}`; +const ADD_ITEM_MUTATION = `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { item { id } } +}`; +const SET_SINGLE_SELECT_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } +}`; +const SET_DATE_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { date: $date } + }) { projectV2Item { id } } +}`; +const CLEAR_FIELD_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) { + clearProjectV2ItemFieldValue(input: {projectId: $projectId, itemId: $itemId, fieldId: $fieldId}) { projectV2Item { id } } +}`; + +function isoDate(daysFromNow) { + const now = new Date(); + now.setUTCDate(now.getUTCDate() + daysFromNow); + return now.toISOString().slice(0, 10); +} + +function classify(labels, state) { + const names = new Set(labels.map((label) => label.name)); + const priority = names.has('bug') + ? PRIORITY_FOR.bug + : names.has('needs-split') + ? PRIORITY_FOR.split + : PRIORITY_FOR.default; + + let followUp = FOLLOW_UP_FOR.ready; + let nextReview = null; + if (state === 'closed') return { priority, followUp, nextReview, status: 'Done' }; + if (names.has('upstream-blocked')) { + followUp = FOLLOW_UP_FOR.upstream; + nextReview = isoDate(7); + } else if (names.has('needs-repro')) { + followUp = FOLLOW_UP_FOR.repro; + nextReview = isoDate(14); + } else if (names.has('needs-split')) { + followUp = FOLLOW_UP_FOR.split; + nextReview = isoDate(14); + } else if (names.has('docs-gap')) { + followUp = FOLLOW_UP_FOR.docs; + nextReview = isoDate(7); + } + return { priority, followUp, nextReview, status: 'Todo' }; +} + +async function github(path, init = {}) { + const response = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(init.headers || {}), + }, + }); + if (!response.ok) { + throw new Error(`GitHub REST ${response.status}: ${await response.text()}`); + } + return response.json(); +} + +async function graphql(query, variables = {}) { + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ query, variables }), + }); + const json = await response.json(); + if (!response.ok || json.errors) { + throw new Error(`GitHub GraphQL failed: ${JSON.stringify(json.errors || json)}`); + } + return json.data; +} + +async function getProjectContext() { + const data = await graphql(PROJECT_QUERY, { owner, number: projectNumber }); + const project = data.user?.projectV2; + if (!project) throw new Error(`Project ${owner}/${projectNumber} not found`); + const fields = new Map(); + for (const node of project.fields.nodes) { + if (!node?.name) continue; + const options = new Map((node.options || []).map((opt) => [opt.name, opt.id])); + fields.set(node.name, { id: node.id, options }); + } + const itemsByNumber = new Map(); + for (const node of project.items.nodes) { + if ( + node?.content?.__typename === 'Issue' && + node.content.repository.nameWithOwner === repoFullName + ) { + itemsByNumber.set(node.content.number, node.id); + } + } + return { projectId: project.id, fields, itemsByNumber }; +} + +async function ensureProjectItem(projectId, itemsByNumber, issue) { + const existing = itemsByNumber.get(issue.number); + if (existing) return existing; + + const data = await graphql(ADD_ITEM_MUTATION, { projectId, contentId: issue.node_id }); + const itemId = data.addProjectV2ItemById.item.id; + itemsByNumber.set(issue.number, itemId); + return itemId; +} + +async function setSingleSelect(projectId, itemId, field, optionName) { + const optionId = field.options.get(optionName); + if (!optionId) throw new Error(`Missing option "${optionName}" on field ${field.id}`); + await graphql(SET_SINGLE_SELECT_MUTATION, { projectId, itemId, fieldId: field.id, optionId }); +} + +async function setDate(projectId, itemId, fieldId, date) { + if (!date) { + await graphql(CLEAR_FIELD_MUTATION, { projectId, itemId, fieldId }); + return; + } + await graphql(SET_DATE_MUTATION, { projectId, itemId, fieldId, date }); +} + +async function getTargetIssues() { + if (process.env.GITHUB_EVENT_PATH) { + const event = JSON.parse( + await import('node:fs/promises').then((fs) => + fs.readFile(process.env.GITHUB_EVENT_PATH, 'utf8') + ) + ); + if (event.issue && !event.issue.pull_request) return [event.issue]; + } + const issues = await github(`/repos/${repoOwner}/${repoName}/issues?state=open&per_page=100`); + return issues.filter((issue) => !issue.pull_request); +} + +async function main() { + const issues = await getTargetIssues(); + const { projectId, fields, itemsByNumber } = await getProjectContext(); + const statusField = fields.get('Status'); + const priorityField = fields.get('Priority'); + const followUpField = fields.get('Follow-up'); + const nextReviewField = fields.get('Next review'); + + for (const issue of issues) { + const itemId = await ensureProjectItem(projectId, itemsByNumber, issue); + const plan = classify(issue.labels || [], issue.state); + await setSingleSelect(projectId, itemId, statusField, plan.status); + await setSingleSelect(projectId, itemId, priorityField, plan.priority); + await setSingleSelect(projectId, itemId, followUpField, plan.followUp); + await setDate(projectId, itemId, nextReviewField.id, plan.nextReview); + console.log( + `synced #${issue.number}: ${plan.status} / ${plan.priority} / ${plan.followUp}${plan.nextReview ? ` / ${plan.nextReview}` : ''}` + ); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});