chore: automate CCS backlog project sync

This commit is contained in:
Tam Nhu Tran
2026-03-27 17:52:34 -04:00
parent fcc5fad601
commit b3b3853db0
3 changed files with 332 additions and 0 deletions
@@ -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
+104
View File
@@ -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 <n> --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/<owner>/<repo>/issues/comments/<id> -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.**
+192
View File
@@ -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);
});