From 55b1fbbc7702c2f6a2b68a966e0a559f5a975dbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:53:30 +0000 Subject: [PATCH 01/15] Initial plan From dbcb9efcb7a5ee0dd4ed899bed10f48f74f15b99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:59:52 +0000 Subject: [PATCH 02/15] Tighten git-tasks planning workflow Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/a01c1929-9198-400a-8e66-bf98867ac651 Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- README.md | 29 +++++++++--- skills/git-tasks/SKILL.md | 36 +++++++++++---- src/automation/lifecycle.js | 19 +++++--- src/commands/epic.js | 8 ++-- src/commands/sprint.js | 10 ++--- src/commands/story.js | 10 ++--- src/commands/wiki.js | 90 ++++++++++++++++++++++++++++++------- test/cli.test.js | 66 ++++++++++++++++++++++++++- 8 files changed, 216 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 2188ea7..363c631 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Labels created automatically: `epic`, `sprint`, `user-story` ### Epics ```bash -git-tasks epic create "Title" [-d ] [-p ] [--start ] [--end ] [-a ] +git-tasks epic create "Title" -d -p --start --end [-a ] git-tasks epic list [--state open|closed|all] [--short] git-tasks epic show [--comments] git-tasks epic update [--title ] [--points ] [--status open|closed] @@ -87,7 +87,7 @@ git-tasks epic update [--title ] [--points ] [--status open|cl ### Sprints ```bash -git-tasks sprint create "Title" [-e ] [-d ] [-p ] [--start ] [--end ] +git-tasks sprint create "Title" -e -d -p --start --end [-a ] git-tasks sprint list [--epic ] [--state open|closed|all] [--short] git-tasks sprint show [--comments] git-tasks sprint update [--title ] [--status open|closed] @@ -95,7 +95,7 @@ git-tasks sprint update [--title ] [--status open|closed] ### User Stories ```bash -git-tasks story create "Title" [-s ] [-e ] [-p ] [--priority low|medium|high] +git-tasks story create "Title" -s -e -d -p --priority low|medium|high [-a ] git-tasks story list [--sprint ] [--epic ] [--assignee ] [--state open|closed|all] [--short] git-tasks story show [--comments] git-tasks story update [--status open|in-progress|ready-for-review|closed] [--reviewer ] @@ -111,12 +111,14 @@ git-tasks overview [--depth 1|2|3] [--state open|closed|all] ```bash git-tasks wiki init git-tasks wiki list -git-tasks wiki show +git-tasks wiki show raw/ +git-tasks wiki show processed/ ``` ## Agent-friendly usage ```bash +git-tasks wiki init git-tasks overview --depth 2 git-tasks epic list --short git-tasks story list --short --sprint 5 @@ -124,10 +126,27 @@ git-tasks story update 42 --status in-progress git-tasks story update 42 --status ready-for-review --reviewer octocat ``` +## AI-native planning granularity + +- **Stories** are agent-sized atomic slices: independently executable work that should usually take from a few hours up to one day. +- **Sprints** are short execution windows and should usually stay within three days. +- **Epics** are the largest planning bucket and should usually stay within two weeks. +- Always keep dependencies explicit: stories belong to a sprint and epic, sprints belong to an epic, and each item should carry enough metadata to be auditable without extra chat context. + +## Wiki-first audit workflow + +- Initialize the wiki once with `git-tasks wiki init`. +- Put raw human inputs in `wiki/raw/` exactly as they arrive: meeting notes, pasted chats, TODO dumps, transcripts, or user scratch notes. +- Before decomposing work or changing the backlog, write a new timestamped file into `wiki/processed/` that captures the interpreted knowledge and intended planning change. +- Treat `wiki/processed/` as append-only. Use timestamp-prefixed filenames so entries remain ordered by arrival time and can serve as an audit trail. +- Then update epics, sprints, and stories using the processed wiki entry as the source of truth. + ## Task lifecycle automation - `story update --status in-progress` keeps the story open, updates its workflow status, and ensures a draft PR exists for the current branch. -- `story update --status ready-for-review` promotes the linked draft PR to ready for review and can request reviewers via `--reviewer` or `GIT_TASKS_REVIEWERS=user1,user2`. +- Agents should pick up stories in an isolated worktree with a named branch and attached draft PR so execution stays reviewable and self-contained. +- While work is in progress, keep the draft PR and `wiki/processed/` updated with units of work so the story history is auditable. +- `story update --status ready-for-review` promotes the linked draft PR to ready for review and now requires reviewers via `--reviewer` or `GIT_TASKS_REVIEWERS=user1,user2` (set this to the repository owner when that is your default reviewer). - `story update --status closed` closes the story and automatically closes the parent sprint and epic when all of their children are closed. - `sprint update --status closed` also cascades closure up to the parent epic when appropriate. diff --git a/skills/git-tasks/SKILL.md b/skills/git-tasks/SKILL.md index 134debd..4d68483 100644 --- a/skills/git-tasks/SKILL.md +++ b/skills/git-tasks/SKILL.md @@ -21,25 +21,39 @@ gh pr list - Ensure `gh auth status` succeeds. - Prefer `--short` output unless you need full issue bodies or comments. +- Run `git-tasks wiki init` in the repository if `wiki/` is missing. - Start with `git-tasks overview --depth 2` before drilling into individual issues. - Install the skill anywhere with `npx @atena-reply/git-tasks skill install --target all`. +## AI-native planning granularity + +- **Stories** are agent-sized atomic units: large enough for one coding agent to finish independently, but usually no more than a few hours to one day. +- **Sprints** should usually span no more than three days. +- **Epics** should usually span no more than two weeks. +- Keep dependencies explicit and current: every story should point at its sprint and epic, and every sprint should point at its epic. + ## Recommended workflow -1. Inspect the hierarchy with `git-tasks overview --depth 2`. -2. Find the right epic with `git-tasks epic list --short`. -3. Find the right sprint with `git-tasks sprint list --epic --short`. -4. Inspect or update stories with `git-tasks story list --sprint --short`. -5. Use `show` only when you need full body text or comments. +1. Initialize and use the wiki as the audit source of truth with `git-tasks wiki init`. +2. If the user gives you notes in chat, dump them first into `wiki/raw/`. +3. If the user already dropped notes into the wiki, read from `wiki/raw/` first. +4. Before decomposing work or changing epics, sprints, or stories, write a new timestamped markdown file into `wiki/processed/` summarizing the interpreted knowledge and intended PM changes. +5. Inspect the hierarchy with `git-tasks overview --depth 2`. +6. Find the right epic with `git-tasks epic list --short`. +7. Find the right sprint with `git-tasks sprint list --epic --short`. +8. Inspect or update stories with `git-tasks story list --sprint --short`. +9. Use `show` only when you need full body text or comments. + +Treat `wiki/processed/` as append-only and ordered by arrival time using timestamp-prefixed filenames. ## Core commands ### Create ```bash -git-tasks epic create "Epic title" -d "description" -p 13 -git-tasks sprint create "Sprint title" --epic --start YYYY-MM-DD --end YYYY-MM-DD -git-tasks story create "Story title" --sprint --epic -p 3 --priority high -a +git-tasks epic create "Epic title" -d "description" -p 13 --start YYYY-MM-DD --end YYYY-MM-DD +git-tasks sprint create "Sprint title" --epic -d "description" -p 8 --start YYYY-MM-DD --end YYYY-MM-DD +git-tasks story create "Story title" --sprint --epic -d "description" -p 3 --priority high -a ``` ### Inspect @@ -69,7 +83,8 @@ git-tasks story update -a ```bash git-tasks wiki init git-tasks wiki list -git-tasks wiki show +git-tasks wiki show raw/ +git-tasks wiki show processed/ ``` ## Title conventions @@ -84,3 +99,6 @@ git-tasks wiki show - Use `list --short` for low-token discovery. - Use `show --comments` only when comments matter. - Reuse returned issue numbers as stable references. +- When a story is picked up, work from an isolated worktree with a named branch and attached draft PR. +- Log meaningful units of work in both the draft PR and `wiki/processed/` so the execution trail remains auditable. +- When moving a story to `ready-for-review`, make sure the draft PR is promoted and review is requested from the repository owner or default reviewers. diff --git a/src/automation/lifecycle.js b/src/automation/lifecycle.js index a65f296..5e7ac9d 100644 --- a/src/automation/lifecycle.js +++ b/src/automation/lifecycle.js @@ -117,27 +117,32 @@ export async function applyStoryLifecycle(number, { status, reviewers = [], base throw new Error(`Issue #${number} is not a user story.`); } + const normalizedStatus = normalizeLifecycleStatus(status); + const reviewerList = normalizedStatus === 'ready-for-review' + ? mergeReviewerSources(reviewers) + : []; + if (normalizedStatus === 'ready-for-review' && !reviewerList.length) { + throw new Error('Marking a story ready-for-review requires at least one reviewer. Pass --reviewer or set GIT_TASKS_REVIEWERS.'); + } + const edit = buildLifecycleEdit(story, status); let pullRequest = null; - if (edit.state === 'open' && normalizeLifecycleStatus(status) !== 'open') { + if (edit.state === 'open' && normalizedStatus !== 'open') { pullRequest = await ensureStoryPullRequest(story, { backend, base, head }); edit.body = setMetadataField(edit.body, 'Linked PR', pullRequest.url); - if (normalizeLifecycleStatus(status) === 'ready-for-review') { + if (normalizedStatus === 'ready-for-review') { if (pullRequest.isDraft) { pullRequest = await backend.markPullRequestReady(pullRequest.number); } - const reviewerList = mergeReviewerSources(reviewers); - if (reviewerList.length) { - pullRequest = await backend.requestPullRequestReview(pullRequest.number, { reviewers: reviewerList }); - } + pullRequest = await backend.requestPullRequestReview(pullRequest.number, { reviewers: reviewerList }); } } const updatedStory = await backend.editIssue(number, edit); - const closedParents = normalizeLifecycleStatus(status) === 'closed' + const closedParents = normalizedStatus === 'closed' ? await cascadeCloseParentsFromIssue(updatedStory, 'story', { backend }) : []; diff --git a/src/commands/epic.js b/src/commands/epic.js index 95fb61a..d2ee67e 100644 --- a/src/commands/epic.js +++ b/src/commands/epic.js @@ -10,10 +10,10 @@ export function makeEpicCommand() { epic .command('create ') .description('Create a new epic') - .option('-d, --description <text>', 'Epic description') - .option('-p, --points <n>', 'Story points', '0') - .option('--start <date>', 'Start date') - .option('--end <date>', 'End date') + .requiredOption('-d, --description <text>', 'Epic description') + .requiredOption('-p, --points <n>', 'Story points') + .requiredOption('--start <date>', 'Start date') + .requiredOption('--end <date>', 'End date') .option('-a, --assignee <user>', 'Assignee username') .action(async (title, opts) => { try { diff --git a/src/commands/sprint.js b/src/commands/sprint.js index cd2fd4b..b63a6f4 100644 --- a/src/commands/sprint.js +++ b/src/commands/sprint.js @@ -10,11 +10,11 @@ export function makeSprintCommand() { sprint .command('create <title>') .description('Create a new sprint') - .option('-e, --epic <epic-number>', 'Parent epic number') - .option('-d, --description <text>', 'Sprint description') - .option('-p, --points <n>', 'Story points', '0') - .option('--start <date>', 'Start date') - .option('--end <date>', 'End date') + .requiredOption('-e, --epic <epic-number>', 'Parent epic number') + .requiredOption('-d, --description <text>', 'Sprint description') + .requiredOption('-p, --points <n>', 'Story points') + .requiredOption('--start <date>', 'Start date') + .requiredOption('--end <date>', 'End date') .option('-a, --assignee <user>', 'Assignee username') .action(async (title, opts) => { try { diff --git a/src/commands/story.js b/src/commands/story.js index fad391c..719e219 100644 --- a/src/commands/story.js +++ b/src/commands/story.js @@ -15,12 +15,12 @@ export function makeStoryCommand() { story .command('create <title>') .description('Create a new user story') - .option('-s, --sprint <sprint-number>', 'Parent sprint number') - .option('-e, --epic <epic-number>', 'Parent epic number') - .option('-d, --description <text>', 'Story description') - .option('-p, --points <n>', 'Story points', '1') + .requiredOption('-s, --sprint <sprint-number>', 'Parent sprint number') + .requiredOption('-e, --epic <epic-number>', 'Parent epic number') + .requiredOption('-d, --description <text>', 'Story description') + .requiredOption('-p, --points <n>', 'Story points') .option('-a, --assignee <user>', 'Assignee username') - .option('--priority <level>', 'Priority: low, medium, high', 'medium') + .requiredOption('--priority <level>', 'Priority: low, medium, high') .action(async (title, opts) => { try { const backend = getBackend(); diff --git a/src/commands/wiki.js b/src/commands/wiki.js index 3199505..85a7a2e 100644 --- a/src/commands/wiki.js +++ b/src/commands/wiki.js @@ -1,10 +1,12 @@ import { Command } from 'commander'; import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; -import { join } from 'path'; +import { dirname, join, relative, resolve } from 'path'; import { printSuccess, printError } from '../utils/format.js'; import chalk from 'chalk'; const WIKI_DIR = 'wiki'; +const RAW_DIR = 'raw'; +const PROCESSED_DIR = 'processed'; const WIKI_README = `# Project Wiki @@ -12,28 +14,86 @@ This wiki contains project documentation managed by git-tasks. ## Structure -- Add markdown files to this directory for project documentation. -- Use \`git-tasks wiki list\` to list files. -- Use \`git-tasks wiki show <filename>\` to view a file. +- \`wiki/raw/\` is for direct user notes, meeting dumps, transcripts, and scratch inputs. +- \`wiki/processed/\` is for AI-managed, append-only planning logs that must be created before decomposition or backlog updates. +- Timestamp processed entries so they sort by arrival time, for example \`wiki/processed/2026-04-23T09-54-02Z-story-split.md\`. +- Use \`git-tasks wiki list\` to list files recursively. +- Use \`git-tasks wiki show <filename>\` to view a file, including nested paths such as \`raw/discovery.md\`. `; +const RAW_WIKI_README = `# Raw Notes + +Use this space for unprocessed inputs from humans or external systems. + +- Drop notes exactly as they arrive. +- Keep the original wording when possible. +- AI project-management flows should read from here, then write normalized conclusions into \`wiki/processed/\`. +`; + +const PROCESSED_WIKI_README = `# Processed Audit Log + +Use this space for AI-managed, append-only planning records. + +- Write a new markdown file before decomposing work or changing epics, sprints, or stories. +- Keep entries ordered by arrival time with timestamp-prefixed filenames. +- Record the source inputs, interpretation, and resulting planning changes so audits can trace every decision. +`; + +function listMarkdownFiles(dir, prefix = '') { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + const absolutePath = join(dir, entry.name); + const relativePath = prefix ? join(prefix, entry.name) : entry.name; + if (entry.isDirectory()) { + files.push(...listMarkdownFiles(absolutePath, relativePath)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(relativePath); + } + } + return files.sort((left, right) => left.localeCompare(right)); +} + +function resolveWikiFilePath(filename) { + const wikiRoot = resolve(WIKI_DIR); + const requested = filename.endsWith('.md') ? filename : `${filename}.md`; + const filePath = resolve(wikiRoot, requested); + const relativePath = relative(wikiRoot, filePath); + if (!relativePath || relativePath.startsWith('..')) { + throw new Error('Wiki paths must stay inside the wiki/ directory.'); + } + return filePath; +} + export function makeWikiCommand() { - const wiki = new Command('wiki').description('Manage local wiki files'); + const wiki = new Command('wiki').description('Manage local wiki files for raw notes and processed audit logs'); wiki .command('init') - .description('Initialize the wiki/ directory with a README.md') + .description('Initialize wiki/ with raw note intake and processed audit-log folders') .action(() => { try { if (!existsSync(WIKI_DIR)) { mkdirSync(WIKI_DIR, { recursive: true }); } - const readmePath = join(WIKI_DIR, 'README.md'); - if (!existsSync(readmePath)) { - writeFileSync(readmePath, WIKI_README, 'utf8'); - printSuccess(`Created ${readmePath}`); - } else { + const files = [ + [join(WIKI_DIR, 'README.md'), WIKI_README], + [join(WIKI_DIR, RAW_DIR, 'README.md'), RAW_WIKI_README], + [join(WIKI_DIR, PROCESSED_DIR, 'README.md'), PROCESSED_WIKI_README], + ]; + const createdPaths = []; + for (const [filePath, contents] of files) { + mkdirSync(dirname(filePath), { recursive: true }); + if (!existsSync(filePath)) { + writeFileSync(filePath, contents, 'utf8'); + createdPaths.push(filePath); + } + } + if (!createdPaths.length) { + const readmePath = join(WIKI_DIR, 'README.md'); console.log(chalk.yellow(`Wiki already initialized at ${readmePath}`)); + } else { + printSuccess(`Created ${createdPaths.join(', ')}`); } } catch (err) { printError(err.message); @@ -42,14 +102,14 @@ export function makeWikiCommand() { wiki .command('list') - .description('List wiki files') + .description('List wiki markdown files recursively') .action(() => { try { if (!existsSync(WIKI_DIR)) { console.log(chalk.yellow('Wiki not initialized. Run: git-tasks wiki init')); return; } - const files = readdirSync(WIKI_DIR).filter(f => f.endsWith('.md')); + const files = listMarkdownFiles(WIKI_DIR); if (!files.length) { console.log(chalk.gray('No wiki files found.')); } else { @@ -62,10 +122,10 @@ export function makeWikiCommand() { wiki .command('show <filename>') - .description('Show the content of a wiki file') + .description('Show the content of a wiki file, including nested raw/ or processed/ paths') .action((filename) => { try { - const filePath = join(WIKI_DIR, filename.endsWith('.md') ? filename : `${filename}.md`); + const filePath = resolveWikiFilePath(filename); if (!existsSync(filePath)) { printError(`Wiki file not found: ${filePath}`); return; diff --git a/test/cli.test.js b/test/cli.test.js index 5428e21..8432f73 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -94,6 +94,24 @@ test('story create --help shows --sprint and --epic options', () => { assert.ok(result.stdout.includes('--priority')); }); +test('epic create requires explicit planning metadata', () => { + const result = run(['epic', 'create', 'Ship auth', '-d', 'desc', '-p', '5', '--start', '2026-04-23']); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('--end')); +}); + +test('sprint create requires a parent epic', () => { + const result = run(['sprint', 'create', 'Sprint 1', '-d', 'desc', '-p', '3', '--start', '2026-04-23', '--end', '2026-04-25']); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('--epic')); +}); + +test('story create requires sprint, epic, description, points, and priority', () => { + const result = run(['story', 'create', 'Implement login', '-s', '7', '-e', '3', '-d', 'desc', '-p', '2']); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('--priority')); +}); + test('story update --help shows lifecycle and reviewer options', () => { const result = run(['story', 'update', '--help']); assert.equal(result.status, 0); @@ -140,6 +158,9 @@ test('agent skill is packaged in the installable repo layout', () => { assert.match(skill, /^---\r?\nname: git-tasks\r?\ndescription:/); assert.ok(skill.includes('git-tasks overview --depth 2')); + assert.ok(skill.includes('few hours to one day')); + assert.ok(skill.includes('wiki/raw/')); + assert.ok(skill.includes('append-only')); assert.ok(skill.includes('allowed-tools:')); assert.ok(skill.includes('hidden: true')); }); @@ -148,10 +169,14 @@ test('wiki init creates git-tasks-branded README content', () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-')); const result = run(['wiki', 'init'], { cwd }); const readme = fs.readFileSync(join(cwd, 'wiki', 'README.md'), 'utf8'); + const rawReadme = fs.readFileSync(join(cwd, 'wiki', 'raw', 'README.md'), 'utf8'); + const processedReadme = fs.readFileSync(join(cwd, 'wiki', 'processed', 'README.md'), 'utf8'); assert.equal(result.status, 0); assert.ok(readme.includes('managed by git-tasks')); - assert.ok(readme.includes('git-tasks wiki list')); + assert.ok(readme.includes('wiki/raw/')); + assert.ok(rawReadme.includes('unprocessed inputs')); + assert.ok(processedReadme.includes('append-only')); }); test('wiki list warns with the renamed command when wiki is missing', () => { @@ -162,6 +187,29 @@ test('wiki list warns with the renamed command when wiki is missing', () => { assert.ok(result.stdout.includes('Run: git-tasks wiki init')); }); +test('wiki list shows nested raw and processed markdown files', () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-list-')); + run(['wiki', 'init'], { cwd }); + fs.writeFileSync(join(cwd, 'wiki', 'raw', 'meeting-notes.md'), '# Raw\n'); + fs.writeFileSync(join(cwd, 'wiki', 'processed', '2026-04-23T09-54-02Z-plan.md'), '# Processed\n'); + + const result = run(['wiki', 'list'], { cwd }); + + assert.equal(result.status, 0); + assert.ok(result.stdout.includes('raw/meeting-notes.md')); + assert.ok(result.stdout.includes('processed/2026-04-23T09-54-02Z-plan.md')); +}); + +test('wiki show rejects paths outside wiki/', () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-show-')); + run(['wiki', 'init'], { cwd }); + + const result = run(['wiki', 'show', '../package'], { cwd }); + + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('Wiki paths must stay inside the wiki/ directory.')); +}); + test('parseIssueTitle correctly identifies epics', async () => { const { parseIssueTitle } = await import('../src/utils/format.js'); const r = parseIssueTitle('epic: My Big Epic'); @@ -255,6 +303,20 @@ test('metadata helpers normalize lifecycle status and reviewer lists', async () assert.deepEqual(parseReviewerList(['octocat,hubot', 'octocat']), ['octocat', 'hubot']); }); +test('applyStoryLifecycle requires reviewers before ready-for-review', async () => { + const { applyStoryLifecycle } = await import('../src/automation/lifecycle.js'); + const backend = { + async viewIssue() { + return { number: 42, title: 'story(#7): Implement login', state: 'OPEN', body: '## Metadata\n- **Status:** open\n', labels: [{ name: 'user-story' }, { name: 'status:open' }] }; + }, + }; + + await assert.rejects( + applyStoryLifecycle(42, { status: 'ready-for-review', backend }), + /requires at least one reviewer/, + ); +}); + test('buildLifecycleEdit updates labels and state consistently', async () => { const { buildLifecycleEdit } = await import('../src/automation/lifecycle.js'); const issue = { @@ -353,7 +415,7 @@ process.exit(1); fs.chmodSync(ghPath, 0o755); try { - const result = run(['epic', 'create', 'Ship auth', '-d', 'Test body', '-p', '3'], { + const result = run(['epic', 'create', 'Ship auth', '-d', 'Test body', '-p', '3', '--start', '2026-01-01', '--end', '2026-01-14'], { cwd, env: { PATH: `${cwd}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}` }, }); From abce1ba5f71987c55310ffd3f0748c682cb75657 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:37:31 +0000 Subject: [PATCH 03/15] Replace wiki init with repo init Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/95059cca-d6d4-4192-b9e9-fe4f040ab859 Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- README.md | 6 ++-- bin/git-tasks.js | 2 ++ skills/git-tasks/SKILL.md | 6 ++-- src/commands/init.js | 32 +++++++++++++++++++ src/commands/wiki.js | 65 +++++++++++++++++---------------------- test/cli.test.js | 48 ++++++++++++++++++----------- 6 files changed, 99 insertions(+), 60 deletions(-) create mode 100644 src/commands/init.js diff --git a/README.md b/README.md index 363c631..87a4c5a 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ git-tasks overview [--depth 1|2|3] [--state open|closed|all] ### Wiki ```bash -git-tasks wiki init +git-tasks init git-tasks wiki list git-tasks wiki show raw/<file> git-tasks wiki show processed/<timestamped-file> @@ -118,7 +118,7 @@ git-tasks wiki show processed/<timestamped-file> ## Agent-friendly usage ```bash -git-tasks wiki init +git-tasks init git-tasks overview --depth 2 git-tasks epic list --short git-tasks story list --short --sprint 5 @@ -135,7 +135,7 @@ git-tasks story update 42 --status ready-for-review --reviewer octocat ## Wiki-first audit workflow -- Initialize the wiki once with `git-tasks wiki init`. +- Initialize the repository once with `git-tasks init`. - Put raw human inputs in `wiki/raw/` exactly as they arrive: meeting notes, pasted chats, TODO dumps, transcripts, or user scratch notes. - Before decomposing work or changing the backlog, write a new timestamped file into `wiki/processed/` that captures the interpreted knowledge and intended planning change. - Treat `wiki/processed/` as append-only. Use timestamp-prefixed filenames so entries remain ordered by arrival time and can serve as an audit trail. diff --git a/bin/git-tasks.js b/bin/git-tasks.js index d6d7c85..464a05d 100755 --- a/bin/git-tasks.js +++ b/bin/git-tasks.js @@ -7,6 +7,7 @@ import { makeSprintCommand } from '../src/commands/sprint.js'; import { makeStoryCommand } from '../src/commands/story.js'; import { makeOverviewCommand } from '../src/commands/overview.js'; import { makeSkillCommand } from '../src/commands/skill.js'; +import { makeInitCommand } from '../src/commands/init.js'; import { makeWikiCommand } from '../src/commands/wiki.js'; const require = createRequire(import.meta.url); @@ -19,6 +20,7 @@ program .description('AI-native GitHub issue planning via epics, sprints, and user stories') .version(pkg.version); +program.addCommand(makeInitCommand()); program.addCommand(makeEpicCommand()); program.addCommand(makeSprintCommand()); program.addCommand(makeStoryCommand()); diff --git a/skills/git-tasks/SKILL.md b/skills/git-tasks/SKILL.md index 4d68483..a735ecf 100644 --- a/skills/git-tasks/SKILL.md +++ b/skills/git-tasks/SKILL.md @@ -21,7 +21,7 @@ gh pr list - Ensure `gh auth status` succeeds. - Prefer `--short` output unless you need full issue bodies or comments. -- Run `git-tasks wiki init` in the repository if `wiki/` is missing. +- Run `git-tasks init` at the repository root if `wiki/` is missing. - Start with `git-tasks overview --depth 2` before drilling into individual issues. - Install the skill anywhere with `npx @atena-reply/git-tasks skill install --target all`. @@ -34,7 +34,7 @@ gh pr list ## Recommended workflow -1. Initialize and use the wiki as the audit source of truth with `git-tasks wiki init`. +1. Initialize and use the wiki as the audit source of truth with `git-tasks init`. 2. If the user gives you notes in chat, dump them first into `wiki/raw/`. 3. If the user already dropped notes into the wiki, read from `wiki/raw/` first. 4. Before decomposing work or changing epics, sprints, or stories, write a new timestamped markdown file into `wiki/processed/` summarizing the interpreted knowledge and intended PM changes. @@ -81,7 +81,7 @@ git-tasks story update <n> -a <username> ### Wiki ```bash -git-tasks wiki init +git-tasks init git-tasks wiki list git-tasks wiki show raw/<filename> git-tasks wiki show processed/<timestamped-filename> diff --git a/src/commands/init.js b/src/commands/init.js new file mode 100644 index 0000000..eda4578 --- /dev/null +++ b/src/commands/init.js @@ -0,0 +1,32 @@ +import { Command } from 'commander'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import chalk from 'chalk'; +import { printError, printSuccess } from '../utils/format.js'; +import { initializeWiki } from './wiki.js'; + +function hasGitRootMarker(rootDir = process.cwd()) { + return existsSync(join(rootDir, '.git')); +} + +export function makeInitCommand() { + return new Command('init') + .description('Initialize git-tasks in the current git repository') + .action(() => { + try { + if (!hasGitRootMarker()) { + throw new Error('git-tasks init must be run from the root of a git repository.'); + } + + const { createdPaths, wikiRoot } = initializeWiki(); + if (!createdPaths.length) { + console.log(chalk.yellow(`git-tasks already initialized at ${wikiRoot}`)); + return; + } + + printSuccess(`Initialized git-tasks at ${wikiRoot}`); + } catch (err) { + printError(err.message); + } + }); +} diff --git a/src/commands/wiki.js b/src/commands/wiki.js index 85a7a2e..cfb91f4 100644 --- a/src/commands/wiki.js +++ b/src/commands/wiki.js @@ -1,7 +1,7 @@ import { Command } from 'commander'; import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; import { dirname, join, relative, resolve } from 'path'; -import { printSuccess, printError } from '../utils/format.js'; +import { printError } from '../utils/format.js'; import chalk from 'chalk'; const WIKI_DIR = 'wiki'; @@ -54,8 +54,33 @@ function listMarkdownFiles(dir, prefix = '') { return files.sort((left, right) => left.localeCompare(right)); } -function resolveWikiFilePath(filename) { - const wikiRoot = resolve(WIKI_DIR); +function getWikiRoot(rootDir = process.cwd()) { + return resolve(rootDir, WIKI_DIR); +} + +export function initializeWiki(rootDir = process.cwd()) { + const wikiRoot = getWikiRoot(rootDir); + const files = [ + [join(wikiRoot, 'README.md'), WIKI_README], + [join(wikiRoot, RAW_DIR, 'README.md'), RAW_WIKI_README], + [join(wikiRoot, PROCESSED_DIR, 'README.md'), PROCESSED_WIKI_README], + ]; + const createdPaths = []; + + mkdirSync(wikiRoot, { recursive: true }); + for (const [filePath, contents] of files) { + mkdirSync(dirname(filePath), { recursive: true }); + if (!existsSync(filePath)) { + writeFileSync(filePath, contents, 'utf8'); + createdPaths.push(filePath); + } + } + + return { createdPaths, wikiRoot }; +} + +function resolveWikiFilePath(filename, rootDir = process.cwd()) { + const wikiRoot = getWikiRoot(rootDir); const requested = filename.endsWith('.md') ? filename : `${filename}.md`; const filePath = resolve(wikiRoot, requested); const relativePath = relative(wikiRoot, filePath); @@ -68,45 +93,13 @@ function resolveWikiFilePath(filename) { export function makeWikiCommand() { const wiki = new Command('wiki').description('Manage local wiki files for raw notes and processed audit logs'); - wiki - .command('init') - .description('Initialize wiki/ with raw note intake and processed audit-log folders') - .action(() => { - try { - if (!existsSync(WIKI_DIR)) { - mkdirSync(WIKI_DIR, { recursive: true }); - } - const files = [ - [join(WIKI_DIR, 'README.md'), WIKI_README], - [join(WIKI_DIR, RAW_DIR, 'README.md'), RAW_WIKI_README], - [join(WIKI_DIR, PROCESSED_DIR, 'README.md'), PROCESSED_WIKI_README], - ]; - const createdPaths = []; - for (const [filePath, contents] of files) { - mkdirSync(dirname(filePath), { recursive: true }); - if (!existsSync(filePath)) { - writeFileSync(filePath, contents, 'utf8'); - createdPaths.push(filePath); - } - } - if (!createdPaths.length) { - const readmePath = join(WIKI_DIR, 'README.md'); - console.log(chalk.yellow(`Wiki already initialized at ${readmePath}`)); - } else { - printSuccess(`Created ${createdPaths.join(', ')}`); - } - } catch (err) { - printError(err.message); - } - }); - wiki .command('list') .description('List wiki markdown files recursively') .action(() => { try { if (!existsSync(WIKI_DIR)) { - console.log(chalk.yellow('Wiki not initialized. Run: git-tasks wiki init')); + console.log(chalk.yellow('Wiki not initialized. Run: git-tasks init')); return; } const files = listMarkdownFiles(WIKI_DIR); diff --git a/test/cli.test.js b/test/cli.test.js index 8432f73..02b8346 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -24,6 +24,7 @@ test('shows help with --help', () => { assert.equal(result.status, 0, `Expected exit 0, got ${result.status}\n${result.stderr}`); assert.ok(result.stdout.includes('git-tasks'), 'Expected program name in help'); assert.ok(result.stdout.includes('epic'), 'Expected epic command in help'); + assert.ok(result.stdout.includes('init'), 'Expected init command in help'); assert.ok(result.stdout.includes('skill'), 'Expected skill command in help'); assert.ok(result.stdout.includes('sprint'), 'Expected sprint command in help'); assert.ok(result.stdout.includes('story'), 'Expected story command in help'); @@ -68,11 +69,34 @@ test('overview --help shows options', () => { test('wiki --help shows subcommands', () => { const result = run(['wiki', '--help']); assert.equal(result.status, 0); - assert.ok(result.stdout.includes('init')); assert.ok(result.stdout.includes('list')); assert.ok(result.stdout.includes('show')); }); +test('init creates git-tasks-branded README content at git repo root', () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-init-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + + const result = run(['init'], { cwd }); + const readme = fs.readFileSync(join(cwd, 'wiki', 'README.md'), 'utf8'); + const rawReadme = fs.readFileSync(join(cwd, 'wiki', 'raw', 'README.md'), 'utf8'); + const processedReadme = fs.readFileSync(join(cwd, 'wiki', 'processed', 'README.md'), 'utf8'); + + assert.equal(result.status, 0); + assert.ok(readme.includes('managed by git-tasks')); + assert.ok(readme.includes('wiki/raw/')); + assert.ok(rawReadme.includes('unprocessed inputs')); + assert.ok(processedReadme.includes('append-only')); +}); + +test('init fails outside a git repository root', () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-init-invalid-')); + const result = run(['init'], { cwd }); + + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('git-tasks init must be run from the root of a git repository.')); +}); + test('epic create --help shows options', () => { const result = run(['epic', 'create', '--help']); assert.equal(result.status, 0); @@ -165,31 +189,18 @@ test('agent skill is packaged in the installable repo layout', () => { assert.ok(skill.includes('hidden: true')); }); -test('wiki init creates git-tasks-branded README content', () => { - const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-')); - const result = run(['wiki', 'init'], { cwd }); - const readme = fs.readFileSync(join(cwd, 'wiki', 'README.md'), 'utf8'); - const rawReadme = fs.readFileSync(join(cwd, 'wiki', 'raw', 'README.md'), 'utf8'); - const processedReadme = fs.readFileSync(join(cwd, 'wiki', 'processed', 'README.md'), 'utf8'); - - assert.equal(result.status, 0); - assert.ok(readme.includes('managed by git-tasks')); - assert.ok(readme.includes('wiki/raw/')); - assert.ok(rawReadme.includes('unprocessed inputs')); - assert.ok(processedReadme.includes('append-only')); -}); - test('wiki list warns with the renamed command when wiki is missing', () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-list-')); const result = run(['wiki', 'list'], { cwd }); assert.equal(result.status, 0); - assert.ok(result.stdout.includes('Run: git-tasks wiki init')); + assert.ok(result.stdout.includes('Run: git-tasks init')); }); test('wiki list shows nested raw and processed markdown files', () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-list-')); - run(['wiki', 'init'], { cwd }); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + run(['init'], { cwd }); fs.writeFileSync(join(cwd, 'wiki', 'raw', 'meeting-notes.md'), '# Raw\n'); fs.writeFileSync(join(cwd, 'wiki', 'processed', '2026-04-23T09-54-02Z-plan.md'), '# Processed\n'); @@ -202,7 +213,8 @@ test('wiki list shows nested raw and processed markdown files', () => { test('wiki show rejects paths outside wiki/', () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-show-')); - run(['wiki', 'init'], { cwd }); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + run(['init'], { cwd }); const result = run(['wiki', 'show', '../package'], { cwd }); From 9604aed3e6a72cc3c5ffd986dfd878d73c30c15c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:39:14 +0000 Subject: [PATCH 04/15] Handle git worktrees in init Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/95059cca-d6d4-4192-b9e9-fe4f040ab859 Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- src/commands/init.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/commands/init.js b/src/commands/init.js index eda4578..45144e0 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -1,12 +1,21 @@ import { Command } from 'commander'; -import { existsSync } from 'fs'; -import { join } from 'path'; +import { execFileSync } from 'child_process'; +import { resolve } from 'path'; import chalk from 'chalk'; import { printError, printSuccess } from '../utils/format.js'; import { initializeWiki } from './wiki.js'; -function hasGitRootMarker(rootDir = process.cwd()) { - return existsSync(join(rootDir, '.git')); +function isGitRepositoryRoot(rootDir = process.cwd()) { + try { + const topLevel = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: rootDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return resolve(topLevel) === resolve(rootDir); + } catch { + return false; + } } export function makeInitCommand() { @@ -14,7 +23,7 @@ export function makeInitCommand() { .description('Initialize git-tasks in the current git repository') .action(() => { try { - if (!hasGitRootMarker()) { + if (!isGitRepositoryRoot()) { throw new Error('git-tasks init must be run from the root of a git repository.'); } From 235cd0d31b5c61e16c3188851fdcb1b2789b6c21 Mon Sep 17 00:00:00 2001 From: DiTo97 <fede97.minutoli@gmail.com> Date: Sat, 25 Apr 2026 18:32:43 +0200 Subject: [PATCH 05/15] Align git-tasks with knowledge-backed planning workflows Switch the shipped workflow to the inbox/knowledge model, link backlog items to knowledge docs, tighten reviewer handoff and PR context sync, and keep skill eval artifacts out of published installs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .gitattributes | 1 + .gitignore | 1 + README.md | 51 +- package.json | 1 + skills/git-tasks/SKILL.md | 79 ++- src/automation/lifecycle.js | 111 +++- src/backends/github.js | 21 +- src/backends/index.js | 6 + src/commands/epic.js | 21 +- src/commands/init.js | 57 +- src/commands/sprint.js | 21 +- src/commands/story.js | 12 +- src/commands/wiki.js | 59 +- src/utils/config.js | 75 +++ src/utils/metadata.js | 23 +- src/utils/templates.js | 15 +- test/cli.test.js | 534 ++++++++++++++++-- test/evals/eval.js | 103 ++++ test/evals/fixtures/knowledge-auth-plan.md | 34 ++ test/evals/fixtures/knowledge-index.md | 3 + .../fixtures/legacy-processed-auth-plan.md | 15 + test/evals/fixtures/raw-feature-request.md | 9 + test/evals/fixtures/raw-meeting-notes.md | 7 + test/evals/git-tasks.evals.json | 63 +++ 24 files changed, 1167 insertions(+), 155 deletions(-) create mode 100644 .gitattributes create mode 100644 src/utils/config.js create mode 100644 test/evals/eval.js create mode 100644 test/evals/fixtures/knowledge-auth-plan.md create mode 100644 test/evals/fixtures/knowledge-index.md create mode 100644 test/evals/fixtures/legacy-processed-auth-plan.md create mode 100644 test/evals/fixtures/raw-feature-request.md create mode 100644 test/evals/fixtures/raw-meeting-notes.md create mode 100644 test/evals/git-tasks.evals.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore index 3c45938..c1dcee1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ *.log .DS_Store +test/evals/git-tasks-workspace/ diff --git a/README.md b/README.md index 87a4c5a..43e7c5b 100644 --- a/README.md +++ b/README.md @@ -111,18 +111,18 @@ git-tasks overview [--depth 1|2|3] [--state open|closed|all] ```bash git-tasks init git-tasks wiki list -git-tasks wiki show raw/<file> -git-tasks wiki show processed/<timestamped-file> +git-tasks wiki show inbox/<file> +git-tasks wiki show knowledge/index ``` ## Agent-friendly usage ```bash -git-tasks init +git-tasks init --owner octocat --reviewer octocat git-tasks overview --depth 2 git-tasks epic list --short git-tasks story list --short --sprint 5 -git-tasks story update 42 --status in-progress +git-tasks story update 42 --status in-progress --knowledge wiki/knowledge/auth-plan.md git-tasks story update 42 --status ready-for-review --reviewer octocat ``` @@ -131,24 +131,53 @@ git-tasks story update 42 --status ready-for-review --reviewer octocat - **Stories** are agent-sized atomic slices: independently executable work that should usually take from a few hours up to one day. - **Sprints** are short execution windows and should usually stay within three days. - **Epics** are the largest planning bucket and should usually stay within two weeks. +- `.git-tasks/config.json` can override these defaults with repo-specific `planningHorizons` for agents to read. - Always keep dependencies explicit: stories belong to a sprint and epic, sprints belong to an epic, and each item should carry enough metadata to be auditable without extra chat context. -## Wiki-first audit workflow +## Wiki-first knowledge workflow - Initialize the repository once with `git-tasks init`. -- Put raw human inputs in `wiki/raw/` exactly as they arrive: meeting notes, pasted chats, TODO dumps, transcripts, or user scratch notes. -- Before decomposing work or changing the backlog, write a new timestamped file into `wiki/processed/` that captures the interpreted knowledge and intended planning change. -- Treat `wiki/processed/` as append-only. Use timestamp-prefixed filenames so entries remain ordered by arrival time and can serve as an audit trail. -- Then update epics, sprints, and stories using the processed wiki entry as the source of truth. +- Put inbound human or system inputs in `wiki/inbox/` exactly as they arrive: meeting notes, pasted chats, TODO dumps, transcripts, uploads, or scratch notes. +- Read `wiki/knowledge/index.md` before opening individual knowledge files so you reuse existing context instead of creating duplicate nodes. +- Keep `wiki/knowledge/` flat. The semantic kind belongs in frontmatter `type`, not in subdirectories. +- Use dash-case frontmatter keys for knowledge nodes. A practical minimum is `id`, `type`, `title`, `timestamp`, `status`, `tags`, `sources`, `issue-refs`, `neighbours`, and `supersedes`. +- Knowledge node bodies should capture the context/source, interpretation, planning changes, rationale, and consequences. +- Update or create knowledge nodes only when durable understanding changes. Then update epics, sprints, and stories from that compiled knowledge when the plan actually changed. +- When backlog items are tied to specific knowledge docs, store repo-relative `wiki/knowledge/...` paths in issue `Knowledge Links` metadata. +- Legacy `wiki/raw/` and `wiki/processed/` content may still be read for historical context, but new writes should target `wiki/inbox/` and `wiki/knowledge/`. + +## Configuration + +Run `git-tasks init --owner <user> --reviewer <user>` to create `.git-tasks/config.json` at the repository root. Commit this file so human teammates and AI agents share the same defaults. + +```json +{ + "owner": "octocat", + "defaultReviewers": ["octocat"], + "planningHorizons": { + "storyMaxDays": 1, + "sprintMaxDays": 3, + "epicMaxWeeks": 2 + } +} +``` + +- `defaultReviewers` is the repo-level fallback when `--reviewer` is not passed and `GIT_TASKS_REVIEWERS` is not set. +- `owner` is the last reviewer fallback when `defaultReviewers` is empty. +- `planningHorizons` is for AI guidance, not CLI enforcement. It lets each repo tighten or relax the default story/sprint/epic sizing without editing the shipped skill text. ## Task lifecycle automation +- New material in `wiki/inbox/` by itself does **not** create or update issues, branches, or pull requests. +- New or updated knowledge in `wiki/knowledge/` may lead to epic, sprint, or story create/update operations when the planning delta is real. - `story update --status in-progress` keeps the story open, updates its workflow status, and ensures a draft PR exists for the current branch. - Agents should pick up stories in an isolated worktree with a named branch and attached draft PR so execution stays reviewable and self-contained. -- While work is in progress, keep the draft PR and `wiki/processed/` updated with units of work so the story history is auditable. -- `story update --status ready-for-review` promotes the linked draft PR to ready for review and now requires reviewers via `--reviewer` or `GIT_TASKS_REVIEWERS=user1,user2` (set this to the repository owner when that is your default reviewer). +- Routine execution should not create new knowledge nodes unless durable understanding or planning changed. +- `story update --status ready-for-review` promotes the linked draft PR to ready for review and requires reviewers via `--reviewer`, `GIT_TASKS_REVIEWERS=user1,user2`, or the repo-level defaults in `.git-tasks/config.json`. +- When a story is tied to knowledge docs, surface those docs via issue `Knowledge Links` metadata and in the story PR body for reviewer context. - `story update --status closed` closes the story and automatically closes the parent sprint and epic when all of their children are closed. - `sprint update --status closed` also cascades closure up to the parent epic when appropriate. +- Branches and draft PRs are execution artifacts for stories, not wiki entities. ## Adding a Backend diff --git a/package.json b/package.json index e99c34a..ecce569 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "test": "node --test test/cli.test.js", "test:coverage": "node --test --experimental-test-coverage test/cli.test.js", + "test:evals": "node --test test/evals/eval.js", "pack:dry-run": "npm pack --dry-run" }, "files": [ diff --git a/skills/git-tasks/SKILL.md b/skills/git-tasks/SKILL.md index a735ecf..f2c3c90 100644 --- a/skills/git-tasks/SKILL.md +++ b/skills/git-tasks/SKILL.md @@ -1,13 +1,13 @@ --- name: git-tasks -description: AI-native project management CLI for GitHub. Use when the user wants to inspect or update the planning hierarchy (epics, sprints, user stories), drive story lifecycle transitions (start work, ready for review, close), or integrate raw inputs such as client meeting notes or feature transcripts into the existing plan — either by updating open items or creating new ones as a diff. Do not use for reading a single issue or PR without any project-management intent; prefer standard gh commands for those one-off lookups. Triggers include "what's the current sprint status", "create a story for this feature", "move this story to in-progress", "close out the sprint", "update the plan based on today's meeting notes", or any request involving epics, sprints, or story lifecycle management. +description: AI-native project management CLI for GitHub. Use when the user wants to inspect or update the planning hierarchy (epics, sprints, user stories), drive story lifecycle transitions (start work, ready for review, close), or compile inbound notes, transcripts, uploads, and feature requests into structured wiki knowledge before changing the plan. Do not use for reading a single issue or PR without project-management intent; prefer standard gh commands for those one-off lookups. Triggers include "what's the current sprint status", "create a story for this feature", "move this story to in-progress", "close out the sprint", "update the plan based on today's meeting notes", or any request involving epics, sprints, story lifecycle, or wiki-backed planning updates. allowed-tools: Bash(git-tasks:*), Bash(npx @atena-reply/git-tasks:*) hidden: true --- # git-tasks -**Use when** you are acting as an AI project manager — inspecting or updating epics, sprints, and user stories, driving story lifecycle transitions, or translating raw inputs (meeting notes, feature requests, transcripts) into structured project plan changes. +**Use when** you are acting as an AI project manager — inspecting or updating epics, sprints, and user stories, driving story lifecycle transitions, or translating inbound inputs (meeting notes, feature requests, transcripts, uploaded files) into structured project-plan changes. **Do not use** for one-off issue or PR lookups without project-management intent. For those, use standard `gh` commands: ```bash @@ -22,6 +22,10 @@ gh pr list - Ensure `gh auth status` succeeds. - Prefer `--short` output unless you need full issue bodies or comments. - Run `git-tasks init` at the repository root if `wiki/` is missing. +- If `.git-tasks/config.json` exists, read it before planning. Use `planningHorizons` as repo-specific sizing guidance and `defaultReviewers` (or `owner`) as the default review handoff target. +- If `wiki/knowledge/index.md` exists, read it before opening individual knowledge nodes. +- If the repo still has legacy `wiki/raw/` or `wiki/processed/` content, it is fine to read it for historical context, but write new material into `wiki/inbox/` and `wiki/knowledge/`. +- If the repo is being initialized for the first time, prefer `git-tasks init --owner <user> --reviewer <user>` so the repo contract is explicit from the start. - Start with `git-tasks overview --depth 2` before drilling into individual issues. - Install the skill anywhere with `npx @atena-reply/git-tasks skill install --target all`. @@ -30,30 +34,48 @@ gh pr list - **Stories** are agent-sized atomic units: large enough for one coding agent to finish independently, but usually no more than a few hours to one day. - **Sprints** should usually span no more than three days. - **Epics** should usually span no more than two weeks. +- If `.git-tasks/config.json` defines `planningHorizons`, treat those values as repo-specific overrides for the default sizing guidance above. - Keep dependencies explicit and current: every story should point at its sprint and epic, and every sprint should point at its epic. ## Recommended workflow -1. Initialize and use the wiki as the audit source of truth with `git-tasks init`. -2. If the user gives you notes in chat, dump them first into `wiki/raw/`. -3. If the user already dropped notes into the wiki, read from `wiki/raw/` first. -4. Before decomposing work or changing epics, sprints, or stories, write a new timestamped markdown file into `wiki/processed/` summarizing the interpreted knowledge and intended PM changes. -5. Inspect the hierarchy with `git-tasks overview --depth 2`. -6. Find the right epic with `git-tasks epic list --short`. -7. Find the right sprint with `git-tasks sprint list --epic <n> --short`. -8. Inspect or update stories with `git-tasks story list --sprint <n> --short`. -9. Use `show` only when you need full body text or comments. - -Treat `wiki/processed/` as append-only and ordered by arrival time using timestamp-prefixed filenames. +1. Initialize the repository once with `git-tasks init`. +2. If the user gives you notes in chat or hands you uploaded files, capture the inbound material in `wiki/inbox/` first. +3. If inbound material is already on disk, treat `wiki/inbox/` as the intake layer and preserve the source wording there. +4. Read `wiki/knowledge/index.md` before planning so you reuse or refine existing knowledge instead of creating duplicates. +5. Update or create knowledge nodes in `wiki/knowledge/` only when durable understanding changes. +6. After the knowledge layer is current, inspect and update epics, sprints, and stories. +7. Use issue `Knowledge Links` metadata whenever backlog items are tied to specific knowledge docs. +8. Use `show` only when you need full body text or comments. + +Keep `wiki/knowledge/` flat. The semantic kind belongs in frontmatter `type`, not in subdirectories. +Use dash-case frontmatter keys. A practical minimum is: +- `id` +- `type` +- `title` +- `timestamp` +- `status` +- `tags` +- `sources` +- `issue-refs` +- `neighbours` +- `supersedes` + +Keep the body focused on human-readable reasoning. Each knowledge node should normally include: +- **Context/Source:** what changed or arrived +- **Interpretation:** the durable understanding extracted from it +- **Planning changes:** the backlog changes that should follow, if any +- **Rationale:** why that decomposition or update is the right one +- **Consequences:** downstream effects or follow-up implications ## Core commands ### Create ```bash -git-tasks epic create "Epic title" -d "description" -p 13 --start YYYY-MM-DD --end YYYY-MM-DD -git-tasks sprint create "Sprint title" --epic <n> -d "description" -p 8 --start YYYY-MM-DD --end YYYY-MM-DD -git-tasks story create "Story title" --sprint <n> --epic <n> -d "description" -p 3 --priority high -a <username> +git-tasks epic create "Epic title" -d "description" -p 13 --start YYYY-MM-DD --end YYYY-MM-DD --knowledge wiki/knowledge/example.md +git-tasks sprint create "Sprint title" --epic <n> -d "description" -p 8 --start YYYY-MM-DD --end YYYY-MM-DD --knowledge wiki/knowledge/example.md +git-tasks story create "Story title" --sprint <n> --epic <n> -d "description" -p 3 --priority high -a <username> --knowledge wiki/knowledge/example.md ``` ### Inspect @@ -70,9 +92,9 @@ git-tasks story show <n> ### Update ```bash -git-tasks epic update <n> --status closed --points 21 -git-tasks sprint update <n> --status closed -git-tasks story update <n> --status in-progress +git-tasks epic update <n> --status closed --knowledge wiki/knowledge/example.md +git-tasks sprint update <n> --status closed --knowledge wiki/knowledge/example.md +git-tasks story update <n> --status in-progress --knowledge wiki/knowledge/example.md git-tasks story update <n> --status ready-for-review --reviewer octocat git-tasks story update <n> --status closed git-tasks story update <n> -a <username> @@ -83,8 +105,8 @@ git-tasks story update <n> -a <username> ```bash git-tasks init git-tasks wiki list -git-tasks wiki show raw/<filename> -git-tasks wiki show processed/<timestamped-filename> +git-tasks wiki show inbox/<filename> +git-tasks wiki show knowledge/index ``` ## Title conventions @@ -93,12 +115,17 @@ git-tasks wiki show processed/<timestamped-filename> - Sprint: `sprint(#<epic-number>): <title>` - User story: `story(#<sprint-number>): <title>` -## Output guidance +## Lifecycle boundaries and output guidance -- Use `overview` for context. -- Use `list --short` for low-token discovery. +- Read `wiki/knowledge/index.md` first, then open only the relevant knowledge files. +- New material in `wiki/inbox/` by itself does **not** justify creating or updating issues, branches, or pull requests. +- New or updated knowledge that changes the plan **may** justify epic/sprint/story create or update operations. +- Use `overview` for context and `list --short` for low-token discovery. - Use `show --comments` only when comments matter. - Reuse returned issue numbers as stable references. +- When backlog items are tied to knowledge docs, record those links in issue `Knowledge Links` metadata and mirror the issue numbers back into knowledge frontmatter `issue-refs`. - When a story is picked up, work from an isolated worktree with a named branch and attached draft PR. -- Log meaningful units of work in both the draft PR and `wiki/processed/` so the execution trail remains auditable. -- When moving a story to `ready-for-review`, make sure the draft PR is promoted and review is requested from the repository owner or default reviewers. +- Routine execution should follow the existing story lifecycle; do not create new knowledge nodes unless durable understanding or planning changed. +- When moving a story to `ready-for-review`, make sure the draft PR is promoted and review is requested from `--reviewer` when explicitly provided, otherwise `GIT_TASKS_REVIEWERS`, otherwise `.git-tasks/config.json` `defaultReviewers`, falling back to `owner`. +- Branches and draft PRs are execution artifacts for stories, not wiki entities. +- Treat worktrees, PR logs, and review handoff as required operating discipline even when your host does not automate them yet; if your agent host supports hooks, that is the right layer to enforce them. diff --git a/src/automation/lifecycle.js b/src/automation/lifecycle.js index 5e7ac9d..a77c3d6 100644 --- a/src/automation/lifecycle.js +++ b/src/automation/lifecycle.js @@ -1,11 +1,14 @@ import getBackend from '../backends/index.js'; import { parseIssueTitle } from '../utils/format.js'; +import { loadConfig } from '../utils/config.js'; import { getMetadataField, normalizeLifecycleStatus, + parseMetadataList, parsePullRequestReference, parseReviewerList, setMetadataField, + setMetadataListField, } from '../utils/metadata.js'; export const WORKFLOW_STATUS_LABELS = [ @@ -42,6 +45,14 @@ function isClosed(issue) { return String(issue.state).toUpperCase() === 'CLOSED'; } +function getKnowledgeLinks(issue) { + return parseMetadataList(getMetadataField(issue.body || '', 'Knowledge Links')); +} + +function isPullRequestNotFound(error) { + return /not found|could not resolve to a pullrequest/i.test(error?.message || ''); +} + export function getStatusLabel(status) { const normalized = normalizeLifecycleStatus(status); return normalized === 'closed' ? 'status:done' : `status:${normalized}`; @@ -61,21 +72,52 @@ export function buildLifecycleEdit(issue, status) { } function buildPullRequestBody(story) { - return `## Summary + const knowledgeLinks = getKnowledgeLinks(story); + let body = `## Summary Implements ${story.title} ## Linked story -Refs #${story.number} +Refs #${story.number}`; + + if (knowledgeLinks.length) { + body += ` + +## Knowledge context +${knowledgeLinks.map((link) => `- ${link}`).join('\n')}`; + } + + return `${body} `; } +function normalizePullRequestBody(body = '') { + return String(body).trimEnd(); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function isManagedPullRequestBody(body, story) { + const normalized = normalizePullRequestBody(body); + const titlePattern = escapeRegExp(story.title); + const numberPattern = escapeRegExp(String(story.number)); + const managedBodyPattern = new RegExp( + `^## Summary\\nImplements ${titlePattern}\\n\\n## Linked story\\nRefs #${numberPattern}(?:\\n\\n## Knowledge context\\n(?:- .+\\n?)*)?$`, + ); + + return managedBodyPattern.test(normalized); +} + async function findStoryPullRequest(story, { backend }) { const linked = parsePullRequestReference(getMetadataField(story.body || '', 'Linked PR')); if (linked?.number) { try { return await backend.viewPullRequest(linked.number); - } catch { - // fall back to search + } catch (error) { + if (!isPullRequestNotFound(error)) { + throw new Error(`Failed to load linked PR #${linked.number}: ${error.message}`); + } } } @@ -88,9 +130,23 @@ async function findStoryPullRequest(story, { backend }) { `${pullRequest.title}\n${pullRequest.body || ''}`.includes(`#${story.number}`)) || null; } +async function syncPullRequestContext(story, pullRequest, { backend }) { + const body = buildPullRequestBody(story); + if (normalizePullRequestBody(pullRequest.body) === normalizePullRequestBody(body)) { + return pullRequest; + } + if (!isManagedPullRequestBody(pullRequest.body, story)) { + return pullRequest; + } + if (typeof backend.editPullRequest !== 'function') { + throw new Error('The configured backend cannot sync pull request context because it does not implement editPullRequest().'); + } + return backend.editPullRequest(pullRequest.number, { body }); +} + export async function ensureStoryPullRequest(story, { backend = getBackend(), base, head } = {}) { const existing = await findStoryPullRequest(story, { backend }); - if (existing) return existing; + if (existing) return syncPullRequestContext(story, existing, { backend }); const branch = head || backend.getCurrentBranch(); if (!branch) { @@ -106,12 +162,34 @@ export async function ensureStoryPullRequest(story, { backend = getBackend(), ba }); } -function mergeReviewerSources(reviewers = []) { - const envReviewers = process.env.GIT_TASKS_REVIEWERS || ''; - return parseReviewerList(reviewers, envReviewers); +function mergeReviewerSources(reviewers = [], rootDir = process.cwd()) { + const explicitReviewers = parseReviewerList(reviewers); + if (explicitReviewers.length) { + return explicitReviewers; + } + + const envReviewers = parseReviewerList(process.env.GIT_TASKS_REVIEWERS || ''); + if (envReviewers.length) { + return envReviewers; + } + + const config = loadConfig(rootDir); + if (config.defaultReviewers?.length) { + return parseReviewerList(config.defaultReviewers); + } + + return config.owner ? [config.owner] : []; } -export async function applyStoryLifecycle(number, { status, reviewers = [], base, head, backend = getBackend() } = {}) { +export async function applyStoryLifecycle(number, { + status, + reviewers = [], + knowledgeLinks = [], + base, + head, + backend = getBackend(), + rootDir = process.cwd(), +} = {}) { const story = await backend.viewIssue(number); if (parseIssueTitle(story.title).type !== 'story') { throw new Error(`Issue #${number} is not a user story.`); @@ -119,17 +197,24 @@ export async function applyStoryLifecycle(number, { status, reviewers = [], base const normalizedStatus = normalizeLifecycleStatus(status); const reviewerList = normalizedStatus === 'ready-for-review' - ? mergeReviewerSources(reviewers) + ? mergeReviewerSources(reviewers, rootDir) : []; if (normalizedStatus === 'ready-for-review' && !reviewerList.length) { - throw new Error('Marking a story ready-for-review requires at least one reviewer. Pass --reviewer or set GIT_TASKS_REVIEWERS.'); + throw new Error('Marking a story ready-for-review requires at least one reviewer. Pass --reviewer, set GIT_TASKS_REVIEWERS, or configure .git-tasks/config.json.'); } - const edit = buildLifecycleEdit(story, status); + const mergedKnowledgeLinks = knowledgeLinks.length + ? parseMetadataList(getKnowledgeLinks(story), knowledgeLinks) + : []; + const storyForEdit = mergedKnowledgeLinks.length + ? { ...story, body: setMetadataListField(story.body || '', 'Knowledge Links', mergedKnowledgeLinks) } + : story; + + const edit = buildLifecycleEdit(storyForEdit, status); let pullRequest = null; if (edit.state === 'open' && normalizedStatus !== 'open') { - pullRequest = await ensureStoryPullRequest(story, { backend, base, head }); + pullRequest = await ensureStoryPullRequest(storyForEdit, { backend, base, head }); edit.body = setMetadataField(edit.body, 'Linked PR', pullRequest.url); if (normalizedStatus === 'ready-for-review') { diff --git a/src/backends/github.js b/src/backends/github.js index 4d8a69a..b66ce38 100644 --- a/src/backends/github.js +++ b/src/backends/github.js @@ -40,16 +40,7 @@ function extractIssueNumberFromOutput(output) { * Ensure a label exists in the repo; create it if missing. */ function ensureLabel(name, color, description) { - try { - runGh(['label', 'list', '--json', 'name', '--jq', `.[].name`]); - } catch { - // ignore - } - try { - runGh(['label', 'create', name, '--color', color, '--description', description, '--force']); - } catch { - // label may already exist - } + runGh(['label', 'create', name, '--color', color, '--description', description, '--force']); } const LABEL_CONFIG = { @@ -160,6 +151,15 @@ export function createPullRequest({ title, body, base, head, draft = false }) { return viewPullRequest(match[1]); } +export function editPullRequest(number, { title, body, base } = {}) { + const args = ['pr', 'edit', String(number)]; + if (title) args.push('--title', title); + if (body !== undefined) args.push('--body', body); + if (base) args.push('--base', base); + runGh(args); + return viewPullRequest(number); +} + export function markPullRequestReady(number) { runGh(['pr', 'ready', String(number)]); return viewPullRequest(number); @@ -184,6 +184,7 @@ const githubBackend = { listPullRequests, viewPullRequest, createPullRequest, + editPullRequest, markPullRequestReady, requestPullRequestReview, }; diff --git a/src/backends/index.js b/src/backends/index.js index 173150e..f577e0a 100644 --- a/src/backends/index.js +++ b/src/backends/index.js @@ -6,6 +6,12 @@ * listIssues({ labels, state, limit }) → Issue[] * viewIssue(number, { comments }) → Issue * editIssue(number, { title, body, addLabels, removeLabels, addAssignees, state }) → Issue + * listPullRequests({ state, base, head, search }) → PullRequest[] + * viewPullRequest(number) → PullRequest + * createPullRequest({ title, body, base, head, draft }) → PullRequest + * editPullRequest(number, { title, body, base }) → PullRequest + * markPullRequestReady(number) → PullRequest + * requestPullRequestReview(number, { reviewers }) → PullRequest */ import githubBackend from './github.js'; diff --git a/src/commands/epic.js b/src/commands/epic.js index d2ee67e..1015baa 100644 --- a/src/commands/epic.js +++ b/src/commands/epic.js @@ -3,6 +3,11 @@ import getBackend from '../backends/index.js'; import { buildLifecycleEdit } from '../automation/lifecycle.js'; import { epicTemplate } from '../utils/templates.js'; import { formatIssueList, formatIssueDetail, printSuccess, printError } from '../utils/format.js'; +import { getMetadataField, parseMetadataList, setMetadataListField } from '../utils/metadata.js'; + +function collectValues(value, previous = []) { + return previous.concat(value); +} export function makeEpicCommand() { const epic = new Command('epic').description('Manage epics'); @@ -15,6 +20,7 @@ export function makeEpicCommand() { .requiredOption('--start <date>', 'Start date') .requiredOption('--end <date>', 'End date') .option('-a, --assignee <user>', 'Assignee username') + .option('-k, --knowledge <path>', 'Linked knowledge document path', collectValues, []) .action(async (title, opts) => { try { const backend = getBackend(); @@ -24,6 +30,7 @@ export function makeEpicCommand() { start: opts.start || '', end: opts.end || '', owner: opts.assignee || '', + knowledgeLinks: parseMetadataList(opts.knowledge), }); const issue = await backend.createIssue({ title: `epic: ${title}`, @@ -73,13 +80,25 @@ export function makeEpicCommand() { .option('--points <n>', 'Story points') .option('--status <state>', 'open or closed') .option('--add-blocker <issue-number>', 'Add a blocking issue reference') + .option('-k, --knowledge <path>', 'Linked knowledge document path', collectValues, []) .action(async (number, opts) => { try { const backend = getBackend(); const currentIssue = await backend.viewIssue(number); const editOpts = {}; + let nextBody = currentIssue.body; + if (opts.title) editOpts.title = `epic: ${opts.title}`; - if (opts.status) Object.assign(editOpts, buildLifecycleEdit(currentIssue, opts.status)); + if (opts.status) { + const lifecycleEdit = buildLifecycleEdit(currentIssue, opts.status); + Object.assign(editOpts, lifecycleEdit); + nextBody = lifecycleEdit.body; + } + if (opts.knowledge.length) { + const knowledgeLinks = parseMetadataList(getMetadataField(nextBody, 'Knowledge Links'), opts.knowledge); + editOpts.body = setMetadataListField(nextBody, 'Knowledge Links', knowledgeLinks); + } + const issue = await backend.editIssue(number, editOpts); printSuccess(`Updated epic #${issue.number}`); } catch (err) { diff --git a/src/commands/init.js b/src/commands/init.js index 45144e0..7c5d50b 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -1,39 +1,64 @@ +import { existsSync, realpathSync } from 'fs'; import { Command } from 'commander'; -import { execFileSync } from 'child_process'; import { resolve } from 'path'; import chalk from 'chalk'; import { printError, printSuccess } from '../utils/format.js'; +import { parseReviewerList } from '../utils/metadata.js'; +import { getConfigPath, loadConfig, resolveRepositoryRoot, saveConfig } from '../utils/config.js'; import { initializeWiki } from './wiki.js'; +function collectValues(value, previous = []) { + return previous.concat(value); +} + function isGitRepositoryRoot(rootDir = process.cwd()) { - try { - const topLevel = execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: rootDir, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - return resolve(topLevel) === resolve(rootDir); - } catch { - return false; - } + const topLevel = resolveRepositoryRoot(rootDir); + if (!topLevel) return false; + + const realpath = realpathSync.native || realpathSync; + return realpath(resolve(topLevel)) === realpath(resolve(rootDir)); } export function makeInitCommand() { return new Command('init') .description('Initialize git-tasks in the current git repository') - .action(() => { + .option('--owner <user>', 'Repository owner or default reviewer username') + .option('-r, --reviewer <user>', 'Default reviewer username', collectValues, []) + .action((opts) => { try { - if (!isGitRepositoryRoot()) { + const rootDir = process.cwd(); + if (!isGitRepositoryRoot(rootDir)) { throw new Error('git-tasks init must be run from the root of a git repository.'); } - const { createdPaths, wikiRoot } = initializeWiki(); - if (!createdPaths.length) { + const { createdPaths, wikiRoot } = initializeWiki(rootDir); + const configPath = getConfigPath(rootDir); + const configExists = existsSync(configPath); + const current = loadConfig(rootDir); + const next = { + ...current, + owner: opts.owner ?? current.owner, + defaultReviewers: opts.reviewer.length + ? parseReviewerList(current.defaultReviewers, opts.reviewer) + : current.defaultReviewers, + }; + const configChanged = !configExists || JSON.stringify(next) !== JSON.stringify(current); + + if (configChanged) { + saveConfig(next, rootDir); + } + + if (!createdPaths.length && !configChanged) { console.log(chalk.yellow(`git-tasks already initialized at ${wikiRoot}`)); return; } - printSuccess(`Initialized git-tasks at ${wikiRoot}`); + if (createdPaths.length || !configExists) { + printSuccess(`Initialized git-tasks at ${wikiRoot}`); + return; + } + + printSuccess(`Updated git-tasks config at ${configPath}`); } catch (err) { printError(err.message); } diff --git a/src/commands/sprint.js b/src/commands/sprint.js index b63a6f4..e0889eb 100644 --- a/src/commands/sprint.js +++ b/src/commands/sprint.js @@ -3,6 +3,11 @@ import getBackend from '../backends/index.js'; import { buildLifecycleEdit, cascadeCloseParentsFromIssue } from '../automation/lifecycle.js'; import { sprintTemplate } from '../utils/templates.js'; import { formatIssueList, formatIssueDetail, printSuccess, printError } from '../utils/format.js'; +import { getMetadataField, parseMetadataList, setMetadataListField } from '../utils/metadata.js'; + +function collectValues(value, previous = []) { + return previous.concat(value); +} export function makeSprintCommand() { const sprint = new Command('sprint').description('Manage sprints'); @@ -16,10 +21,10 @@ export function makeSprintCommand() { .requiredOption('--start <date>', 'Start date') .requiredOption('--end <date>', 'End date') .option('-a, --assignee <user>', 'Assignee username') + .option('-k, --knowledge <path>', 'Linked knowledge document path', collectValues, []) .action(async (title, opts) => { try { const backend = getBackend(); - const epicRef = opts.epic ? `#${opts.epic}` : ''; const body = sprintTemplate({ description: opts.description, epicNumber: opts.epic || '', @@ -27,6 +32,7 @@ export function makeSprintCommand() { start: opts.start || '', end: opts.end || '', owner: opts.assignee || '', + knowledgeLinks: parseMetadataList(opts.knowledge), }); const prefix = opts.epic ? `sprint(#${opts.epic})` : 'sprint'; const issue = await backend.createIssue({ @@ -82,16 +88,27 @@ export function makeSprintCommand() { .option('--epic <epic-number>', 'Re-assign to epic') .option('--points <n>', 'Story points') .option('--status <state>', 'open or closed') + .option('-k, --knowledge <path>', 'Linked knowledge document path', collectValues, []) .action(async (number, opts) => { try { const backend = getBackend(); const currentIssue = await backend.viewIssue(number); const editOpts = {}; + let nextBody = currentIssue.body; if (opts.title) { const epicPart = opts.epic ? `(#${opts.epic})` : ''; editOpts.title = `sprint${epicPart}: ${opts.title}`; } - if (opts.status) Object.assign(editOpts, buildLifecycleEdit(currentIssue, opts.status)); + if (opts.status) { + const lifecycleEdit = buildLifecycleEdit(currentIssue, opts.status); + Object.assign(editOpts, lifecycleEdit); + nextBody = lifecycleEdit.body; + } + if (opts.knowledge.length) { + const knowledgeLinks = parseMetadataList(getMetadataField(nextBody, 'Knowledge Links'), opts.knowledge); + editOpts.body = setMetadataListField(nextBody, 'Knowledge Links', knowledgeLinks); + } + const issue = await backend.editIssue(number, editOpts); if (opts.status === 'closed') { await cascadeCloseParentsFromIssue(issue, 'sprint', { backend }); diff --git a/src/commands/story.js b/src/commands/story.js index 719e219..893bd61 100644 --- a/src/commands/story.js +++ b/src/commands/story.js @@ -1,7 +1,7 @@ import { Command } from 'commander'; import getBackend from '../backends/index.js'; import { applyStoryLifecycle } from '../automation/lifecycle.js'; -import { parseReviewerList } from '../utils/metadata.js'; +import { getMetadataField, parseMetadataList, parseReviewerList, setMetadataListField } from '../utils/metadata.js'; import { storyTemplate } from '../utils/templates.js'; import { formatIssueList, formatIssueDetail, printSuccess, printError } from '../utils/format.js'; @@ -21,6 +21,7 @@ export function makeStoryCommand() { .requiredOption('-p, --points <n>', 'Story points') .option('-a, --assignee <user>', 'Assignee username') .requiredOption('--priority <level>', 'Priority: low, medium, high') + .option('-k, --knowledge <path>', 'Linked knowledge document path', collectValues, []) .action(async (title, opts) => { try { const backend = getBackend(); @@ -31,6 +32,7 @@ export function makeStoryCommand() { points: opts.points, assignee: opts.assignee || '', priority: opts.priority, + knowledgeLinks: parseMetadataList(opts.knowledge), }); const sprintRef = opts.sprint ? `#${opts.sprint}` : ''; const prefix = sprintRef ? `story(${sprintRef})` : 'story'; @@ -99,10 +101,12 @@ export function makeStoryCommand() { .option('--base <branch>', 'Base branch to use when creating a lifecycle pull request') .option('--head <branch>', 'Head branch to use when creating a lifecycle pull request') .option('-r, --reviewer <user>', 'Reviewer to request when marking ready-for-review', collectValues, []) + .option('-k, --knowledge <path>', 'Linked knowledge document path', collectValues, []) .action(async (number, opts) => { try { const backend = getBackend(); const editOpts = {}; + const requestedKnowledgeLinks = parseMetadataList(opts.knowledge); if (opts.title) { const sprintPart = opts.sprint ? `(#${opts.sprint})` : ''; editOpts.title = `story${sprintPart}: ${opts.title}`; @@ -113,6 +117,7 @@ export function makeStoryCommand() { ({ issue } = await applyStoryLifecycle(number, { status: opts.status, reviewers: parseReviewerList(opts.reviewer), + knowledgeLinks: requestedKnowledgeLinks, base: opts.base, head: opts.head, backend, @@ -121,6 +126,11 @@ export function makeStoryCommand() { issue = await backend.editIssue(number, editOpts); } } else { + const currentIssue = await backend.viewIssue(number); + if (requestedKnowledgeLinks.length) { + const knowledgeLinks = parseMetadataList(getMetadataField(currentIssue.body, 'Knowledge Links'), requestedKnowledgeLinks); + editOpts.body = setMetadataListField(currentIssue.body, 'Knowledge Links', knowledgeLinks); + } issue = await backend.editIssue(number, editOpts); } printSuccess(`Updated user story #${issue.number}`); diff --git a/src/commands/wiki.js b/src/commands/wiki.js index cfb91f4..dd1c31b 100644 --- a/src/commands/wiki.js +++ b/src/commands/wiki.js @@ -5,48 +5,60 @@ import { printError } from '../utils/format.js'; import chalk from 'chalk'; const WIKI_DIR = 'wiki'; -const RAW_DIR = 'raw'; -const PROCESSED_DIR = 'processed'; +const INBOX_DIR = 'inbox'; +const KNOWLEDGE_DIR = 'knowledge'; const WIKI_README = `# Project Wiki -This wiki contains project documentation managed by git-tasks. +This wiki contains project knowledge managed by git-tasks. ## Structure -- \`wiki/raw/\` is for direct user notes, meeting dumps, transcripts, and scratch inputs. -- \`wiki/processed/\` is for AI-managed, append-only planning logs that must be created before decomposition or backlog updates. -- Timestamp processed entries so they sort by arrival time, for example \`wiki/processed/2026-04-23T09-54-02Z-story-split.md\`. +- \`wiki/inbox/\` is for direct human or system inputs such as meeting notes, transcripts, pasted chats, and scratch notes. +- \`wiki/knowledge/\` is for structured knowledge nodes whose semantic type lives in frontmatter. +- \`wiki/knowledge/index.md\` is the append-only encyclopedia index that AI agents should scan first before opening individual knowledge files. +- Keep legacy \`wiki/raw/\` and \`wiki/processed/\` folders readable if they already exist, but write new material into \`wiki/inbox/\` and \`wiki/knowledge/\`. - Use \`git-tasks wiki list\` to list files recursively. -- Use \`git-tasks wiki show <filename>\` to view a file, including nested paths such as \`raw/discovery.md\`. +- Use \`git-tasks wiki show <filename>\` to view a file, including nested paths such as \`inbox/discovery.md\` or \`knowledge/index.md\`. `; -const RAW_WIKI_README = `# Raw Notes +const INBOX_WIKI_README = `# Inbox -Use this space for unprocessed inputs from humans or external systems. +Use this space for unmodified incoming material from humans or external systems. - Drop notes exactly as they arrive. -- Keep the original wording when possible. -- AI project-management flows should read from here, then write normalized conclusions into \`wiki/processed/\`. +- Preserve original wording when possible. +- Inbox entries alone should not trigger issue, branch, or pull-request changes until the knowledge has been compiled into \`wiki/knowledge/\`. `; -const PROCESSED_WIKI_README = `# Processed Audit Log +const KNOWLEDGE_WIKI_README = `# Knowledge -Use this space for AI-managed, append-only planning records. +Use this space for durable knowledge nodes managed by AI and humans. -- Write a new markdown file before decomposing work or changing epics, sprints, or stories. -- Keep entries ordered by arrival time with timestamp-prefixed filenames. -- Record the source inputs, interpretation, and resulting planning changes so audits can trace every decision. +- Keep files flat in this directory; use frontmatter \`type\` rather than subdirectories to express whether a node is a decision, plan, constraint, observation, procedure, or something else. +- Use dash-case frontmatter keys such as \`timestamp\`, \`issue-refs\`, and \`neighbours\`. +- Update or create knowledge nodes when durable understanding changes. +- Update \`index.md\` whenever knowledge changes so agents can navigate the wiki efficiently. +- Legacy \`processed/\` content may still be read, but new durable knowledge belongs here. +`; + +const KNOWLEDGE_INDEX = `# Knowledge Index + +Append new knowledge entries in arrival order using this format: + +- \`<timestamp> | <type> | [Title](file.md) — short description\` + +Agents should scan this index first, then open only the relevant knowledge nodes. `; function listMarkdownFiles(dir, prefix = '') { const files = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name.startsWith('.')) continue; - const absolutePath = join(dir, entry.name); - const relativePath = prefix ? join(prefix, entry.name) : entry.name; + const entryPath = join(dir, entry.name); + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) { - files.push(...listMarkdownFiles(absolutePath, relativePath)); + files.push(...listMarkdownFiles(entryPath, relativePath)); } else if (entry.isFile() && entry.name.endsWith('.md')) { files.push(relativePath); } @@ -62,8 +74,9 @@ export function initializeWiki(rootDir = process.cwd()) { const wikiRoot = getWikiRoot(rootDir); const files = [ [join(wikiRoot, 'README.md'), WIKI_README], - [join(wikiRoot, RAW_DIR, 'README.md'), RAW_WIKI_README], - [join(wikiRoot, PROCESSED_DIR, 'README.md'), PROCESSED_WIKI_README], + [join(wikiRoot, INBOX_DIR, 'README.md'), INBOX_WIKI_README], + [join(wikiRoot, KNOWLEDGE_DIR, 'README.md'), KNOWLEDGE_WIKI_README], + [join(wikiRoot, KNOWLEDGE_DIR, 'index.md'), KNOWLEDGE_INDEX], ]; const createdPaths = []; @@ -91,7 +104,7 @@ function resolveWikiFilePath(filename, rootDir = process.cwd()) { } export function makeWikiCommand() { - const wiki = new Command('wiki').description('Manage local wiki files for raw notes and processed audit logs'); + const wiki = new Command('wiki').description('Manage local wiki files for inbox inputs and structured knowledge'); wiki .command('list') @@ -115,7 +128,7 @@ export function makeWikiCommand() { wiki .command('show <filename>') - .description('Show the content of a wiki file, including nested raw/ or processed/ paths') + .description('Show the content of a wiki file, including nested inbox/ or knowledge/ paths') .action((filename) => { try { const filePath = resolveWikiFilePath(filename); diff --git a/src/utils/config.js b/src/utils/config.js new file mode 100644 index 0000000..aea5b91 --- /dev/null +++ b/src/utils/config.js @@ -0,0 +1,75 @@ +import { execFileSync } from 'child_process'; +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { parseReviewerList } from './metadata.js'; + +export const DEFAULT_PLANNING_HORIZONS = Object.freeze({ + storyMaxDays: 1, + sprintMaxDays: 3, + epicMaxWeeks: 2, +}); + +function normalizePlanningHorizons(planningHorizons = {}) { + return { + ...DEFAULT_PLANNING_HORIZONS, + ...(planningHorizons || {}), + }; +} + +export function normalizeConfig(config = {}) { + return { + owner: String(config.owner || '').trim(), + defaultReviewers: parseReviewerList(config.defaultReviewers || []), + planningHorizons: normalizePlanningHorizons(config.planningHorizons), + }; +} + +function resolveRealPath(targetPath) { + const realpath = realpathSync.native || realpathSync; + return realpath(resolve(targetPath)); +} + +export function resolveRepositoryRoot(rootDir = process.cwd()) { + try { + const topLevel = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: rootDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + return resolveRealPath(topLevel); + } catch (error) { + if (error?.code === 'ENOENT') { + throw new Error('git is not installed or not found in PATH.'); + } + if (typeof error?.status === 'number') { + return null; + } + throw error; + } +} + +export function getConfigPath(rootDir = process.cwd()) { + const repoRoot = resolveRepositoryRoot(rootDir) || resolve(rootDir); + return resolve(repoRoot, '.git-tasks', 'config.json'); +} + +export function loadConfig(rootDir = process.cwd()) { + const configPath = getConfigPath(rootDir); + if (!existsSync(configPath)) { + return normalizeConfig(); + } + + try { + return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf8'))); + } catch (error) { + throw new Error(`Unable to parse git-tasks config at ${configPath}: ${error.message}`); + } +} + +export function saveConfig(config = {}, rootDir = process.cwd()) { + const configPath = getConfigPath(rootDir); + const normalized = normalizeConfig(config); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8'); + return { config: normalized, configPath }; +} diff --git a/src/utils/metadata.js b/src/utils/metadata.js index 8d06d99..fcccbd7 100644 --- a/src/utils/metadata.js +++ b/src/utils/metadata.js @@ -2,6 +2,14 @@ function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +function parseDelimitedList(...values) { + return [...new Set(values + .flat() + .flatMap((value) => value == null ? [] : String(value).split(',')) + .map((value) => value.trim()) + .filter(Boolean))]; +} + export function getMetadataField(body = '', field) { const pattern = new RegExp(`^- \\*\\*${escapeRegex(field)}:\\*\\*\\s*(.*)$`, 'mi'); const match = body.match(pattern); @@ -24,6 +32,11 @@ export function setMetadataField(body = '', field, value) { return safeBody.replace(/## Metadata\s*\n/i, (heading) => `${heading}${line}\n`); } +export function setMetadataListField(body = '', field, values = []) { + const normalized = parseDelimitedList(values); + return setMetadataField(body, field, normalized.join(', ')); +} + export function normalizeLifecycleStatus(status = 'open') { const normalized = String(status).trim().toLowerCase(); const aliases = new Map([ @@ -49,12 +62,12 @@ export function normalizeLifecycleStatus(status = 'open') { return aliases.get(normalized); } +export function parseMetadataList(...values) { + return parseDelimitedList(...values); +} + export function parseReviewerList(...values) { - return [...new Set(values - .flat() - .flatMap((value) => String(value).split(',')) - .map((value) => value.trim()) - .filter(Boolean))]; + return parseDelimitedList(...values); } export function parsePullRequestReference(value = '') { diff --git a/src/utils/templates.js b/src/utils/templates.js index bc9a54a..ee2ced8 100644 --- a/src/utils/templates.js +++ b/src/utils/templates.js @@ -2,7 +2,11 @@ * Issue body templates for epics, sprints, and user stories. */ -export function epicTemplate({ description = '', points = 0, start = '', end = '', owner = '' } = {}) { +function formatKnowledgeLinks(knowledgeLinks = []) { + return knowledgeLinks.join(', '); +} + +export function epicTemplate({ description = '', points = 0, start = '', end = '', owner = '', knowledgeLinks = [] } = {}) { return `## Description ${description || '<!-- Epic description -->'} @@ -18,6 +22,7 @@ ${description || '<!-- Epic description -->'} - **Start Date:** ${start} - **End Date:** ${end} - **Owner:** ${owner} +- **Knowledge Links:** ${formatKnowledgeLinks(knowledgeLinks)} ## Child Sprints <!-- Will be auto-populated --> @@ -30,7 +35,7 @@ ${description || '<!-- Epic description -->'} `; } -export function sprintTemplate({ description = '', epicNumber = '', points = 0, start = '', end = '', owner = '' } = {}) { +export function sprintTemplate({ description = '', epicNumber = '', points = 0, start = '', end = '', owner = '', knowledgeLinks = [] } = {}) { return `## Description ${description || '<!-- Sprint description -->'} @@ -47,6 +52,7 @@ ${description || '<!-- Sprint description -->'} - **End Date:** ${end} - **Epic:** ${epicNumber ? `#${epicNumber}` : ''} - **Owner:** ${owner} +- **Knowledge Links:** ${formatKnowledgeLinks(knowledgeLinks)} ## User Stories <!-- Will be auto-populated --> @@ -58,7 +64,7 @@ ${description || '<!-- Sprint description -->'} `; } -export function storyTemplate({ description = '', sprintNumber = '', epicNumber = '', points = 1, assignee = '', priority = 'medium' } = {}) { +export function storyTemplate({ description = '', sprintNumber = '', epicNumber = '', points = 1, assignee = '', priority = 'medium', knowledgeLinks = [] } = {}) { return `## Description ${description || '<!-- User story description -->'} @@ -75,7 +81,8 @@ ${description || '<!-- User story description -->'} - **Epic:** ${epicNumber ? `#${epicNumber}` : ''} - **Assignee:** ${assignee} - **Priority:** ${priority} -- **Linked PR:** +- **Knowledge Links:** ${formatKnowledgeLinks(knowledgeLinks)} +- **Linked PR:** ## Tasks - [ ] task 1 diff --git a/test/cli.test.js b/test/cli.test.js index 02b8346..bccfc12 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -73,49 +73,132 @@ test('wiki --help shows subcommands', () => { assert.ok(result.stdout.includes('show')); }); -test('init creates git-tasks-branded README content at git repo root', () => { +test('init creates git-tasks-branded inbox and knowledge wiki content at git repo root', async () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-init-')); spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); - const result = run(['init'], { cwd }); - const readme = fs.readFileSync(join(cwd, 'wiki', 'README.md'), 'utf8'); - const rawReadme = fs.readFileSync(join(cwd, 'wiki', 'raw', 'README.md'), 'utf8'); - const processedReadme = fs.readFileSync(join(cwd, 'wiki', 'processed', 'README.md'), 'utf8'); + try { + const result = run(['init'], { cwd }); + const readme = fs.readFileSync(join(cwd, 'wiki', 'README.md'), 'utf8'); + const inboxReadme = fs.readFileSync(join(cwd, 'wiki', 'inbox', 'README.md'), 'utf8'); + const knowledgeReadme = fs.readFileSync(join(cwd, 'wiki', 'knowledge', 'README.md'), 'utf8'); + const knowledgeIndex = fs.readFileSync(join(cwd, 'wiki', 'knowledge', 'index.md'), 'utf8'); + + assert.equal(result.status, 0); + assert.ok(readme.includes('managed by git-tasks')); + assert.ok(readme.includes('wiki/inbox/')); + assert.ok(readme.includes('wiki/knowledge/index.md')); + assert.ok(inboxReadme.includes('unmodified incoming material')); + assert.ok(knowledgeReadme.includes('durable knowledge nodes')); + assert.ok(knowledgeReadme.includes('dash-case frontmatter')); + assert.ok(knowledgeIndex.includes('Knowledge Index')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('init fails outside a git repository root', async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-init-invalid-')); + + try { + const result = run(['init'], { cwd }); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('git-tasks init must be run from the root of a git repository.')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('init --help shows owner and reviewer options', () => { + const result = run(['init', '--help']); assert.equal(result.status, 0); - assert.ok(readme.includes('managed by git-tasks')); - assert.ok(readme.includes('wiki/raw/')); - assert.ok(rawReadme.includes('unprocessed inputs')); - assert.ok(processedReadme.includes('append-only')); + assert.ok(result.stdout.includes('--owner')); + assert.ok(result.stdout.includes('--reviewer') || result.stdout.includes('-r')); }); -test('init fails outside a git repository root', () => { - const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-init-invalid-')); - const result = run(['init'], { cwd }); +test('init creates repo config with owner, reviewers, and planning horizons', async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-config-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); - assert.equal(result.status, 1); - assert.ok(result.stderr.includes('git-tasks init must be run from the root of a git repository.')); + try { + const result = run(['init', '--owner', 'octocat', '--reviewer', 'hubot', '--reviewer', 'octocat'], { cwd }); + const config = JSON.parse(fs.readFileSync(join(cwd, '.git-tasks', 'config.json'), 'utf8')); + + assert.equal(result.status, 0, result.stderr); + assert.equal(config.owner, 'octocat'); + assert.deepEqual(config.defaultReviewers, ['hubot', 'octocat']); + assert.deepEqual(config.planningHorizons, { + storyMaxDays: 1, + sprintMaxDays: 3, + epicMaxWeeks: 2, + }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('init keeps existing config unchanged when rerun without flags', async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-config-idempotent-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + + try { + run(['init', '--owner', 'octocat', '--reviewer', 'hubot'], { cwd }); + + const configPath = join(cwd, '.git-tasks', 'config.json'); + const before = fs.readFileSync(configPath, 'utf8'); + const result = run(['init'], { cwd }); + const after = fs.readFileSync(configPath, 'utf8'); + + assert.equal(result.status, 0, result.stderr); + assert.equal(after, before); + assert.ok(result.stdout.includes('already initialized')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } }); -test('epic create --help shows options', () => { +test('init updates existing config when new reviewer flags are passed', async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-config-update-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + + try { + run(['init', '--owner', 'octocat', '--reviewer', 'hubot'], { cwd }); + + const result = run(['init', '--reviewer', 'mona'], { cwd }); + const config = JSON.parse(fs.readFileSync(join(cwd, '.git-tasks', 'config.json'), 'utf8')); + + assert.equal(result.status, 0, result.stderr); + assert.equal(config.owner, 'octocat'); + assert.deepEqual(config.defaultReviewers, ['hubot', 'mona']); + assert.ok(result.stdout.includes('Updated git-tasks config')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('epic create --help shows planning and knowledge options', () => { const result = run(['epic', 'create', '--help']); assert.equal(result.status, 0); assert.ok(result.stdout.includes('--description') || result.stdout.includes('-d')); assert.ok(result.stdout.includes('--points') || result.stdout.includes('-p')); + assert.ok(result.stdout.includes('--knowledge') || result.stdout.includes('-k')); }); -test('sprint create --help shows --epic option', () => { +test('sprint create --help shows --epic and --knowledge options', () => { const result = run(['sprint', 'create', '--help']); assert.equal(result.status, 0); assert.ok(result.stdout.includes('--epic') || result.stdout.includes('-e')); + assert.ok(result.stdout.includes('--knowledge') || result.stdout.includes('-k')); }); -test('story create --help shows --sprint and --epic options', () => { +test('story create --help shows sprint, epic, priority, and knowledge options', () => { const result = run(['story', 'create', '--help']); assert.equal(result.status, 0); assert.ok(result.stdout.includes('--sprint') || result.stdout.includes('-s')); assert.ok(result.stdout.includes('--epic') || result.stdout.includes('-e')); assert.ok(result.stdout.includes('--priority')); + assert.ok(result.stdout.includes('--knowledge') || result.stdout.includes('-k')); }); test('epic create requires explicit planning metadata', () => { @@ -136,11 +219,12 @@ test('story create requires sprint, epic, description, points, and priority', () assert.ok(result.stderr.includes('--priority')); }); -test('story update --help shows lifecycle and reviewer options', () => { +test('story update --help shows lifecycle, reviewer, and knowledge options', () => { const result = run(['story', 'update', '--help']); assert.equal(result.status, 0); assert.ok(result.stdout.includes('ready-for-review')); assert.ok(result.stdout.includes('--reviewer') || result.stdout.includes('-r')); + assert.ok(result.stdout.includes('--knowledge') || result.stdout.includes('-k')); }); test('skill install copies canonical skill into requested targets', async () => { @@ -183,43 +267,63 @@ test('agent skill is packaged in the installable repo layout', () => { assert.match(skill, /^---\r?\nname: git-tasks\r?\ndescription:/); assert.ok(skill.includes('git-tasks overview --depth 2')); assert.ok(skill.includes('few hours to one day')); - assert.ok(skill.includes('wiki/raw/')); - assert.ok(skill.includes('append-only')); + assert.ok(skill.includes('wiki/inbox/')); + assert.ok(skill.includes('wiki/knowledge/index.md')); + assert.ok(skill.includes('Knowledge Links')); assert.ok(skill.includes('allowed-tools:')); assert.ok(skill.includes('hidden: true')); + assert.equal(fs.existsSync(join(REPO_ROOT, 'skills', 'git-tasks', 'evals', 'evals.json')), false); + assert.equal(fs.existsSync(join(REPO_ROOT, 'skills', 'git-tasks-workspace')), false); + assert.equal(fs.existsSync(join(REPO_ROOT, 'test', 'evals', 'git-tasks.evals.json')), true); }); -test('wiki list warns with the renamed command when wiki is missing', () => { +test('wiki list warns with the renamed command when wiki is missing', async () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-list-')); - const result = run(['wiki', 'list'], { cwd }); - assert.equal(result.status, 0); - assert.ok(result.stdout.includes('Run: git-tasks init')); + try { + const result = run(['wiki', 'list'], { cwd }); + + assert.equal(result.status, 0); + assert.ok(result.stdout.includes('Run: git-tasks init')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } }); -test('wiki list shows nested raw and processed markdown files', () => { +test('wiki list shows nested inbox and knowledge markdown files', async () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-list-')); spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); - run(['init'], { cwd }); - fs.writeFileSync(join(cwd, 'wiki', 'raw', 'meeting-notes.md'), '# Raw\n'); - fs.writeFileSync(join(cwd, 'wiki', 'processed', '2026-04-23T09-54-02Z-plan.md'), '# Processed\n'); - const result = run(['wiki', 'list'], { cwd }); + try { + run(['init'], { cwd }); + fs.writeFileSync(join(cwd, 'wiki', 'inbox', 'meeting-notes.md'), '# Inbox\n'); + fs.writeFileSync(join(cwd, 'wiki', 'knowledge', 'auth-plan.md'), '# Knowledge\n'); - assert.equal(result.status, 0); - assert.ok(result.stdout.includes('raw/meeting-notes.md')); - assert.ok(result.stdout.includes('processed/2026-04-23T09-54-02Z-plan.md')); + const result = run(['wiki', 'list'], { cwd }); + + assert.equal(result.status, 0); + assert.ok(result.stdout.includes('inbox/meeting-notes.md')); + assert.ok(result.stdout.includes('knowledge/index.md')); + assert.ok(result.stdout.includes('knowledge/auth-plan.md')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } }); -test('wiki show rejects paths outside wiki/', () => { +test('wiki show rejects paths outside wiki/', async () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-show-')); spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); - run(['init'], { cwd }); - const result = run(['wiki', 'show', '../package'], { cwd }); + try { + run(['init'], { cwd }); - assert.equal(result.status, 1); - assert.ok(result.stderr.includes('Wiki paths must stay inside the wiki/ directory.')); + const result = run(['wiki', 'show', '../package'], { cwd }); + + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('Wiki paths must stay inside the wiki/ directory.')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } }); test('parseIssueTitle correctly identifies epics', async () => { @@ -282,8 +386,10 @@ test('format helpers and templates render expected project data', async () => { assert.ok(formatIssueDetail(issue, { comments: true }).includes('Looks good')); assert.ok(formatOverview([{ ...issue, sprints: [{ number: 2, title: 'sprint(#1): Sprint 1', state: 'OPEN', stories: [{ number: 3, title: 'story(#2): Story', state: 'OPEN' }] }] }], { depth: 3 }).includes('#3')); assert.ok(epicTemplate({ description: 'Ship auth', points: 13 }).includes('Ship auth')); + assert.ok(epicTemplate({ knowledgeLinks: ['wiki/knowledge/auth-plan.md'] }).includes('Knowledge Links:** wiki/knowledge/auth-plan.md')); assert.ok(sprintTemplate({ epicNumber: 1, points: 5 }).includes('#1')); assert.ok(storyTemplate({ sprintNumber: 2, epicNumber: 1, priority: 'high' }).includes('Priority:** high')); + assert.ok(storyTemplate({ knowledgeLinks: ['wiki/knowledge/auth-plan.md'] }).includes('Knowledge Links:** wiki/knowledge/auth-plan.md')); assert.ok(storyTemplate({ sprintNumber: 2 }).includes('Linked PR')); const out = []; @@ -300,19 +406,43 @@ test('format helpers and templates render expected project data', async () => { assert.ok(out[1].includes('done')); }); -test('metadata helpers normalize lifecycle status and reviewer lists', async () => { +test('metadata helpers normalize lifecycle status, reviewers, and knowledge links', async () => { const { getMetadataField, normalizeLifecycleStatus, + parseMetadataList, parseReviewerList, setMetadataField, + setMetadataListField, } = await import('../src/utils/metadata.js'); const body = setMetadataField('## Metadata\n- **Status:** open\n', 'Linked PR', 'https://example.com/pull/12'); + const withKnowledge = setMetadataListField(body, 'Knowledge Links', ['wiki/knowledge/auth-plan.md', 'wiki/knowledge/auth-plan.md', 'wiki/knowledge/sso-plan.md']); assert.equal(getMetadataField(body, 'Linked PR'), 'https://example.com/pull/12'); + assert.equal(getMetadataField(withKnowledge, 'Knowledge Links'), 'wiki/knowledge/auth-plan.md, wiki/knowledge/sso-plan.md'); assert.equal(normalizeLifecycleStatus('running'), 'in-progress'); assert.equal(normalizeLifecycleStatus('ready'), 'ready-for-review'); assert.deepEqual(parseReviewerList(['octocat,hubot', 'octocat']), ['octocat', 'hubot']); + assert.deepEqual(parseMetadataList([null, 'wiki/knowledge/auth-plan.md, wiki/knowledge/sso-plan.md']), ['wiki/knowledge/auth-plan.md', 'wiki/knowledge/sso-plan.md']); +}); + +test('config helpers return defaults when the repo config is missing', async () => { + const { loadConfig } = await import('../src/utils/config.js'); + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-config-defaults-')); + + try { + assert.deepEqual(loadConfig(cwd), { + owner: '', + defaultReviewers: [], + planningHorizons: { + storyMaxDays: 1, + sprintMaxDays: 3, + epicMaxWeeks: 2, + }, + }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } }); test('applyStoryLifecycle requires reviewers before ready-for-review', async () => { @@ -329,6 +459,314 @@ test('applyStoryLifecycle requires reviewers before ready-for-review', async () ); }); +test('applyStoryLifecycle falls back to repo owner when config reviewers are not set', async () => { + const { applyStoryLifecycle } = await import('../src/automation/lifecycle.js'); + const { saveConfig } = await import('../src/utils/config.js'); + const rootDir = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-review-owner-')); + saveConfig({ owner: 'octocat' }, rootDir); + + try { + const story = { + number: 42, + title: 'story(#7): Implement login', + state: 'OPEN', + body: '## Metadata\n- **Status:** open\n', + labels: [{ name: 'user-story' }, { name: 'status:open' }], + }; + const basePullRequest = { + number: 88, + title: story.title, + body: 'Refs #42', + url: 'https://example.com/pull/88', + isDraft: true, + }; + let requestedReviewers = []; + let markedReady = false; + + const backend = { + async viewIssue() { + return structuredClone(story); + }, + async listPullRequests() { + return [structuredClone(basePullRequest)]; + }, + async markPullRequestReady() { + markedReady = true; + return { ...basePullRequest, isDraft: false }; + }, + async requestPullRequestReview(number, { reviewers }) { + requestedReviewers = reviewers; + return { ...basePullRequest, number, isDraft: false }; + }, + async editIssue(number, edits) { + return { + ...story, + number, + body: edits.body, + state: 'OPEN', + labels: [{ name: 'user-story' }, { name: 'status:ready-for-review' }], + }; + }, + }; + + const { pullRequest } = await applyStoryLifecycle(42, { + status: 'ready-for-review', + backend, + rootDir, + }); + + assert.equal(markedReady, true); + assert.deepEqual(requestedReviewers, ['octocat']); + assert.equal(pullRequest.number, 88); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +}); + +test('applyStoryLifecycle prefers explicit reviewers, then env reviewers, before repo config', async () => { + const { applyStoryLifecycle } = await import('../src/automation/lifecycle.js'); + const { saveConfig } = await import('../src/utils/config.js'); + const rootDir = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-review-precedence-')); + const previousEnvReviewers = process.env.GIT_TASKS_REVIEWERS; + saveConfig({ owner: 'octocat', defaultReviewers: ['config-reviewer'] }, rootDir); + process.env.GIT_TASKS_REVIEWERS = 'env-reviewer'; + + const story = { + number: 42, + title: 'story(#7): Implement login', + state: 'OPEN', + body: '## Metadata\n- **Status:** open\n', + labels: [{ name: 'user-story' }, { name: 'status:open' }], + }; + const basePullRequest = { + number: 88, + title: story.title, + body: 'Refs #42', + url: 'https://example.com/pull/88', + isDraft: true, + }; + let requestedReviewers = []; + + const backend = { + async viewIssue() { + return structuredClone(story); + }, + async listPullRequests() { + return [structuredClone(basePullRequest)]; + }, + async markPullRequestReady() { + return { ...basePullRequest, isDraft: false }; + }, + async requestPullRequestReview(number, { reviewers }) { + requestedReviewers = reviewers; + return { ...basePullRequest, number, isDraft: false }; + }, + async editIssue(number, edits) { + return { + ...story, + number, + body: edits.body, + state: 'OPEN', + labels: [{ name: 'user-story' }, { name: 'status:ready-for-review' }], + }; + }, + }; + + try { + await applyStoryLifecycle(42, { + status: 'ready-for-review', + reviewers: ['cli-reviewer'], + backend, + rootDir, + }); + assert.deepEqual(requestedReviewers, ['cli-reviewer']); + + requestedReviewers = []; + await applyStoryLifecycle(42, { + status: 'ready-for-review', + backend, + rootDir, + }); + assert.deepEqual(requestedReviewers, ['env-reviewer']); + } finally { + if (previousEnvReviewers === undefined) { + delete process.env.GIT_TASKS_REVIEWERS; + } else { + process.env.GIT_TASKS_REVIEWERS = previousEnvReviewers; + } + await rm(rootDir, { recursive: true, force: true }); + } +}); + +test('applyStoryLifecycle syncs knowledge links into an existing story PR', async () => { + const { applyStoryLifecycle } = await import('../src/automation/lifecycle.js'); + const story = { + number: 42, + title: 'story(#7): Implement login', + state: 'OPEN', + body: '## Metadata\n- **Status:** open\n', + labels: [{ name: 'user-story' }, { name: 'status:open' }], + }; + const basePullRequest = { + number: 88, + title: story.title, + body: `## Summary\nImplements ${story.title}\n\n## Linked story\nRefs #${story.number}\n`, + url: 'https://example.com/pull/88', + isDraft: true, + }; + let createdPullRequest = false; + let editedPullRequestBody = ''; + + const backend = { + async viewIssue() { + return structuredClone(story); + }, + async listPullRequests() { + return [structuredClone(basePullRequest)]; + }, + async createPullRequest() { + createdPullRequest = true; + throw new Error('createPullRequest should not be called when a matching PR already exists'); + }, + async editPullRequest(number, { body }) { + editedPullRequestBody = body; + return { ...basePullRequest, number, body }; + }, + async editIssue(number, edits) { + return { + ...story, + number, + body: edits.body, + state: 'OPEN', + labels: [{ name: 'user-story' }, { name: 'status:in-progress' }], + }; + }, + }; + + const { issue, pullRequest } = await applyStoryLifecycle(42, { + status: 'in-progress', + knowledgeLinks: ['wiki/knowledge/auth-plan.md'], + backend, + }); + + assert.equal(createdPullRequest, false); + assert.ok(editedPullRequestBody.includes('## Knowledge context')); + assert.ok(editedPullRequestBody.includes('wiki/knowledge/auth-plan.md')); + assert.equal(pullRequest.number, 88); + assert.ok(pullRequest.body.includes('wiki/knowledge/auth-plan.md')); + assert.ok(issue.body.includes('Knowledge Links')); + assert.ok(issue.body.includes('wiki/knowledge/auth-plan.md')); +}); + +test('applyStoryLifecycle preserves non-managed existing story PR bodies', async () => { + const { applyStoryLifecycle } = await import('../src/automation/lifecycle.js'); + const story = { + number: 42, + title: 'story(#7): Implement login', + state: 'OPEN', + body: '## Metadata\n- **Status:** open\n', + labels: [{ name: 'user-story' }, { name: 'status:open' }], + }; + const basePullRequest = { + number: 88, + title: story.title, + body: `## Summary\nImplements ${story.title}\n\n## Linked story\nRefs #${story.number}\n\n## Manual notes\nKeep this reviewer checklist intact.`, + url: 'https://example.com/pull/88', + isDraft: true, + }; + let editedPullRequestBody = ''; + + const backend = { + async viewIssue() { + return structuredClone(story); + }, + async listPullRequests() { + return [structuredClone(basePullRequest)]; + }, + async editPullRequest(number, { body }) { + editedPullRequestBody = body; + return { ...basePullRequest, number, body }; + }, + async editIssue(number, edits) { + return { + ...story, + number, + body: edits.body, + state: 'OPEN', + labels: [{ name: 'user-story' }, { name: 'status:in-progress' }], + }; + }, + }; + + const { issue, pullRequest } = await applyStoryLifecycle(42, { + status: 'in-progress', + knowledgeLinks: ['wiki/knowledge/auth-plan.md'], + backend, + }); + + assert.equal(editedPullRequestBody, ''); + assert.equal(pullRequest.body, basePullRequest.body); + assert.ok(issue.body.includes('Knowledge Links')); + assert.ok(issue.body.includes('wiki/knowledge/auth-plan.md')); +}); + +test('applyStoryLifecycle merges knowledge links before creating a story PR', async () => { + const { applyStoryLifecycle } = await import('../src/automation/lifecycle.js'); + const story = { + number: 42, + title: 'story(#7): Implement login', + state: 'OPEN', + body: '## Metadata\n- **Status:** open\n', + labels: [{ name: 'user-story' }, { name: 'status:open' }], + }; + let createdPullRequestBody = ''; + + const backend = { + async viewIssue() { + return structuredClone(story); + }, + async listPullRequests() { + return []; + }, + getCurrentBranch() { + return 'feature/auth-login'; + }, + async createPullRequest({ title, body, head, draft }) { + createdPullRequestBody = body; + return { + number: 88, + title, + body, + head, + draft, + url: 'https://example.com/pull/88', + isDraft: true, + }; + }, + async editIssue(number, edits) { + return { + ...story, + number, + body: edits.body, + state: 'OPEN', + labels: [{ name: 'user-story' }, { name: 'status:in-progress' }], + }; + }, + }; + + const { issue, pullRequest } = await applyStoryLifecycle(42, { + status: 'in-progress', + knowledgeLinks: ['wiki/knowledge/auth-plan.md'], + backend, + }); + + assert.equal(pullRequest.number, 88); + assert.ok(createdPullRequestBody.includes('## Knowledge context')); + assert.ok(createdPullRequestBody.includes('wiki/knowledge/auth-plan.md')); + assert.ok(issue.body.includes('Knowledge Links')); + assert.ok(issue.body.includes('wiki/knowledge/auth-plan.md')); +}); + test('buildLifecycleEdit updates labels and state consistently', async () => { const { buildLifecycleEdit } = await import('../src/automation/lifecycle.js'); const issue = { @@ -389,11 +827,13 @@ test('cascadeCloseParentsFromIssue closes sprint and epic when all children are assert.equal(issues.get(1).state, 'CLOSED'); }); -test('epic create works with gh issue create stdout output and no unsupported json flag', async () => { +test('epic create forwards knowledge links through gh issue create output flow', { skip: process.platform === 'win32' }, async () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-gh-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); const ghLog = join(cwd, 'gh.log'); - const ghPath = join(cwd, 'gh'); - fs.writeFileSync(ghPath, `#!/usr/bin/env node + const ghScriptPath = join(cwd, 'gh.js'); + const ghPath = join(cwd, process.platform === 'win32' ? 'gh.cmd' : 'gh'); + fs.writeFileSync(ghScriptPath, `#!/usr/bin/env node import fs from 'node:fs'; const args = process.argv.slice(2); fs.appendFileSync(${JSON.stringify(ghLog)}, JSON.stringify(args) + '\\n'); @@ -424,10 +864,15 @@ if (args[0] === 'issue' && args[1] === 'view') { console.error('Unexpected gh invocation: ' + args.join(' ')); process.exit(1); `); - fs.chmodSync(ghPath, 0o755); + if (process.platform === 'win32') { + fs.writeFileSync(ghPath, `@echo off\r\nnode "%~dp0\\gh.js" %*\r\n`); + } else { + fs.writeFileSync(ghPath, `#!/usr/bin/env sh\nnode "$(dirname "$0")/gh.js" "$@"\n`); + fs.chmodSync(ghPath, 0o755); + } try { - const result = run(['epic', 'create', 'Ship auth', '-d', 'Test body', '-p', '3', '--start', '2026-01-01', '--end', '2026-01-14'], { + const result = run(['epic', 'create', 'Ship auth', '-d', 'Test body', '-p', '3', '--start', '2026-01-01', '--end', '2026-01-14', '--knowledge', 'wiki/knowledge/auth-plan.md'], { cwd, env: { PATH: `${cwd}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}` }, }); @@ -442,6 +887,9 @@ process.exit(1); assert.ok(issueCreate, 'expected gh issue create to be called'); assert.ok(issueView, 'expected gh issue view to be called'); assert.ok(!issueCreate.includes('--json'), 'gh issue create should not receive --json'); + const bodyArg = issueCreate[issueCreate.indexOf('--body') + 1]; + assert.ok(bodyArg.includes('Knowledge Links')); + assert.ok(bodyArg.includes('wiki/knowledge/auth-plan.md')); assert.deepEqual(issueView, ['issue', 'view', '123', '--json', 'number,title,state,body,labels,assignees,createdAt,updatedAt,url']); } finally { await rm(cwd, { recursive: true, force: true }); diff --git a/test/evals/eval.js b/test/evals/eval.js new file mode 100644 index 0000000..c2e97c9 --- /dev/null +++ b/test/evals/eval.js @@ -0,0 +1,103 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { rm } from 'node:fs/promises'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); +const CLI = join(REPO_ROOT, 'bin', 'git-tasks.js'); +const SKILL_PATH = join(REPO_ROOT, 'skills', 'git-tasks', 'SKILL.md'); +const EVALS_PATH = join(REPO_ROOT, 'test', 'evals', 'git-tasks.evals.json'); + +function run(args, options = {}) { + return spawnSync('node', [CLI, ...args], { + encoding: 'utf8', + cwd: options.cwd || REPO_ROOT, + env: { ...process.env, ...(options.env || {}) }, + }); +} + +test('eval harness smoke-tests repo bootstrap and wiki knowledge flow', async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-eval-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + + try { + const initResult = run(['init', '--owner', 'octocat', '--reviewer', 'hubot'], { cwd }); + assert.equal(initResult.status, 0, initResult.stderr); + + const config = JSON.parse(fs.readFileSync(join(cwd, '.git-tasks', 'config.json'), 'utf8')); + assert.equal(config.owner, 'octocat'); + assert.deepEqual(config.defaultReviewers, ['hubot']); + assert.deepEqual(config.planningHorizons, { + storyMaxDays: 1, + sprintMaxDays: 3, + epicMaxWeeks: 2, + }); + + const inboxFixture = fs.readFileSync(join(REPO_ROOT, 'test', 'evals', 'fixtures', 'raw-meeting-notes.md'), 'utf8'); + const knowledgeFixture = fs.readFileSync(join(REPO_ROOT, 'test', 'evals', 'fixtures', 'knowledge-auth-plan.md'), 'utf8'); + fs.writeFileSync(join(cwd, 'wiki', 'inbox', 'meeting-notes.md'), inboxFixture); + fs.writeFileSync(join(cwd, 'wiki', 'knowledge', 'auth-plan.md'), knowledgeFixture, 'utf8'); + fs.appendFileSync( + join(cwd, 'wiki', 'knowledge', 'index.md'), + '- `2026-04-23T09:54:02Z | decision | [Split auth rollout into reviewable stories](auth-plan.md) — Break the auth work into agent-sized stories with clear review handoff.`\n', + 'utf8', + ); + + const listResult = run(['wiki', 'list'], { cwd }); + assert.equal(listResult.status, 0, listResult.stderr); + assert.ok(listResult.stdout.includes('inbox/meeting-notes.md')); + assert.ok(listResult.stdout.includes('knowledge/index.md')); + assert.ok(listResult.stdout.includes('knowledge/auth-plan.md')); + + const showResult = run(['wiki', 'show', 'knowledge/index'], { cwd }); + assert.equal(showResult.status, 0, showResult.stderr); + assert.ok(showResult.stdout.includes('Knowledge Index')); + assert.ok(showResult.stdout.includes('decision')); + assert.ok(showResult.stdout.includes('auth-plan.md')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('shipped skill documents repo config, knowledge index, and lifecycle boundaries', () => { + const skill = fs.readFileSync(SKILL_PATH, 'utf8'); + + assert.ok(skill.includes('.git-tasks/config.json')); + assert.ok(skill.includes('planningHorizons')); + assert.ok(skill.includes('defaultReviewers')); + assert.ok(skill.includes('owner')); + assert.ok(skill.includes('wiki/inbox/')); + assert.ok(skill.includes('wiki/knowledge/index.md')); + assert.ok(skill.includes('timestamp')); + assert.ok(skill.includes('neighbours')); + assert.ok(skill.includes('issue-refs')); + assert.ok(skill.includes('does **not** justify creating or updating issues, branches, or pull requests')); + assert.ok(skill.includes('draft PR')); + assert.ok(skill.includes('Knowledge Links')); +}); + +test('skill eval set covers knowledge/index flow, no-CRUD cases, and reviewer fallback', () => { + const evalSet = JSON.parse(fs.readFileSync(EVALS_PATH, 'utf8')); + + assert.equal(evalSet.skill_name, 'git-tasks'); + assert.ok(evalSet.evals.length >= 4); + + for (const entry of evalSet.evals) { + assert.ok(entry.id, 'expected eval id'); + assert.ok(entry.prompt?.trim(), 'expected eval prompt'); + assert.ok(entry.expected_output?.trim(), 'expected expected_output text'); + for (const relativeFile of entry.files || []) { + assert.ok(fs.existsSync(join(REPO_ROOT, relativeFile)), `missing eval fixture: ${relativeFile}`); + } + } + + assert.ok(evalSet.evals.some((entry) => /knowledge\/index/i.test(entry.prompt))); + assert.ok(evalSet.evals.some((entry) => /issue, branch, and pull-request CRUD|no planning delta/i.test(entry.expected_output))); + assert.ok(evalSet.evals.some((entry) => /defaultReviewers|owner|config\.json/i.test(entry.expected_output))); + assert.ok(evalSet.evals.some((entry) => /legacy raw\/processed|write forward|inbox\/knowledge/i.test(entry.expected_output))); +}); diff --git a/test/evals/fixtures/knowledge-auth-plan.md b/test/evals/fixtures/knowledge-auth-plan.md new file mode 100644 index 0000000..284e1d3 --- /dev/null +++ b/test/evals/fixtures/knowledge-auth-plan.md @@ -0,0 +1,34 @@ +--- +id: k-auth-rollout-plan +type: decision +title: Split auth rollout into reviewable stories +timestamp: 2026-04-23T09:54:02Z +status: accepted +tags: + - auth + - planning +sources: + - wiki/inbox/meeting-notes.md +issue-refs: + - "#12" + - "#27" +neighbours: + - id: k-session-token-compliance + relation: constrained-by +supersedes: [] +--- + +## Context/Source +The team wants auth work split into reviewable, agent-sized stories with clear audit links. + +## Interpretation +A single auth rewrite is too large for one short sprint and should be decomposed. + +## Planning changes +Create or update a short auth epic, a sprint scoped to the rollout, and stories for login, logout, password reset, and SSO. + +## Rationale +This keeps execution auditable and lets each story move through the draft-PR lifecycle independently. + +## Consequences +Each affected backlog item should store a `Knowledge Links` reference back to this node. diff --git a/test/evals/fixtures/knowledge-index.md b/test/evals/fixtures/knowledge-index.md new file mode 100644 index 0000000..2d6c055 --- /dev/null +++ b/test/evals/fixtures/knowledge-index.md @@ -0,0 +1,3 @@ +# Knowledge Index + +- `2026-04-23T09:54:02Z | decision | [Split auth rollout into reviewable stories](knowledge-auth-plan.md) — Break the auth work into agent-sized stories with clear review handoff.` diff --git a/test/evals/fixtures/legacy-processed-auth-plan.md b/test/evals/fixtures/legacy-processed-auth-plan.md new file mode 100644 index 0000000..fe2bc66 --- /dev/null +++ b/test/evals/fixtures/legacy-processed-auth-plan.md @@ -0,0 +1,15 @@ +# Processed auth plan + +## Source +- raw/meeting-notes.md + +## Interpretation +The request needs a short AI-sized epic, a short sprint, and stories that can each be completed independently. + +## Planning changes +- Create epic: Authentication hardening +- Create sprint: Auth sprint +- Create stories: login flow, logout flow, password reset, Google SSO + +## Rationale +This decomposition keeps each story independently executable and auditable. diff --git a/test/evals/fixtures/raw-feature-request.md b/test/evals/fixtures/raw-feature-request.md new file mode 100644 index 0000000..14f2361 --- /dev/null +++ b/test/evals/fixtures/raw-feature-request.md @@ -0,0 +1,9 @@ +# Customer request dump + +The support team wants an audit-ready way to capture raw customer notes and then turn them into structured work. + +Constraints: +- Human notes may arrive directly in the wiki. +- AI project managers must write a processed summary before changing issues. +- The resulting plan should keep sprint and epic horizons short for agent execution. +- Stories should stay dependency-aware and easy to review in isolated branches and draft pull requests. diff --git a/test/evals/fixtures/raw-meeting-notes.md b/test/evals/fixtures/raw-meeting-notes.md new file mode 100644 index 0000000..e1537b6 --- /dev/null +++ b/test/evals/fixtures/raw-meeting-notes.md @@ -0,0 +1,7 @@ +# Auth planning sync + +- We need login, logout, password reset, and Google SSO. +- The work should be split for coding agents, not for a human sprint board. +- Each story should be independently executable within a few hours to one day. +- We want a short sprint and an auditable planning trail before backlog changes. +- Review should default to the repository owner unless the user says otherwise. diff --git a/test/evals/git-tasks.evals.json b/test/evals/git-tasks.evals.json new file mode 100644 index 0000000..87b3ccc --- /dev/null +++ b/test/evals/git-tasks.evals.json @@ -0,0 +1,63 @@ +{ + "skill_name": "git-tasks", + "evals": [ + { + "id": 1, + "prompt": "Use the git-tasks skill to turn the meeting notes into an AI-era plan. Start from the inbox fixture, read wiki/knowledge/index.md first, update or create the right knowledge node with dash-case frontmatter, update the knowledge index, then create or update the right epic, sprint, and stories with explicit dependencies and --knowledge links.", + "expected_output": "Reads test/evals/fixtures/raw-meeting-notes.md alongside the knowledge index, writes or references wiki/inbox content, updates wiki/knowledge/index.md and a structured knowledge node before backlog changes, then decomposes the work into agent-sized epic/sprint/story items that respect the repo planning horizons and store Knowledge Links.", + "files": [ + "test/evals/fixtures/raw-meeting-notes.md", + "test/evals/fixtures/knowledge-index.md", + "test/evals/fixtures/knowledge-auth-plan.md" + ], + "expectations": [ + "Consults `wiki/knowledge/index.md` before proposing specific knowledge-node or backlog changes.", + "Proposes a structured knowledge-node update or creation with dash-case fields including `timestamp`, `issue-refs`, and `neighbours`.", + "Proposes epic, sprint, and story backlog actions only after the knowledge-layer update and includes `--knowledge` links on backlog commands." + ] + }, + { + "id": 2, + "prompt": "Assume the user dropped new inbound notes into the inbox, but the new information only clarifies an existing knowledge node and does not change the backlog. Read the fixture, read wiki/knowledge/index.md first, update the relevant knowledge node and the index if needed, and stop there instead of creating or updating issues, branches, or pull requests.", + "expected_output": "Uses test/evals/fixtures/raw-feature-request.md as inbox input, reuses existing knowledge from the index, updates a structured wiki/knowledge record that includes timestamp, issue-refs, and neighbours, and deliberately avoids issue, branch, and pull-request CRUD because there is no planning delta.", + "files": [ + "test/evals/fixtures/raw-feature-request.md", + "test/evals/fixtures/knowledge-index.md", + "test/evals/fixtures/knowledge-auth-plan.md" + ], + "expectations": [ + "Uses `wiki/knowledge/index.md` and the existing knowledge node to refine durable knowledge instead of starting from scratch.", + "States explicitly that there is no issue, branch, or pull-request CRUD because the backlog does not change.", + "Keeps the response in the knowledge layer, with no proposed epic/sprint/story create-update commands." + ] + }, + { + "id": 3, + "prompt": "Move an in-progress story to ready-for-review in a repo that already ran git-tasks init with owner and reviewer defaults and that links the story to one or more wiki/knowledge docs. Make sure the review handoff uses the repo contract and preserves the linked knowledge context for reviewers.", + "expected_output": "Reads .git-tasks/config.json, promotes the linked draft PR, requests review from defaultReviewers or owner when explicit reviewers are not supplied, and carries wiki/knowledge context through the story review handoff.", + "files": [ + "test/evals/fixtures/knowledge-auth-plan.md" + ], + "expectations": [ + "Reads `.git-tasks/config.json` and uses repo-configured reviewer fallback when no explicit reviewer is supplied.", + "Proposes the `ready-for-review` story lifecycle handoff rather than a generic PR update.", + "Preserves reviewer context by carrying linked wiki knowledge into the review handoff." + ] + }, + { + "id": 4, + "prompt": "The repo still contains legacy wiki/raw and wiki/processed material from before the knowledge rename. Use those legacy files for historical context, but if the new information changes durable understanding, write forward into wiki/inbox and wiki/knowledge/index.md rather than extending the old directories.", + "expected_output": "Reads legacy raw/processed-style fixtures for context, treats them as historical inputs only, and writes any new durable knowledge into the inbox/knowledge model with an updated wiki/knowledge/index.md entry before applying planning changes.", + "files": [ + "test/evals/fixtures/raw-meeting-notes.md", + "test/evals/fixtures/legacy-processed-auth-plan.md", + "test/evals/fixtures/knowledge-index.md" + ], + "expectations": [ + "Treats `wiki/raw/` and `wiki/processed/` as historical context only.", + "Writes any new durable understanding forward into `wiki/inbox/`, `wiki/knowledge/`, or `wiki/knowledge/index.md` rather than extending legacy directories.", + "If planning changes are proposed, ties them to the new knowledge artifact instead of to legacy raw/processed files." + ] + } + ] +} From 13dd2a20b0054f78a1ba4efbcda221982cc272b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:43:23 +0000 Subject: [PATCH 06/15] Address review thread follow-ups Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/3d02f50d-1b4f-44ff-b1c7-c3d948d526d8 Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- README.md | 4 +-- src/backends/github.js | 31 +++++++++++++--- src/commands/wiki.js | 10 ++++-- test/cli.test.js | 80 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 115 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 43e7c5b..3ea7d1b 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Labels created automatically: `epic`, `sprint`, `user-story` ### Epics ```bash -git-tasks epic create "Title" -d <desc> -p <points> --start <date> --end <date> [-a <user>] +git-tasks epic create "Title" -d <desc> -p <points> --start <date> --end <date> [-k <wiki/knowledge/file.md>] [-a <user>] git-tasks epic list [--state open|closed|all] [--short] git-tasks epic show <number> [--comments] git-tasks epic update <number> [--title <text>] [--points <n>] [--status open|closed] @@ -87,7 +87,7 @@ git-tasks epic update <number> [--title <text>] [--points <n>] [--status open|cl ### Sprints ```bash -git-tasks sprint create "Title" -e <epic> -d <desc> -p <points> --start <date> --end <date> [-a <user>] +git-tasks sprint create "Title" -e <epic> -d <desc> -p <points> --start <date> --end <date> [-k <wiki/knowledge/file.md>] [-a <user>] git-tasks sprint list [--epic <n>] [--state open|closed|all] [--short] git-tasks sprint show <number> [--comments] git-tasks sprint update <number> [--title <text>] [--status open|closed] diff --git a/src/backends/github.js b/src/backends/github.js index b66ce38..f2a2818 100644 --- a/src/backends/github.js +++ b/src/backends/github.js @@ -36,11 +36,25 @@ function extractIssueNumberFromOutput(output) { return match[1]; } +function listExistingLabels() { + try { + const labels = runGhJSON(['label', 'list', '--json', 'name']); + return new Set(labels.map((label) => label.name)); + } catch { + return null; + } +} + /** * Ensure a label exists in the repo; create it if missing. */ function ensureLabel(name, color, description) { - runGh(['label', 'create', name, '--color', color, '--description', description, '--force']); + try { + runGh(['label', 'create', name, '--color', color, '--description', description, '--force']); + return true; + } catch { + return false; + } } const LABEL_CONFIG = { @@ -54,18 +68,27 @@ const LABEL_CONFIG = { }; function ensureLabels(labels) { + const existingLabels = listExistingLabels(); + const ensuredLabels = []; for (const label of labels) { + if (existingLabels?.has(label)) { + ensuredLabels.push(label); + continue; + } const cfg = LABEL_CONFIG[label] || { color: 'ededed', description: '' }; - ensureLabel(label, cfg.color, cfg.description); + if (ensureLabel(label, cfg.color, cfg.description)) { + ensuredLabels.push(label); + } } + return ensuredLabels; } // ─── Issues ────────────────────────────────────────────────────────────────── export function createIssue({ title, body, labels = [], assignees = [] }) { - ensureLabels(labels); + const ensuredLabels = ensureLabels(labels); const args = ['issue', 'create', '--title', title, '--body', body]; - for (const l of labels) args.push('--label', l); + for (const l of ensuredLabels) args.push('--label', l); for (const a of assignees) args.push('--assignee', a); const output = runGh(args); diff --git a/src/commands/wiki.js b/src/commands/wiki.js index dd1c31b..0cd63ff 100644 --- a/src/commands/wiki.js +++ b/src/commands/wiki.js @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; -import { dirname, join, relative, resolve } from 'path'; +import { dirname, join, relative, resolve, isAbsolute } from 'path'; import { printError } from '../utils/format.js'; import chalk from 'chalk'; @@ -92,12 +92,16 @@ export function initializeWiki(rootDir = process.cwd()) { return { createdPaths, wikiRoot }; } +export function isWikiPathWithinRoot(wikiRoot, filePath, pathModule = { relative, isAbsolute }) { + const relativePath = pathModule.relative(wikiRoot, filePath); + return Boolean(relativePath) && !relativePath.startsWith('..') && !pathModule.isAbsolute(relativePath); +} + function resolveWikiFilePath(filename, rootDir = process.cwd()) { const wikiRoot = getWikiRoot(rootDir); const requested = filename.endsWith('.md') ? filename : `${filename}.md`; const filePath = resolve(wikiRoot, requested); - const relativePath = relative(wikiRoot, filePath); - if (!relativePath || relativePath.startsWith('..')) { + if (!isWikiPathWithinRoot(wikiRoot, filePath)) { throw new Error('Wiki paths must stay inside the wiki/ directory.'); } return filePath; diff --git a/test/cli.test.js b/test/cli.test.js index bccfc12..0f0cf3c 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import { spawnSync } from 'child_process'; import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; +import { dirname, join, win32 } from 'path'; import { rm } from 'node:fs/promises'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -326,6 +326,19 @@ test('wiki show rejects paths outside wiki/', async () => { } }); +test('isWikiPathWithinRoot rejects win32 cross-drive paths', async () => { + const { isWikiPathWithinRoot } = await import('../src/commands/wiki.js'); + + assert.equal( + isWikiPathWithinRoot('C:\\repo\\wiki', 'D:\\tmp\\note.md', win32), + false, + ); + assert.equal( + isWikiPathWithinRoot('C:\\repo\\wiki', 'C:\\repo\\wiki\\knowledge\\index.md', win32), + true, + ); +}); + test('parseIssueTitle correctly identifies epics', async () => { const { parseIssueTitle } = await import('../src/utils/format.js'); const r = parseIssueTitle('epic: My Big Epic'); @@ -895,3 +908,68 @@ process.exit(1); await rm(cwd, { recursive: true, force: true }); } }); + +test('epic create continues when label creation is not permitted', { skip: process.platform === 'win32' }, async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-gh-labels-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + const ghLog = join(cwd, 'gh.log'); + const ghScriptPath = join(cwd, 'gh.js'); + const ghPath = join(cwd, process.platform === 'win32' ? 'gh.cmd' : 'gh'); + fs.writeFileSync(ghScriptPath, `#!/usr/bin/env node +import fs from 'node:fs'; +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(ghLog)}, JSON.stringify(args) + '\\n'); +if (args[0] === 'label' && args[1] === 'list') { + process.stdout.write('[]\\n'); + process.exit(0); +} +if (args[0] === 'label' && args[1] === 'create') { + console.error('HTTP 403: Resource not accessible by integration'); + process.exit(1); +} +if (args[0] === 'issue' && args[1] === 'create') { + process.stdout.write('https://github.com/Atena-IT/git-tasks/issues/321\\n'); + process.exit(0); +} +if (args[0] === 'issue' && args[1] === 'view') { + process.stdout.write(JSON.stringify({ + number: 321, + url: 'https://github.com/Atena-IT/git-tasks/issues/321', + title: 'epic: Ship auth', + state: 'OPEN', + body: 'Test body', + labels: [], + assignees: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + })); + process.exit(0); +} +console.error('Unexpected gh invocation: ' + args.join(' ')); +process.exit(1); +`); + if (process.platform === 'win32') { + fs.writeFileSync(ghPath, `@echo off\r\nnode "%~dp0\\gh.js" %*\r\n`); + } else { + fs.writeFileSync(ghPath, `#!/usr/bin/env sh\nnode "$(dirname "$0")/gh.js" "$@"\n`); + fs.chmodSync(ghPath, 0o755); + } + + try { + const result = run(['epic', 'create', 'Ship auth', '-d', 'Test body', '-p', '3', '--start', '2026-01-01', '--end', '2026-01-14'], { + cwd, + env: { PATH: `${cwd}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}` }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.ok(result.stdout.includes('Created epic #321: https://github.com/Atena-IT/git-tasks/issues/321')); + + const commands = fs.readFileSync(ghLog, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + const issueCreate = commands.find((args) => args[0] === 'issue' && args[1] === 'create'); + + assert.ok(issueCreate, 'expected gh issue create to be called'); + assert.ok(!issueCreate.includes('--label'), 'gh issue create should skip labels when they cannot be ensured'); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); From 8c734d74580490ecb49020c23842a3314acdb729 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:06:49 +0000 Subject: [PATCH 07/15] Address second review thread Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/06259a5c-12d1-461b-999b-99a46f09f14a Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- README.md | 2 +- src/automation/lifecycle.js | 8 +- src/commands/epic.js | 38 +++++++- src/commands/sprint.js | 27 ++++-- src/commands/story.js | 60 ++++++++---- src/commands/wiki.js | 10 +- test/cli.test.js | 187 ++++++++++++++++++++++++++++++++++++ 7 files changed, 297 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 3ea7d1b..4b3e21e 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ git-tasks sprint update <number> [--title <text>] [--status open|closed] ### User Stories ```bash -git-tasks story create "Title" -s <sprint> -e <epic> -d <desc> -p <points> --priority low|medium|high [-a <user>] +git-tasks story create "Title" -s <sprint> -e <epic> -d <desc> -p <points> --priority low|medium|high [-k <wiki/knowledge/file.md>] [-a <user>] git-tasks story list [--sprint <n>] [--epic <n>] [--assignee <user>] [--state open|closed|all] [--short] git-tasks story show <number> [--comments] git-tasks story update <number> [--status open|in-progress|ready-for-review|closed] [--reviewer <user>] diff --git a/src/automation/lifecycle.js b/src/automation/lifecycle.js index a77c3d6..400033d 100644 --- a/src/automation/lifecycle.js +++ b/src/automation/lifecycle.js @@ -94,16 +94,10 @@ function normalizePullRequestBody(body = '') { return String(body).trimEnd(); } -function escapeRegExp(value) { - return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - function isManagedPullRequestBody(body, story) { const normalized = normalizePullRequestBody(body); - const titlePattern = escapeRegExp(story.title); - const numberPattern = escapeRegExp(String(story.number)); const managedBodyPattern = new RegExp( - `^## Summary\\nImplements ${titlePattern}\\n\\n## Linked story\\nRefs #${numberPattern}(?:\\n\\n## Knowledge context\\n(?:- .+\\n?)*)?$`, + `^## Summary\\nImplements .+\\n\\n## Linked story\\nRefs #${String(story.number)}(?:\\n\\n## Knowledge context\\n(?:- .+\\n?)*)?$`, ); return managedBodyPattern.test(normalized); diff --git a/src/commands/epic.js b/src/commands/epic.js index 1015baa..cd4871b 100644 --- a/src/commands/epic.js +++ b/src/commands/epic.js @@ -3,12 +3,34 @@ import getBackend from '../backends/index.js'; import { buildLifecycleEdit } from '../automation/lifecycle.js'; import { epicTemplate } from '../utils/templates.js'; import { formatIssueList, formatIssueDetail, printSuccess, printError } from '../utils/format.js'; -import { getMetadataField, parseMetadataList, setMetadataListField } from '../utils/metadata.js'; +import { getMetadataField, parseMetadataList, setMetadataField, setMetadataListField } from '../utils/metadata.js'; function collectValues(value, previous = []) { return previous.concat(value); } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function appendSectionListItem(body = '', heading, value) { + const sectionPattern = new RegExp(`(## ${escapeRegExp(heading)}\\n)([\\s\\S]*?)(\\n## |$)`); + const match = body.match(sectionPattern); + const nextItem = `- ${value}`; + + if (!match) { + return `${body.trimEnd()}\n\n## ${heading}\n${nextItem}\n`; + } + + const items = match[2] + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('<!--')); + const nextItems = items.includes(nextItem) ? items : [...items, nextItem]; + + return body.replace(sectionPattern, `${match[1]}${nextItems.join('\n')}\n${match[3]}`); +} + export function makeEpicCommand() { const epic = new Command('epic').description('Manage epics'); @@ -94,9 +116,21 @@ export function makeEpicCommand() { Object.assign(editOpts, lifecycleEdit); nextBody = lifecycleEdit.body; } + if (opts.points) { + nextBody = setMetadataField(nextBody, 'Story Points', opts.points); + editOpts.body = nextBody; + } + if (opts.addBlocker) { + nextBody = appendSectionListItem(nextBody, 'Dependencies', `#${opts.addBlocker}`); + editOpts.body = nextBody; + } if (opts.knowledge.length) { const knowledgeLinks = parseMetadataList(getMetadataField(nextBody, 'Knowledge Links'), opts.knowledge); - editOpts.body = setMetadataListField(nextBody, 'Knowledge Links', knowledgeLinks); + nextBody = setMetadataListField(nextBody, 'Knowledge Links', knowledgeLinks); + editOpts.body = nextBody; + } + if (!Object.keys(editOpts).length) { + printError('Pass at least one update option.'); } const issue = await backend.editIssue(number, editOpts); diff --git a/src/commands/sprint.js b/src/commands/sprint.js index e0889eb..db458ec 100644 --- a/src/commands/sprint.js +++ b/src/commands/sprint.js @@ -2,8 +2,8 @@ import { Command } from 'commander'; import getBackend from '../backends/index.js'; import { buildLifecycleEdit, cascadeCloseParentsFromIssue } from '../automation/lifecycle.js'; import { sprintTemplate } from '../utils/templates.js'; -import { formatIssueList, formatIssueDetail, printSuccess, printError } from '../utils/format.js'; -import { getMetadataField, parseMetadataList, setMetadataListField } from '../utils/metadata.js'; +import { formatIssueList, formatIssueDetail, parseIssueTitle, printSuccess, printError } from '../utils/format.js'; +import { getMetadataField, parseMetadataList, setMetadataField, setMetadataListField } from '../utils/metadata.js'; function collectValues(value, previous = []) { return previous.concat(value); @@ -95,18 +95,33 @@ export function makeSprintCommand() { const currentIssue = await backend.viewIssue(number); const editOpts = {}; let nextBody = currentIssue.body; - if (opts.title) { - const epicPart = opts.epic ? `(#${opts.epic})` : ''; - editOpts.title = `sprint${epicPart}: ${opts.title}`; + const parsedTitle = parseIssueTitle(currentIssue.title); + if (opts.title || opts.epic) { + const epicRef = opts.epic ? `#${opts.epic}` : parsedTitle.ref; + const title = opts.title || parsedTitle.title; + const epicPart = epicRef ? `(${epicRef})` : ''; + editOpts.title = `sprint${epicPart}: ${title}`; } if (opts.status) { const lifecycleEdit = buildLifecycleEdit(currentIssue, opts.status); Object.assign(editOpts, lifecycleEdit); nextBody = lifecycleEdit.body; } + if (opts.epic) { + nextBody = setMetadataField(nextBody, 'Epic', `#${opts.epic}`); + editOpts.body = nextBody; + } + if (opts.points) { + nextBody = setMetadataField(nextBody, 'Story Points', opts.points); + editOpts.body = nextBody; + } if (opts.knowledge.length) { const knowledgeLinks = parseMetadataList(getMetadataField(nextBody, 'Knowledge Links'), opts.knowledge); - editOpts.body = setMetadataListField(nextBody, 'Knowledge Links', knowledgeLinks); + nextBody = setMetadataListField(nextBody, 'Knowledge Links', knowledgeLinks); + editOpts.body = nextBody; + } + if (!Object.keys(editOpts).length) { + printError('Pass at least one update option.'); } const issue = await backend.editIssue(number, editOpts); diff --git a/src/commands/story.js b/src/commands/story.js index 893bd61..27ceceb 100644 --- a/src/commands/story.js +++ b/src/commands/story.js @@ -1,9 +1,9 @@ import { Command } from 'commander'; import getBackend from '../backends/index.js'; import { applyStoryLifecycle } from '../automation/lifecycle.js'; -import { getMetadataField, parseMetadataList, parseReviewerList, setMetadataListField } from '../utils/metadata.js'; +import { getMetadataField, parseMetadataList, parseReviewerList, setMetadataField, setMetadataListField } from '../utils/metadata.js'; import { storyTemplate } from '../utils/templates.js'; -import { formatIssueList, formatIssueDetail, printSuccess, printError } from '../utils/format.js'; +import { formatIssueList, formatIssueDetail, parseIssueTitle, printSuccess, printError } from '../utils/format.js'; function collectValues(value, previous = []) { return previous.concat(value); @@ -104,34 +104,58 @@ export function makeStoryCommand() { .option('-k, --knowledge <path>', 'Linked knowledge document path', collectValues, []) .action(async (number, opts) => { try { + const requestedKnowledgeLinks = parseMetadataList(opts.knowledge); + if (!opts.status && !opts.title && !opts.sprint && !opts.points && !opts.priority && !opts.assignee && !requestedKnowledgeLinks.length) { + printError('Pass at least one update option.'); + } const backend = getBackend(); + const currentIssue = await backend.viewIssue(number); const editOpts = {}; - const requestedKnowledgeLinks = parseMetadataList(opts.knowledge); - if (opts.title) { - const sprintPart = opts.sprint ? `(#${opts.sprint})` : ''; - editOpts.title = `story${sprintPart}: ${opts.title}`; + const parsedTitle = parseIssueTitle(currentIssue.title); + let nextBody = currentIssue.body; + if (opts.title || opts.sprint) { + const sprintRef = opts.sprint ? `#${opts.sprint}` : parsedTitle.ref; + const title = opts.title || parsedTitle.title; + const sprintPart = sprintRef ? `(${sprintRef})` : ''; + editOpts.title = `story${sprintPart}: ${title}`; + } + if (opts.points) { + nextBody = setMetadataField(nextBody, 'Story Points', opts.points); + editOpts.body = nextBody; + } + if (opts.sprint) { + nextBody = setMetadataField(nextBody, 'Sprint', `#${opts.sprint}`); + editOpts.body = nextBody; + } + if (opts.priority) { + nextBody = setMetadataField(nextBody, 'Priority', opts.priority); + editOpts.body = nextBody; } - if (opts.assignee) editOpts.addAssignees = [opts.assignee]; + if (opts.assignee) { + editOpts.addAssignees = [opts.assignee]; + nextBody = setMetadataField(nextBody, 'Assignee', opts.assignee); + editOpts.body = nextBody; + } + if (requestedKnowledgeLinks.length) { + const knowledgeLinks = parseMetadataList(getMetadataField(nextBody, 'Knowledge Links'), requestedKnowledgeLinks); + nextBody = setMetadataListField(nextBody, 'Knowledge Links', knowledgeLinks); + editOpts.body = nextBody; + } + + const hasDirectEdits = Object.keys(editOpts).length > 0; let issue; + if (hasDirectEdits) { + issue = await backend.editIssue(number, editOpts); + } if (opts.status) { ({ issue } = await applyStoryLifecycle(number, { status: opts.status, reviewers: parseReviewerList(opts.reviewer), - knowledgeLinks: requestedKnowledgeLinks, + knowledgeLinks: hasDirectEdits ? [] : requestedKnowledgeLinks, base: opts.base, head: opts.head, backend, })); - if (Object.keys(editOpts).length) { - issue = await backend.editIssue(number, editOpts); - } - } else { - const currentIssue = await backend.viewIssue(number); - if (requestedKnowledgeLinks.length) { - const knowledgeLinks = parseMetadataList(getMetadataField(currentIssue.body, 'Knowledge Links'), requestedKnowledgeLinks); - editOpts.body = setMetadataListField(currentIssue.body, 'Knowledge Links', knowledgeLinks); - } - issue = await backend.editIssue(number, editOpts); } printSuccess(`Updated user story #${issue.number}`); } catch (err) { diff --git a/src/commands/wiki.js b/src/commands/wiki.js index 0cd63ff..6569a1e 100644 --- a/src/commands/wiki.js +++ b/src/commands/wiki.js @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; +import { existsSync, readdirSync, readFileSync, mkdirSync, realpathSync, writeFileSync } from 'fs'; import { dirname, join, relative, resolve, isAbsolute } from 'path'; import { printError } from '../utils/format.js'; import chalk from 'chalk'; @@ -104,6 +104,14 @@ function resolveWikiFilePath(filename, rootDir = process.cwd()) { if (!isWikiPathWithinRoot(wikiRoot, filePath)) { throw new Error('Wiki paths must stay inside the wiki/ directory.'); } + if (existsSync(wikiRoot) && existsSync(filePath)) { + const realWikiRoot = realpathSync(wikiRoot); + const realFilePath = realpathSync(filePath); + if (!isWikiPathWithinRoot(realWikiRoot, realFilePath)) { + throw new Error('Wiki paths must stay inside the wiki/ directory.'); + } + return realFilePath; + } return filePath; } diff --git a/test/cli.test.js b/test/cli.test.js index 0f0cf3c..b50e76f 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -326,6 +326,27 @@ test('wiki show rejects paths outside wiki/', async () => { } }); +test('wiki show rejects symlink escapes outside wiki/', { skip: process.platform === 'win32' }, async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-link-')); + const outsideDir = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-outside-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + fs.writeFileSync(join(outsideDir, 'secret.md'), '# Secret\n'); + + try { + run(['init'], { cwd }); + await rm(join(cwd, 'wiki', 'knowledge'), { recursive: true, force: true }); + fs.symlinkSync(outsideDir, join(cwd, 'wiki', 'knowledge')); + + const result = run(['wiki', 'show', 'knowledge/secret'], { cwd }); + + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('Wiki paths must stay inside the wiki/ directory.')); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(outsideDir, { recursive: true, force: true }); + } +}); + test('isWikiPathWithinRoot rejects win32 cross-drive paths', async () => { const { isWikiPathWithinRoot } = await import('../src/commands/wiki.js'); @@ -723,6 +744,55 @@ test('applyStoryLifecycle preserves non-managed existing story PR bodies', async assert.ok(issue.body.includes('wiki/knowledge/auth-plan.md')); }); +test('applyStoryLifecycle resyncs managed PR bodies after a story rename', async () => { + const { applyStoryLifecycle } = await import('../src/automation/lifecycle.js'); + const story = { + number: 42, + title: 'story(#7): Implement passwordless login', + state: 'OPEN', + body: '## Metadata\n- **Status:** open\n', + labels: [{ name: 'user-story' }, { name: 'status:open' }], + }; + const basePullRequest = { + number: 88, + title: story.title, + body: '## Summary\nImplements story(#7): Implement login\n\n## Linked story\nRefs #42\n', + url: 'https://example.com/pull/88', + isDraft: true, + }; + let editedPullRequestBody = ''; + + const backend = { + async viewIssue() { + return structuredClone(story); + }, + async listPullRequests() { + return [structuredClone(basePullRequest)]; + }, + async editPullRequest(number, { body }) { + editedPullRequestBody = body; + return { ...basePullRequest, number, body }; + }, + async editIssue(number, edits) { + return { + ...story, + number, + body: edits.body, + state: 'OPEN', + labels: [{ name: 'user-story' }, { name: 'status:in-progress' }], + }; + }, + }; + + const { pullRequest } = await applyStoryLifecycle(42, { + status: 'in-progress', + backend, + }); + + assert.ok(editedPullRequestBody.includes('Implements story(#7): Implement passwordless login')); + assert.equal(pullRequest.number, 88); +}); + test('applyStoryLifecycle merges knowledge links before creating a story PR', async () => { const { applyStoryLifecycle } = await import('../src/automation/lifecycle.js'); const story = { @@ -840,6 +910,19 @@ test('cascadeCloseParentsFromIssue closes sprint and epic when all children are assert.equal(issues.get(1).state, 'CLOSED'); }); +test('story update rejects no-op edits before invoking gh', async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-story-update-noop-')); + + try { + const result = run(['story', 'update', '42'], { cwd }); + + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('Pass at least one update option.')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + test('epic create forwards knowledge links through gh issue create output flow', { skip: process.platform === 'win32' }, async () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-gh-')); spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); @@ -909,6 +992,110 @@ process.exit(1); } }); +test('epic update applies points and blockers', { skip: process.platform === 'win32' }, async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-epic-update-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + const ghLog = join(cwd, 'gh.log'); + const ghScriptPath = join(cwd, 'gh.js'); + const ghPath = join(cwd, 'gh'); + fs.writeFileSync(ghScriptPath, `#!/usr/bin/env node +import fs from 'node:fs'; +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(ghLog)}, JSON.stringify(args) + '\\n'); +if (args[0] === 'issue' && args[1] === 'view') { + process.stdout.write(JSON.stringify({ + number: 12, + url: 'https://github.com/Atena-IT/git-tasks/issues/12', + title: 'epic: Ship auth', + state: 'OPEN', + body: '## Description\\nTest\\n\\n## Metadata\\n- **Status:** open\\n- **Story Points:** 3\\n- **Knowledge Links:** \\n\\n## Dependencies\\n<!-- List blocking epics/issues -->\\n\\n## Notes\\n', + labels: [{ name: 'epic' }, { name: 'status:open' }], + assignees: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z' + })); + process.exit(0); +} +if (args[0] === 'issue' && args[1] === 'edit') { + process.exit(0); +} +console.error('Unexpected gh invocation: ' + args.join(' ')); +process.exit(1); +`); + fs.writeFileSync(ghPath, `#!/usr/bin/env sh\nnode "$(dirname "$0")/gh.js" "$@"\n`); + fs.chmodSync(ghPath, 0o755); + + try { + const result = run(['epic', 'update', '12', '--points', '5', '--add-blocker', '99'], { + cwd, + env: { PATH: `${cwd}:${process.env.PATH}` }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = fs.readFileSync(ghLog, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + const issueEdit = commands.find((args) => args[0] === 'issue' && args[1] === 'edit'); + const bodyArg = issueEdit[issueEdit.indexOf('--body') + 1]; + + assert.ok(bodyArg.includes('- **Story Points:** 5')); + assert.ok(bodyArg.includes('## Dependencies\n- #99')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('sprint update applies epic and points without changing the raw title', { skip: process.platform === 'win32' }, async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-sprint-update-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + const ghLog = join(cwd, 'gh.log'); + const ghScriptPath = join(cwd, 'gh.js'); + const ghPath = join(cwd, 'gh'); + fs.writeFileSync(ghScriptPath, `#!/usr/bin/env node +import fs from 'node:fs'; +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(ghLog)}, JSON.stringify(args) + '\\n'); +if (args[0] === 'issue' && args[1] === 'view') { + process.stdout.write(JSON.stringify({ + number: 7, + url: 'https://github.com/Atena-IT/git-tasks/issues/7', + title: 'sprint(#3): Auth Sprint 1', + state: 'OPEN', + body: '## Description\\nTest\\n\\n## Metadata\\n- **Status:** open\\n- **Story Points:** 3\\n- **Epic:** #3\\n- **Knowledge Links:** \\n\\n## Notes\\n', + labels: [{ name: 'sprint' }, { name: 'status:open' }], + assignees: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z' + })); + process.exit(0); +} +if (args[0] === 'issue' && args[1] === 'edit') { + process.exit(0); +} +console.error('Unexpected gh invocation: ' + args.join(' ')); +process.exit(1); +`); + fs.writeFileSync(ghPath, `#!/usr/bin/env sh\nnode "$(dirname "$0")/gh.js" "$@"\n`); + fs.chmodSync(ghPath, 0o755); + + try { + const result = run(['sprint', 'update', '7', '--epic', '8', '--points', '5'], { + cwd, + env: { PATH: `${cwd}:${process.env.PATH}` }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = fs.readFileSync(ghLog, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + const issueEdit = commands.find((args) => args[0] === 'issue' && args[1] === 'edit'); + const titleArg = issueEdit[issueEdit.indexOf('--title') + 1]; + const bodyArg = issueEdit[issueEdit.indexOf('--body') + 1]; + + assert.equal(titleArg, 'sprint(#8): Auth Sprint 1'); + assert.ok(bodyArg.includes('- **Story Points:** 5')); + assert.ok(bodyArg.includes('- **Epic:** #8')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + test('epic create continues when label creation is not permitted', { skip: process.platform === 'win32' }, async () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-gh-labels-')); spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); From 8f76b70e2b3ef3bfde7d3ba6628cc3c79ace93f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:07:54 +0000 Subject: [PATCH 08/15] Address validation follow-ups Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/06259a5c-12d1-461b-999b-99a46f09f14a Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- src/commands/epic.js | 1 + src/commands/sprint.js | 1 + src/commands/story.js | 1 + 3 files changed, 3 insertions(+) diff --git a/src/commands/epic.js b/src/commands/epic.js index cd4871b..04a0142 100644 --- a/src/commands/epic.js +++ b/src/commands/epic.js @@ -131,6 +131,7 @@ export function makeEpicCommand() { } if (!Object.keys(editOpts).length) { printError('Pass at least one update option.'); + return; } const issue = await backend.editIssue(number, editOpts); diff --git a/src/commands/sprint.js b/src/commands/sprint.js index db458ec..b69e586 100644 --- a/src/commands/sprint.js +++ b/src/commands/sprint.js @@ -122,6 +122,7 @@ export function makeSprintCommand() { } if (!Object.keys(editOpts).length) { printError('Pass at least one update option.'); + return; } const issue = await backend.editIssue(number, editOpts); diff --git a/src/commands/story.js b/src/commands/story.js index 27ceceb..204d034 100644 --- a/src/commands/story.js +++ b/src/commands/story.js @@ -107,6 +107,7 @@ export function makeStoryCommand() { const requestedKnowledgeLinks = parseMetadataList(opts.knowledge); if (!opts.status && !opts.title && !opts.sprint && !opts.points && !opts.priority && !opts.assignee && !requestedKnowledgeLinks.length) { printError('Pass at least one update option.'); + return; } const backend = getBackend(); const currentIssue = await backend.viewIssue(number); From e990c28496c95380a67b988589595cd4b2e850a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:08:59 +0000 Subject: [PATCH 09/15] Refine validation cleanup Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/06259a5c-12d1-461b-999b-99a46f09f14a Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- src/automation/lifecycle.js | 2 +- src/commands/epic.js | 8 ++------ src/commands/story.js | 10 +++++++++- src/utils/metadata.js | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/automation/lifecycle.js b/src/automation/lifecycle.js index 400033d..bab5f26 100644 --- a/src/automation/lifecycle.js +++ b/src/automation/lifecycle.js @@ -97,7 +97,7 @@ function normalizePullRequestBody(body = '') { function isManagedPullRequestBody(body, story) { const normalized = normalizePullRequestBody(body); const managedBodyPattern = new RegExp( - `^## Summary\\nImplements .+\\n\\n## Linked story\\nRefs #${String(story.number)}(?:\\n\\n## Knowledge context\\n(?:- .+\\n?)*)?$`, + `^## Summary\\nImplements story\\([^)]+\\): .+\\n\\n## Linked story\\nRefs #${String(story.number)}(?:\\n\\n## Knowledge context\\n(?:- .+\\n?)*)?$`, ); return managedBodyPattern.test(normalized); diff --git a/src/commands/epic.js b/src/commands/epic.js index 04a0142..b943cf4 100644 --- a/src/commands/epic.js +++ b/src/commands/epic.js @@ -3,18 +3,14 @@ import getBackend from '../backends/index.js'; import { buildLifecycleEdit } from '../automation/lifecycle.js'; import { epicTemplate } from '../utils/templates.js'; import { formatIssueList, formatIssueDetail, printSuccess, printError } from '../utils/format.js'; -import { getMetadataField, parseMetadataList, setMetadataField, setMetadataListField } from '../utils/metadata.js'; +import { escapeRegex, getMetadataField, parseMetadataList, setMetadataField, setMetadataListField } from '../utils/metadata.js'; function collectValues(value, previous = []) { return previous.concat(value); } -function escapeRegExp(value) { - return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - function appendSectionListItem(body = '', heading, value) { - const sectionPattern = new RegExp(`(## ${escapeRegExp(heading)}\\n)([\\s\\S]*?)(\\n## |$)`); + const sectionPattern = new RegExp(`(## ${escapeRegex(heading)}\\n)([\\s\\S]*?)(\\n## |$)`); const match = body.match(sectionPattern); const nextItem = `- ${value}`; diff --git a/src/commands/story.js b/src/commands/story.js index 204d034..fb83c1c 100644 --- a/src/commands/story.js +++ b/src/commands/story.js @@ -105,7 +105,15 @@ export function makeStoryCommand() { .action(async (number, opts) => { try { const requestedKnowledgeLinks = parseMetadataList(opts.knowledge); - if (!opts.status && !opts.title && !opts.sprint && !opts.points && !opts.priority && !opts.assignee && !requestedKnowledgeLinks.length) { + const hasRequestedEdits = [ + opts.status, + opts.title, + opts.sprint, + opts.points, + opts.priority, + opts.assignee, + ].some(Boolean) || requestedKnowledgeLinks.length > 0; + if (!hasRequestedEdits) { printError('Pass at least one update option.'); return; } diff --git a/src/utils/metadata.js b/src/utils/metadata.js index fcccbd7..93cdcb4 100644 --- a/src/utils/metadata.js +++ b/src/utils/metadata.js @@ -1,4 +1,4 @@ -function escapeRegex(value) { +export function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } From 816715bfc6d86ca827e043be92846a1f954b9fe5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:09:50 +0000 Subject: [PATCH 10/15] Tighten managed PR matcher Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/06259a5c-12d1-461b-999b-99a46f09f14a Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- src/automation/lifecycle.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/automation/lifecycle.js b/src/automation/lifecycle.js index bab5f26..710acbb 100644 --- a/src/automation/lifecycle.js +++ b/src/automation/lifecycle.js @@ -97,7 +97,7 @@ function normalizePullRequestBody(body = '') { function isManagedPullRequestBody(body, story) { const normalized = normalizePullRequestBody(body); const managedBodyPattern = new RegExp( - `^## Summary\\nImplements story\\([^)]+\\): .+\\n\\n## Linked story\\nRefs #${String(story.number)}(?:\\n\\n## Knowledge context\\n(?:- .+\\n?)*)?$`, + `^## Summary\\nImplements story\\(#\\d+\\): .+\\n\\n## Linked story\\nRefs #${String(story.number)}(?:\\n\\n## Knowledge context\\n(?:- .+\\n?)*)?$`, ); return managedBodyPattern.test(normalized); From 12ae4624d117e8115240938895af0174442528b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:10:45 +0000 Subject: [PATCH 11/15] Polish review feedback Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/06259a5c-12d1-461b-999b-99a46f09f14a Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- src/automation/lifecycle.js | 2 +- src/commands/epic.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/automation/lifecycle.js b/src/automation/lifecycle.js index 710acbb..fc77948 100644 --- a/src/automation/lifecycle.js +++ b/src/automation/lifecycle.js @@ -97,7 +97,7 @@ function normalizePullRequestBody(body = '') { function isManagedPullRequestBody(body, story) { const normalized = normalizePullRequestBody(body); const managedBodyPattern = new RegExp( - `^## Summary\\nImplements story\\(#\\d+\\): .+\\n\\n## Linked story\\nRefs #${String(story.number)}(?:\\n\\n## Knowledge context\\n(?:- .+\\n?)*)?$`, + `^## Summary\\nImplements story\\(#\\d+\\): [^\\n]+\\n\\n## Linked story\\nRefs #${String(story.number)}(?:\\n\\n## Knowledge context\\n(?:- .+\\n?)*)?$`, ); return managedBodyPattern.test(normalized); diff --git a/src/commands/epic.js b/src/commands/epic.js index b943cf4..132f74a 100644 --- a/src/commands/epic.js +++ b/src/commands/epic.js @@ -19,9 +19,10 @@ function appendSectionListItem(body = '', heading, value) { } const items = match[2] + .replace(/<!--[\s\S]*?-->/g, '') .split('\n') .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('<!--')); + .filter(Boolean); const nextItems = items.includes(nextItem) ? items : [...items, nextItem]; return body.replace(sectionPattern, `${match[1]}${nextItems.join('\n')}\n${match[3]}`); From a98a606aee76044c4bbe070886cf4a25cb990c9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:11:46 +0000 Subject: [PATCH 12/15] Resolve final review nits Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/06259a5c-12d1-461b-999b-99a46f09f14a Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- src/commands/epic.js | 33 ++++++++++++++++++++++++++++----- test/cli.test.js | 3 ++- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/commands/epic.js b/src/commands/epic.js index 132f74a..1976059 100644 --- a/src/commands/epic.js +++ b/src/commands/epic.js @@ -18,11 +18,34 @@ function appendSectionListItem(body = '', heading, value) { return `${body.trimEnd()}\n\n## ${heading}\n${nextItem}\n`; } - const items = match[2] - .replace(/<!--[\s\S]*?-->/g, '') - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); + const items = []; + let inComment = false; + for (const rawLine of match[2].split('\n')) { + let line = rawLine; + if (inComment) { + const commentEnd = line.indexOf('-->'); + if (commentEnd === -1) { + continue; + } + line = line.slice(commentEnd + 3); + inComment = false; + } + while (true) { + const commentStart = line.indexOf('<!--'); + if (commentStart === -1) break; + const commentEnd = line.indexOf('-->', commentStart + 4); + if (commentEnd === -1) { + line = line.slice(0, commentStart); + inComment = true; + break; + } + line = `${line.slice(0, commentStart)}${line.slice(commentEnd + 3)}`; + } + const trimmed = line.trim(); + if (trimmed) { + items.push(trimmed); + } + } const nextItems = items.includes(nextItem) ? items : [...items, nextItem]; return body.replace(sectionPattern, `${match[1]}${nextItems.join('\n')}\n${match[3]}`); diff --git a/test/cli.test.js b/test/cli.test.js index b50e76f..06a5d5e 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -746,6 +746,7 @@ test('applyStoryLifecycle preserves non-managed existing story PR bodies', async test('applyStoryLifecycle resyncs managed PR bodies after a story rename', async () => { const { applyStoryLifecycle } = await import('../src/automation/lifecycle.js'); + const previousTitle = 'story(#7): Implement login'; const story = { number: 42, title: 'story(#7): Implement passwordless login', @@ -756,7 +757,7 @@ test('applyStoryLifecycle resyncs managed PR bodies after a story rename', async const basePullRequest = { number: 88, title: story.title, - body: '## Summary\nImplements story(#7): Implement login\n\n## Linked story\nRefs #42\n', + body: `## Summary\nImplements ${previousTitle}\n\n## Linked story\nRefs #42\n`, url: 'https://example.com/pull/88', isDraft: true, }; From f183330e8829cd3a768266c3e3ea3054192ba413 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:24:34 +0000 Subject: [PATCH 13/15] Handle final review thread Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/ea17ee26-74fa-4436-906f-0c1512463c0b Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- src/automation/lifecycle.js | 8 ++ src/backends/github.js | 28 +++++- src/commands/story.js | 5 +- test/cli.test.js | 176 ++++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 6 deletions(-) diff --git a/src/automation/lifecycle.js b/src/automation/lifecycle.js index fc77948..f9b7d43 100644 --- a/src/automation/lifecycle.js +++ b/src/automation/lifecycle.js @@ -138,6 +138,14 @@ async function syncPullRequestContext(story, pullRequest, { backend }) { return backend.editPullRequest(pullRequest.number, { body }); } +export async function syncExistingStoryPullRequestContext(story, { backend = getBackend() } = {}) { + const existing = await findStoryPullRequest(story, { backend }); + if (!existing) { + return null; + } + return syncPullRequestContext(story, existing, { backend }); +} + export async function ensureStoryPullRequest(story, { backend = getBackend(), base, head } = {}) { const existing = await findStoryPullRequest(story, { backend }); if (existing) return syncPullRequestContext(story, existing, { backend }); diff --git a/src/backends/github.js b/src/backends/github.js index f2a2818..708c570 100644 --- a/src/backends/github.js +++ b/src/backends/github.js @@ -69,6 +69,9 @@ const LABEL_CONFIG = { function ensureLabels(labels) { const existingLabels = listExistingLabels(); + if (existingLabels === null) { + return [...labels]; + } const ensuredLabels = []; for (const label of labels) { if (existingLabels?.has(label)) { @@ -83,15 +86,30 @@ function ensureLabels(labels) { return ensuredLabels; } +function buildIssueCreateArgs({ title, body, labels = [], assignees = [] }) { + const args = ['issue', 'create', '--title', title, '--body', body]; + for (const label of labels) args.push('--label', label); + for (const assignee of assignees) args.push('--assignee', assignee); + return args; +} + +function isUnknownLabelError(error) { + return /label .*does not exist|unknown label|could not add label/i.test(error?.message || ''); +} + // ─── Issues ────────────────────────────────────────────────────────────────── export function createIssue({ title, body, labels = [], assignees = [] }) { const ensuredLabels = ensureLabels(labels); - const args = ['issue', 'create', '--title', title, '--body', body]; - for (const l of ensuredLabels) args.push('--label', l); - for (const a of assignees) args.push('--assignee', a); - - const output = runGh(args); + let output; + try { + output = runGh(buildIssueCreateArgs({ title, body, labels: ensuredLabels, assignees })); + } catch (error) { + if (!ensuredLabels.length || !isUnknownLabelError(error)) { + throw error; + } + output = runGh(buildIssueCreateArgs({ title, body, assignees })); + } return viewIssue(extractIssueNumberFromOutput(output)); } diff --git a/src/commands/story.js b/src/commands/story.js index fb83c1c..563cf2b 100644 --- a/src/commands/story.js +++ b/src/commands/story.js @@ -1,6 +1,6 @@ import { Command } from 'commander'; import getBackend from '../backends/index.js'; -import { applyStoryLifecycle } from '../automation/lifecycle.js'; +import { applyStoryLifecycle, syncExistingStoryPullRequestContext } from '../automation/lifecycle.js'; import { getMetadataField, parseMetadataList, parseReviewerList, setMetadataField, setMetadataListField } from '../utils/metadata.js'; import { storyTemplate } from '../utils/templates.js'; import { formatIssueList, formatIssueDetail, parseIssueTitle, printSuccess, printError } from '../utils/format.js'; @@ -155,6 +155,9 @@ export function makeStoryCommand() { let issue; if (hasDirectEdits) { issue = await backend.editIssue(number, editOpts); + if (!opts.status && (editOpts.title || requestedKnowledgeLinks.length)) { + await syncExistingStoryPullRequestContext(issue, { backend }); + } } if (opts.status) { ({ issue } = await applyStoryLifecycle(number, { diff --git a/test/cli.test.js b/test/cli.test.js index 06a5d5e..bab7c0f 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -692,6 +692,44 @@ test('applyStoryLifecycle syncs knowledge links into an existing story PR', asyn assert.ok(issue.body.includes('wiki/knowledge/auth-plan.md')); }); +test('syncExistingStoryPullRequestContext syncs knowledge links without a lifecycle transition', async () => { + const { syncExistingStoryPullRequestContext } = await import('../src/automation/lifecycle.js'); + const story = { + number: 42, + title: 'story(#7): Implement login', + state: 'OPEN', + body: '## Metadata\n- **Status:** open\n- **Knowledge Links:** wiki/knowledge/auth-plan.md\n- **Linked PR:** https://example.com/pull/88\n', + labels: [{ name: 'user-story' }, { name: 'status:open' }], + }; + const basePullRequest = { + number: 88, + title: story.title, + body: `## Summary\nImplements ${story.title}\n\n## Linked story\nRefs #${story.number}\n`, + url: 'https://example.com/pull/88', + isDraft: true, + }; + let editedPullRequestBody = ''; + + const backend = { + async viewPullRequest() { + return structuredClone(basePullRequest); + }, + async listPullRequests() { + throw new Error('listPullRequests should not be called when Linked PR metadata is present'); + }, + async editPullRequest(number, { body }) { + editedPullRequestBody = body; + return { ...basePullRequest, number, body }; + }, + }; + + const pullRequest = await syncExistingStoryPullRequestContext(story, { backend }); + + assert.equal(pullRequest.number, 88); + assert.ok(editedPullRequestBody.includes('## Knowledge context')); + assert.ok(editedPullRequestBody.includes('wiki/knowledge/auth-plan.md')); +}); + test('applyStoryLifecycle preserves non-managed existing story PR bodies', async () => { const { applyStoryLifecycle } = await import('../src/automation/lifecycle.js'); const story = { @@ -1161,3 +1199,141 @@ process.exit(1); await rm(cwd, { recursive: true, force: true }); } }); + +test('epic create keeps labels when discovery is unavailable but issue creation accepts them', { skip: process.platform === 'win32' }, async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-gh-labels-optimistic-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + const ghLog = join(cwd, 'gh.log'); + const ghScriptPath = join(cwd, 'gh.js'); + const ghPath = join(cwd, process.platform === 'win32' ? 'gh.cmd' : 'gh'); + fs.writeFileSync(ghScriptPath, `#!/usr/bin/env node +import fs from 'node:fs'; +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(ghLog)}, JSON.stringify(args) + '\\n'); +if (args[0] === 'label' && args[1] === 'list') { + console.error('failed to list labels'); + process.exit(1); +} +if (args[0] === 'label' && args[1] === 'create') { + console.error('HTTP 403: Resource not accessible by integration'); + process.exit(1); +} +if (args[0] === 'issue' && args[1] === 'create') { + process.stdout.write('https://github.com/Atena-IT/git-tasks/issues/654\\n'); + process.exit(0); +} +if (args[0] === 'issue' && args[1] === 'view') { + process.stdout.write(JSON.stringify({ + number: 654, + url: 'https://github.com/Atena-IT/git-tasks/issues/654', + title: 'epic: Ship auth', + state: 'OPEN', + body: 'Test body', + labels: [{ name: 'epic' }, { name: 'status:open' }], + assignees: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z' + })); + process.exit(0); +} +console.error('Unexpected gh invocation: ' + args.join(' ')); +process.exit(1); +`); + if (process.platform === 'win32') { + fs.writeFileSync(ghPath, `@echo off\r\nnode "%~dp0\\gh.js" %*\r\n`); + } else { + fs.writeFileSync(ghPath, `#!/usr/bin/env sh\nnode "$(dirname "$0")/gh.js" "$@"\n`); + fs.chmodSync(ghPath, 0o755); + } + + try { + const result = run(['epic', 'create', 'Ship auth', '-d', 'Test body', '-p', '3', '--start', '2026-01-01', '--end', '2026-01-14'], { + cwd, + env: { PATH: `${cwd}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}` }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = fs.readFileSync(ghLog, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + const issueCreate = commands.find((args) => args[0] === 'issue' && args[1] === 'create'); + + assert.ok(issueCreate.includes('--label')); + assert.ok(issueCreate.includes('epic')); + assert.ok(issueCreate.includes('status:open')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test('epic create retries without labels when optimistic labels are unknown', { skip: process.platform === 'win32' }, async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-gh-labels-retry-')); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + const ghLog = join(cwd, 'gh.log'); + const ghScriptPath = join(cwd, 'gh.js'); + const ghPath = join(cwd, process.platform === 'win32' ? 'gh.cmd' : 'gh'); + fs.writeFileSync(ghScriptPath, `#!/usr/bin/env node +import fs from 'node:fs'; +const args = process.argv.slice(2); +let issueCreateAttempts = 0; +if (fs.existsSync(${JSON.stringify(join(cwd, 'attempts.txt'))})) { + issueCreateAttempts = Number(fs.readFileSync(${JSON.stringify(join(cwd, 'attempts.txt'))}, 'utf8')); +} +fs.appendFileSync(${JSON.stringify(ghLog)}, JSON.stringify(args) + '\\n'); +if (args[0] === 'label' && args[1] === 'list') { + console.error('failed to list labels'); + process.exit(1); +} +if (args[0] === 'label' && args[1] === 'create') { + console.error('HTTP 403: Resource not accessible by integration'); + process.exit(1); +} +if (args[0] === 'issue' && args[1] === 'create') { + issueCreateAttempts += 1; + fs.writeFileSync(${JSON.stringify(join(cwd, 'attempts.txt'))}, String(issueCreateAttempts)); + if (issueCreateAttempts === 1) { + console.error('could not add label "epic": label does not exist'); + process.exit(1); + } + process.stdout.write('https://github.com/Atena-IT/git-tasks/issues/655\\n'); + process.exit(0); +} +if (args[0] === 'issue' && args[1] === 'view') { + process.stdout.write(JSON.stringify({ + number: 655, + url: 'https://github.com/Atena-IT/git-tasks/issues/655', + title: 'epic: Ship auth', + state: 'OPEN', + body: 'Test body', + labels: [], + assignees: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z' + })); + process.exit(0); +} +console.error('Unexpected gh invocation: ' + args.join(' ')); +process.exit(1); +`); + if (process.platform === 'win32') { + fs.writeFileSync(ghPath, `@echo off\r\nnode "%~dp0\\gh.js" %*\r\n`); + } else { + fs.writeFileSync(ghPath, `#!/usr/bin/env sh\nnode "$(dirname "$0")/gh.js" "$@"\n`); + fs.chmodSync(ghPath, 0o755); + } + + try { + const result = run(['epic', 'create', 'Ship auth', '-d', 'Test body', '-p', '3', '--start', '2026-01-01', '--end', '2026-01-14'], { + cwd, + env: { PATH: `${cwd}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}` }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = fs.readFileSync(ghLog, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + const issueCreates = commands.filter((args) => args[0] === 'issue' && args[1] === 'create'); + + assert.equal(issueCreates.length, 2); + assert.ok(issueCreates[0].includes('--label')); + assert.ok(!issueCreates[1].includes('--label')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); From 28679b76385922f7c47e9fba64b3e71be63d7722 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:35:02 +0000 Subject: [PATCH 14/15] Fix wiki repo-root resolution Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/cf8602dd-ecc7-4017-b6ca-71d9aee47d82 Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- src/commands/wiki.js | 9 ++++++--- test/cli.test.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/commands/wiki.js b/src/commands/wiki.js index 6569a1e..2e6bd41 100644 --- a/src/commands/wiki.js +++ b/src/commands/wiki.js @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { existsSync, readdirSync, readFileSync, mkdirSync, realpathSync, writeFileSync } from 'fs'; import { dirname, join, relative, resolve, isAbsolute } from 'path'; import { printError } from '../utils/format.js'; +import { resolveRepositoryRoot } from '../utils/config.js'; import chalk from 'chalk'; const WIKI_DIR = 'wiki'; @@ -67,7 +68,8 @@ function listMarkdownFiles(dir, prefix = '') { } function getWikiRoot(rootDir = process.cwd()) { - return resolve(rootDir, WIKI_DIR); + const repoRoot = resolveRepositoryRoot(rootDir) || resolve(rootDir); + return resolve(repoRoot, WIKI_DIR); } export function initializeWiki(rootDir = process.cwd()) { @@ -123,11 +125,12 @@ export function makeWikiCommand() { .description('List wiki markdown files recursively') .action(() => { try { - if (!existsSync(WIKI_DIR)) { + const wikiRoot = getWikiRoot(); + if (!existsSync(wikiRoot)) { console.log(chalk.yellow('Wiki not initialized. Run: git-tasks init')); return; } - const files = listMarkdownFiles(WIKI_DIR); + const files = listMarkdownFiles(wikiRoot); if (!files.length) { console.log(chalk.gray('No wiki files found.')); } else { diff --git a/test/cli.test.js b/test/cli.test.js index bab7c0f..5caedc9 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -310,6 +310,26 @@ test('wiki list shows nested inbox and knowledge markdown files', async () => { } }); +test('wiki list resolves the repo-root wiki from nested directories', async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-list-nested-')); + const nestedDir = join(cwd, 'packages', 'cli'); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + + try { + run(['init'], { cwd }); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(join(cwd, 'wiki', 'knowledge', 'auth-plan.md'), '# Knowledge\n'); + + const result = run(['wiki', 'list'], { cwd: nestedDir }); + + assert.equal(result.status, 0); + assert.ok(result.stdout.includes('knowledge/index.md')); + assert.ok(result.stdout.includes('knowledge/auth-plan.md')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + test('wiki show rejects paths outside wiki/', async () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-show-')); spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); @@ -326,6 +346,25 @@ test('wiki show rejects paths outside wiki/', async () => { } }); +test('wiki show resolves repo-root files from nested directories', async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-show-nested-')); + const nestedDir = join(cwd, 'packages', 'cli'); + spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); + + try { + run(['init'], { cwd }); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(join(cwd, 'wiki', 'knowledge', 'auth-plan.md'), '# Auth Plan\n'); + + const result = run(['wiki', 'show', 'knowledge/auth-plan'], { cwd: nestedDir }); + + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), '# Auth Plan'); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + test('wiki show rejects symlink escapes outside wiki/', { skip: process.platform === 'win32' }, async () => { const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-link-')); const outsideDir = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-wiki-outside-')); From 75d858c4c6de6fc35369638d341287560ab84c07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:36:10 +0000 Subject: [PATCH 15/15] Clarify nested wiki test setup Agent-Logs-Url: https://github.com/Atena-IT/git-tasks/sessions/cf8602dd-ecc7-4017-b6ca-71d9aee47d82 Co-authored-by: DiTo97 <40361744+DiTo97@users.noreply.github.com> --- test/cli.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/cli.test.js b/test/cli.test.js index 5caedc9..57a9d0b 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -316,8 +316,10 @@ test('wiki list resolves the repo-root wiki from nested directories', async () = spawnSync('git', ['init'], { cwd, encoding: 'utf8' }); try { - run(['init'], { cwd }); + const initResult = run(['init'], { cwd }); + assert.equal(initResult.status, 0, initResult.stderr); fs.mkdirSync(nestedDir, { recursive: true }); + assert.equal(fs.existsSync(join(cwd, 'wiki', 'knowledge', 'index.md')), true); fs.writeFileSync(join(cwd, 'wiki', 'knowledge', 'auth-plan.md'), '# Knowledge\n'); const result = run(['wiki', 'list'], { cwd: nestedDir });