mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
chore: automate CCS backlog project sync
This commit is contained in:
@@ -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
|
||||
@@ -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.**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user