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 2188ea7..4b3e21e 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 [-k ] [-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 [-k ] [-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 [-k ] [-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 ] @@ -109,27 +109,75 @@ 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 +git-tasks wiki show inbox/ +git-tasks wiki show knowledge/index ``` ## Agent-friendly usage ```bash +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 ``` +## 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. +- `.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 knowledge workflow + +- Initialize the repository once with `git-tasks init`. +- 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 --reviewer ` 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. -- `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. +- 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/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/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 134debd..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 @@ -21,25 +21,61 @@ 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 --reviewer ` 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`. +## 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. +- 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. 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 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 -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 --knowledge wiki/knowledge/example.md +git-tasks sprint create "Sprint title" --epic -d "description" -p 8 --start YYYY-MM-DD --end YYYY-MM-DD --knowledge wiki/knowledge/example.md +git-tasks story create "Story title" --sprint --epic -d "description" -p 3 --priority high -a --knowledge wiki/knowledge/example.md ``` ### Inspect @@ -56,9 +92,9 @@ git-tasks story show ### Update ```bash -git-tasks epic update --status closed --points 21 -git-tasks sprint update --status closed -git-tasks story update --status in-progress +git-tasks epic update --status closed --knowledge wiki/knowledge/example.md +git-tasks sprint update --status closed --knowledge wiki/knowledge/example.md +git-tasks story update --status in-progress --knowledge wiki/knowledge/example.md git-tasks story update --status ready-for-review --reviewer octocat git-tasks story update --status closed git-tasks story update -a @@ -67,9 +103,10 @@ git-tasks story update -a ### Wiki ```bash -git-tasks wiki init +git-tasks init git-tasks wiki list -git-tasks wiki show +git-tasks wiki show inbox/ +git-tasks wiki show knowledge/index ``` ## Title conventions @@ -78,9 +115,17 @@ git-tasks wiki show - Sprint: `sprint(#): ` - 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. +- 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 a65f296..f9b7d43 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,46 @@ 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 isManagedPullRequestBody(body, story) { + const normalized = normalizePullRequestBody(body); + const managedBodyPattern = new RegExp( + `^## Summary\\nImplements story\\(#\\d+\\): [^\\n]+\\n\\n## Linked story\\nRefs #${String(story.number)}(?:\\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 +124,31 @@ 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 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 existing; + if (existing) return syncPullRequestContext(story, existing, { backend }); const branch = head || backend.getCurrentBranch(); if (!branch) { @@ -106,38 +164,72 @@ 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.`); } - const edit = buildLifecycleEdit(story, status); + const normalizedStatus = normalizeLifecycleStatus(status); + const reviewerList = normalizedStatus === 'ready-for-review' + ? 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, set GIT_TASKS_REVIEWERS, or configure .git-tasks/config.json.'); + } + + 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' && normalizeLifecycleStatus(status) !== 'open') { - pullRequest = await ensureStoryPullRequest(story, { backend, base, head }); + if (edit.state === 'open' && normalizedStatus !== 'open') { + pullRequest = await ensureStoryPullRequest(storyForEdit, { 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/backends/github.js b/src/backends/github.js index 4d8a69a..708c570 100644 --- a/src/backends/github.js +++ b/src/backends/github.js @@ -36,19 +36,24 @@ 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) { - try { - runGh(['label', 'list', '--json', 'name', '--jq', `.[].name`]); - } catch { - // ignore - } try { runGh(['label', 'create', name, '--color', color, '--description', description, '--force']); + return true; } catch { - // label may already exist + return false; } } @@ -63,21 +68,48 @@ 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)) { + 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; +} + +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 = [] }) { - ensureLabels(labels); - const args = ['issue', 'create', '--title', title, '--body', body]; - for (const l of labels) args.push('--label', l); - for (const a of assignees) args.push('--assignee', a); - - const output = runGh(args); + const ensuredLabels = ensureLabels(labels); + 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)); } @@ -160,6 +192,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 +225,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 95fb61a..1976059 100644 --- a/src/commands/epic.js +++ b/src/commands/epic.js @@ -3,6 +3,53 @@ 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 { escapeRegex, getMetadataField, parseMetadataList, setMetadataField, setMetadataListField } from '../utils/metadata.js'; + +function collectValues(value, previous = []) { + return previous.concat(value); +} + +function appendSectionListItem(body = '', heading, value) { + const sectionPattern = new RegExp(`(## ${escapeRegex(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 = []; + 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]}`); +} export function makeEpicCommand() { const epic = new Command('epic').description('Manage epics'); @@ -10,11 +57,12 @@ export function makeEpicCommand() { epic .command('create <title>') .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') + .option('-k, --knowledge <path>', 'Linked knowledge document path', collectValues, []) .action(async (title, opts) => { try { const backend = getBackend(); @@ -24,6 +72,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 +122,38 @@ 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.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); + nextBody = setMetadataListField(nextBody, 'Knowledge Links', knowledgeLinks); + editOpts.body = nextBody; + } + if (!Object.keys(editOpts).length) { + printError('Pass at least one update option.'); + return; + } + 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 new file mode 100644 index 0000000..7c5d50b --- /dev/null +++ b/src/commands/init.js @@ -0,0 +1,66 @@ +import { existsSync, realpathSync } from 'fs'; +import { Command } from 'commander'; +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()) { + 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') + .option('--owner <user>', 'Repository owner or default reviewer username') + .option('-r, --reviewer <user>', 'Default reviewer username', collectValues, []) + .action((opts) => { + try { + 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(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; + } + + 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 cd2fd4b..b69e586 100644 --- a/src/commands/sprint.js +++ b/src/commands/sprint.js @@ -2,7 +2,12 @@ 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 { 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); +} export function makeSprintCommand() { const sprint = new Command('sprint').description('Manage sprints'); @@ -10,16 +15,16 @@ 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') + .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,43 @@ 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 = {}; - if (opts.title) { - const epicPart = opts.epic ? `(#${opts.epic})` : ''; - editOpts.title = `sprint${epicPart}: ${opts.title}`; + let nextBody = currentIssue.body; + 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.status) Object.assign(editOpts, buildLifecycleEdit(currentIssue, opts.status)); + 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); + nextBody = setMetadataListField(nextBody, 'Knowledge Links', knowledgeLinks); + editOpts.body = nextBody; + } + if (!Object.keys(editOpts).length) { + printError('Pass at least one update option.'); + return; + } + 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 fad391c..563cf2b 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 { parseReviewerList } from '../utils/metadata.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, printSuccess, printError } from '../utils/format.js'; +import { formatIssueList, formatIssueDetail, parseIssueTitle, printSuccess, printError } from '../utils/format.js'; function collectValues(value, previous = []) { return previous.concat(value); @@ -15,12 +15,13 @@ 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') + .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,29 +101,73 @@ 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 requestedKnowledgeLinks = parseMetadataList(opts.knowledge); + 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; + } const backend = getBackend(); + const currentIssue = await backend.viewIssue(number); const editOpts = {}; - 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]; + nextBody = setMetadataField(nextBody, 'Assignee', opts.assignee); + editOpts.body = nextBody; } - if (opts.assignee) editOpts.addAssignees = [opts.assignee]; + 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 && (editOpts.title || requestedKnowledgeLinks.length)) { + await syncExistingStoryPullRequestContext(issue, { backend }); + } + } if (opts.status) { ({ issue } = await applyStoryLifecycle(number, { status: opts.status, reviewers: parseReviewerList(opts.reviewer), + knowledgeLinks: hasDirectEdits ? [] : requestedKnowledgeLinks, base: opts.base, head: opts.head, backend, })); - if (Object.keys(editOpts).length) { - issue = await backend.editIssue(number, editOpts); - } - } else { - 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 3199505..2e6bd41 100644 --- a/src/commands/wiki.js +++ b/src/commands/wiki.js @@ -1,55 +1,136 @@ import { Command } from 'commander'; -import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { printSuccess, printError } from '../utils/format.js'; +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'; +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 -- 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/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 \`inbox/discovery.md\` or \`knowledge/index.md\`. `; -export function makeWikiCommand() { - const wiki = new Command('wiki').description('Manage local wiki files'); +const INBOX_WIKI_README = `# Inbox - wiki - .command('init') - .description('Initialize the wiki/ directory with a README.md') - .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 { - console.log(chalk.yellow(`Wiki already initialized at ${readmePath}`)); - } - } catch (err) { - printError(err.message); - } - }); +Use this space for unmodified incoming material from humans or external systems. + +- Drop notes exactly as they arrive. +- 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 KNOWLEDGE_WIKI_README = `# Knowledge + +Use this space for durable knowledge nodes managed by AI and humans. + +- 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 entryPath = join(dir, entry.name); + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + files.push(...listMarkdownFiles(entryPath, relativePath)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(relativePath); + } + } + return files.sort((left, right) => left.localeCompare(right)); +} + +function getWikiRoot(rootDir = process.cwd()) { + const repoRoot = resolveRepositoryRoot(rootDir) || resolve(rootDir); + return resolve(repoRoot, WIKI_DIR); +} + +export function initializeWiki(rootDir = process.cwd()) { + const wikiRoot = getWikiRoot(rootDir); + const files = [ + [join(wikiRoot, 'README.md'), 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 = []; + + 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 }; +} + +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); + 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; +} + +export function makeWikiCommand() { + const wiki = new Command('wiki').description('Manage local wiki files for inbox inputs and structured knowledge'); 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')); + const wikiRoot = getWikiRoot(); + if (!existsSync(wikiRoot)) { + console.log(chalk.yellow('Wiki not initialized. Run: git-tasks init')); return; } - const files = readdirSync(WIKI_DIR).filter(f => f.endsWith('.md')); + const files = listMarkdownFiles(wikiRoot); if (!files.length) { console.log(chalk.gray('No wiki files found.')); } else { @@ -62,10 +143,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 inbox/ or knowledge/ 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/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..93cdcb4 100644 --- a/src/utils/metadata.js +++ b/src/utils/metadata.js @@ -1,7 +1,15 @@ -function escapeRegex(value) { +export 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 5428e21..57a9d0b 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)); @@ -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,37 +69,162 @@ 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('epic create --help shows options', () => { +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' }); + + 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(result.stdout.includes('--owner')); + assert.ok(result.stdout.includes('--reviewer') || result.stdout.includes('-r')); +}); + +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' }); + + 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('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', () => { + 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', () => { +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 () => { @@ -140,26 +266,139 @@ 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/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 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'); +test('wiki list warns with the renamed command when wiki is missing', async () => { + const cwd = fs.mkdtempSync(join(os.tmpdir(), 'git-tasks-list-')); - assert.equal(result.status, 0); - assert.ok(readme.includes('managed by git-tasks')); - assert.ok(readme.includes('git-tasks wiki list')); + 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 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 }); +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' }); - assert.equal(result.status, 0); - assert.ok(result.stdout.includes('Run: git-tasks wiki init')); + 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'); + + 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 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 { + 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 }); + + 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' }); + + try { + run(['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.')); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +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-')); + 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'); + + 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 () => { @@ -222,8 +461,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 = []; @@ -240,19 +481,453 @@ 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 () => { + 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('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('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 = { + 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 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', + 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 ${previousTitle}\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 = { + 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 () => { @@ -315,11 +990,26 @@ 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('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' }); 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'); @@ -350,10 +1040,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'], { + 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}` }, }); @@ -368,8 +1063,318 @@ 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 }); } }); + +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' }); + 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 }); + } +}); + +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 }); + } +}); 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." + ] + } + ] +}