From 1cb8af15f52bc035fd1e162ea7cfe639984241be Mon Sep 17 00:00:00 2001 From: StatPan Date: Sun, 24 May 2026 20:09:47 +0900 Subject: [PATCH] feat: add read-only feature map commands --- docs-site/.vitepress/config.mts | 1 + docs-site/agent-operator-skill.md | 3 + docs-site/command-capabilities.md | 3 + docs-site/command-reference.md | 85 +++++ docs-site/feature-map.md | 52 +++ docs-site/index.md | 2 + docs/feature-map.md | 99 +++++ docs/skills/gira-agent-operator.md | 3 + internal/cli/cli.go | 255 +++++++++++++ internal/cli/cli_test.go | 65 ++++ internal/gira/command_registry.go | 55 +++ internal/gira/feature_map.go | 593 +++++++++++++++++++++++++++++ internal/gira/feature_map_test.go | 86 +++++ 13 files changed, 1302 insertions(+) create mode 100644 docs-site/feature-map.md create mode 100644 docs/feature-map.md create mode 100644 internal/gira/feature_map.go create mode 100644 internal/gira/feature_map_test.go diff --git a/docs-site/.vitepress/config.mts b/docs-site/.vitepress/config.mts index 219fef5..6806c81 100644 --- a/docs-site/.vitepress/config.mts +++ b/docs-site/.vitepress/config.mts @@ -47,6 +47,7 @@ export default defineConfig({ items: [ { text: 'Workspace', link: '/workspace' }, { text: 'Ticket Workflow', link: '/ticket-workflow' }, + { text: 'Feature Map', link: '/feature-map' }, { text: 'State Model', link: '/state-model' }, { text: 'Goal Mode', link: '/goal-mode' }, { text: 'Sprint And Release', link: '/sprint-release' }, diff --git a/docs-site/agent-operator-skill.md b/docs-site/agent-operator-skill.md index 3a35871..f927c19 100644 --- a/docs-site/agent-operator-skill.md +++ b/docs-site/agent-operator-skill.md @@ -30,6 +30,9 @@ summarize that skill instead of redefining it. - `gira ticket wait [TICKET] [--repo OWNER/REPO] [--timeout 5m] [--interval 5s]`: Wait for pending linked PR checks without merging. - `gira ticket finish [TICKET] --dry-run|--apply [--repo OWNER/REPO] [--sync-local]`: Merge the linked PR when policy allows and close the ticket loop without local checkout sync by default. - `gira ticket status [TICKET] [--repo OWNER/REPO] [--json]`: Report ticket status, linked PR blockers, and next action. +- `gira feature check [--repo OWNER/REPO] [--limit N] [--json]`: Validate optional feature map records and work links without mutating GitHub. +- `gira feature for ISSUE [--repo OWNER/REPO] [--limit N] [--json]`: Show which feature or capability a work issue is linked to. +- `gira feature list [--repo OWNER/REPO] [--limit N] [--json]`: List optional issue-backed feature or capability records. - `gira goal finish [GOAL] --dry-run|--apply [--repo OWNER/REPO] [--terminal done|human_review|blocked|superseded|abandoned] [--json]`: Preview goal finish readiness and apply human-review handoff receipts. - `gira goal next [GOAL] [--repo OWNER/REPO] [--json]`: Select the next safe child ticket for a goal or explain why work must stop. - `gira goal plan [GOAL] --dry-run [--repo OWNER/REPO] [--json]`: Propose dry-run child ticket packets from a goal issue without mutation. diff --git a/docs-site/command-capabilities.md b/docs-site/command-capabilities.md index 37df308..1c07ee0 100644 --- a/docs-site/command-capabilities.md +++ b/docs-site/command-capabilities.md @@ -6,6 +6,9 @@ Schema version: `gira-command-capabilities/v1` | Command | Aliases | Capability | JSON support | Mutation boundary | Docs | | --- | --- | --- | --- | --- | --- | +| `gira feature check` | gira feat check | `read` | `stable_json` | none | docs/feature-map.md, docs-site/feature-map.md, docs-site/command-reference.md | +| `gira feature for` | gira feat for | `read` | `stable_json` | none | docs/feature-map.md, docs-site/feature-map.md, docs-site/command-reference.md | +| `gira feature list` | gira feat list | `read` | `stable_json` | none | docs/feature-map.md, docs-site/feature-map.md, docs-site/command-reference.md | | `gira goal finish` | none | `apply_mutation` | `stable_json` | posts an idempotent goal finish handoff receipt when run with --apply; --dry-run previews readiness and receipt | docs/goal-operating-model.md, docs-site/command-reference.md | | `gira goal next` | none | `read` | `stable_json` | none | docs/goal-operating-model.md, docs-site/command-reference.md | | `gira goal plan` | none | `dry_run_mutation` | `stable_json` | computes child ticket proposals only; no apply surface exists for this command | docs/goal-operating-model.md, docs-site/command-reference.md | diff --git a/docs-site/command-reference.md b/docs-site/command-reference.md index 08e722c..e9e44e2 100644 --- a/docs-site/command-reference.md +++ b/docs-site/command-reference.md @@ -2,6 +2,91 @@ This page is generated from Gira's command metadata registry. Update `internal/gira/command_registry.go` first, then refresh this page. +## `feature check` + +Validate optional feature map records and work links without mutating GitHub. + +Usage: + +```bash +gira feature check [--repo OWNER/REPO] [--limit N] [--json] +``` + +Since: `v1.18.0` + +Flags: + +- `--repo`: Target GitHub repo in OWNER/REPO format. +- `--limit`: Max issues to inspect. Default: 1000. +- `--json`: Emit stable feature-map-check/v1 JSON. + +Examples: + +- Check feature map health + +```bash +gira feat check --repo OWNER/backlog +``` + +Documented in: `docs/feature-map.md`, `docs-site/feature-map.md`, `docs-site/command-reference.md` + +## `feature for` + +Show which feature or capability a work issue is linked to. + +Usage: + +```bash +gira feature for ISSUE [--repo OWNER/REPO] [--limit N] [--json] +``` + +Since: `v1.18.0` + +Flags: + +- `--repo`: Target GitHub repo in OWNER/REPO format. +- `--issue`: Work issue number. Can also be numeric positional. +- `--limit`: Max issues to inspect. Default: 1000. +- `--json`: Emit stable feature-map-for/v1 JSON. + +Examples: + +- Inspect one work issue + +```bash +gira feat for 123 --repo OWNER/app +``` + +Documented in: `docs/feature-map.md`, `docs-site/feature-map.md`, `docs-site/command-reference.md` + +## `feature list` + +List optional issue-backed feature or capability records. + +Usage: + +```bash +gira feature list [--repo OWNER/REPO] [--limit N] [--json] +``` + +Since: `v1.18.0` + +Flags: + +- `--repo`: Target GitHub repo in OWNER/REPO format. +- `--limit`: Max issues to inspect. Default: 1000. +- `--json`: Emit stable feature-map-list/v1 JSON. + +Examples: + +- List feature records + +```bash +gira feat list --repo OWNER/backlog +``` + +Documented in: `docs/feature-map.md`, `docs-site/feature-map.md`, `docs-site/command-reference.md` + ## `goal finish` Preview goal finish readiness and apply human-review handoff receipts. diff --git a/docs-site/feature-map.md b/docs-site/feature-map.md new file mode 100644 index 0000000..8852c83 --- /dev/null +++ b/docs-site/feature-map.md @@ -0,0 +1,52 @@ +# Feature Map + +Gira supports an optional issue-backed feature map for teams that want a durable +capability view in GitHub. + +The model is: + +| Surface | Role | +| --- | --- | +| GitHub issue | Canonical feature or capability record. | +| GitHub Project | Visibility view for the map, roadmap, and todo slices. | +| Milestone | Delivery batch for executable work. | +| PR | Implementation evidence. | +| Gira | Read-only checker/compiler. | + +Feature records are GitHub issues labeled `type:capability` or `type:feature`, +or issues titled with `Capability:` or `Feature:`. + +```markdown +Key: tl +Status: stable + +## User Need +## Capability +## Surface +## Docs +## Evidence +``` + +Work issues can link back with: + +```markdown +Related capability: #31 +``` + +Start with the read-only commands: + +```bash +gira feature list --repo OWNER/REPO +gira feature check --repo OWNER/REPO +gira feature for 123 --repo OWNER/REPO +``` + +For daily typing, use the short alias: + +```bash +gira feat check +gira feat for 123 +``` + +If no feature records exist, the map is treated as not configured. Normal ticket +lifecycle commands keep working. diff --git a/docs-site/index.md b/docs-site/index.md index b94c836..d1c2c99 100644 --- a/docs-site/index.md +++ b/docs-site/index.md @@ -36,6 +36,7 @@ features: | Set up a repo or personal workspace | [Quick Start](/quickstart), [Global Config](/global-config) | `gira init`, `gira setup global`, `gira repo register`, `gira adopt repo` | | See work across repos | [Workspace](/workspace) | `gira workspace status`, `gira workspace repos sync` | | Run issue to PR work | [Ticket Workflow](/ticket-workflow) | `gira ticket new`, `start`, `pr`, `review`, `checks`, `wait`, `finish` | +| Maintain an optional feature map | [Feature Map](/feature-map) | `gira feature list`, `feature check`, `feature for` | | Manage larger work packets | [Goal Mode](/goal-mode), [Sprint And Release](/sprint-release) | `gira goal status`, `goal next`, `goal finish`, `epic list`, `milestone plan` | | Diagnose readiness and drift | [Readiness And Audit](/readiness-audit), [Troubleshooting](/troubleshooting) | `gira ticket status`, `ticket review`, `audit drift`, `jira doctor` | | Map Jira concepts to GitHub | [Jira Mapping](/jira-mapping), [Jira Provider](/jira-primary-provider) | `gira jira init`, `jira mirror`, `jira transition`, `jira import`, `jira export` | @@ -47,6 +48,7 @@ features: | `guide` | Built-in quickstart, ticket, stats, Jira, agent, skill, and concepts guides in the installed CLI. | [Quick Start](/quickstart), [Command Reference](/command-reference) | | `setup`, `init`, `repo`, `adopt`, `config` | First-run setup, global registry entries, repo adoption, issue adoption, and config source diagnosis. | [Global Config](/global-config), [Troubleshooting](/troubleshooting) | | `workspace`, `projects`, `status` | Multi-repo workspace status, repo allowlist sync, existing Project mirroring, and compact read-only repo summaries. | [Workspace](/workspace) | +| `feature`, `feat` | Optional issue-backed feature map listing, validation, and work issue linkage checks. | [Feature Map](/feature-map) | | `ticket`, `start`, `work`, `dev` | Daily issue to branch to PR to finish lifecycle, plus compatibility aliases and lower-level helpers. | [Ticket Workflow](/ticket-workflow) | | `goal`, `epic`, `milestone`, `sprint`, `release` | Larger objective tracking, child ticket selection, milestone batches, sprint planning, and release readiness. | [Goal Mode](/goal-mode), [Sprint And Release](/sprint-release) | | `audit`, `jira` | Drift, readiness, provider compatibility, Jira mirror, transition planning, import, and export diagnostics. | [Readiness And Audit](/readiness-audit), [Jira Provider](/jira-primary-provider) | diff --git a/docs/feature-map.md b/docs/feature-map.md new file mode 100644 index 0000000..ecaae7b --- /dev/null +++ b/docs/feature-map.md @@ -0,0 +1,99 @@ +# Issue-Backed Feature Map + +Gira's feature map is an optional capability map over GitHub issues. It is for +operators who want to keep a durable view of product capabilities while still +using milestones and tickets as the delivery queue. + +The feature map is not required for normal ticket lifecycle work. If a repo has +no feature records, `gira feature check` reports the map as not configured and +does not block work. + +## Ownership + +| Surface | Role | +| --- | --- | +| GitHub issue | Canonical feature or capability record. | +| GitHub Project | Visibility view for PM-style map, roadmap, and todo views. | +| Milestone | Delivery batch for executable work issues. | +| PR | Implementation evidence for a linked work issue. | +| Docs | Optional snapshot or public contract. | +| Gira | Read-only checker/compiler for links, sections, and maturity state. | + +This keeps the Project screen useful without making Project-only items the +source of truth. + +## Feature Records + +A feature record is a GitHub issue that either: + +- has `type:capability` or `type:feature`; or +- has a title beginning with `Capability:` or `Feature:`. + +Recommended body shape: + +```markdown +# Capability: Ticket lifecycle + +Key: tl +Status: stable + +## User Need + +## Capability + +## Surface + +## Docs + +## Evidence +``` + +Allowed maturity values are: + +- `optional` +- `planned` +- `preview` +- `stable` +- `legacy` +- `deprecated` + +Maturity can be recorded as a body line such as `Status: stable`, or as a label +such as `capability:stable` or `feature:stable` when the repo chooses to create +those labels. `Key:` is a short daily identifier so operators can avoid typing +long slugs. + +## Work Links + +Executable work remains normal Gira tickets. A work issue can link to a feature +record with a readable body line: + +```markdown +Related capability: #31 +``` + +or: + +```markdown +Feature: #31 +``` + +In the first slice, Gira only reads these links. Mutation helpers such as +`gira feat link` are planned separately. + +## Commands + +```bash +gira feature list --repo OWNER/REPO +gira feature check --repo OWNER/REPO +gira feature for 123 --repo OWNER/REPO +``` + +`gira feat` is the short alias for daily use: + +```bash +gira feat check +gira feat for 123 +``` + +`check` validates feature records and work links. In optional mode, missing +feature links are diagnostics, not ticket readiness blockers. diff --git a/docs/skills/gira-agent-operator.md b/docs/skills/gira-agent-operator.md index c494c23..d0ce7d9 100644 --- a/docs/skills/gira-agent-operator.md +++ b/docs/skills/gira-agent-operator.md @@ -98,6 +98,9 @@ This generated section contains command facts for the agent lifecycle. Update `i - `gira ticket wait [TICKET] [--repo OWNER/REPO] [--timeout 5m] [--interval 5s]`: Wait for pending linked PR checks without merging. - `gira ticket finish [TICKET] --dry-run|--apply [--repo OWNER/REPO] [--sync-local]`: Merge the linked PR when policy allows and close the ticket loop without local checkout sync by default. - `gira ticket status [TICKET] [--repo OWNER/REPO] [--json]`: Report ticket status, linked PR blockers, and next action. +- `gira feature check [--repo OWNER/REPO] [--limit N] [--json]`: Validate optional feature map records and work links without mutating GitHub. +- `gira feature for ISSUE [--repo OWNER/REPO] [--limit N] [--json]`: Show which feature or capability a work issue is linked to. +- `gira feature list [--repo OWNER/REPO] [--limit N] [--json]`: List optional issue-backed feature or capability records. - `gira goal finish [GOAL] --dry-run|--apply [--repo OWNER/REPO] [--terminal done|human_review|blocked|superseded|abandoned] [--json]`: Preview goal finish readiness and apply human-review handoff receipts. - `gira goal next [GOAL] [--repo OWNER/REPO] [--json]`: Select the next safe child ticket for a goal or explain why work must stop. - `gira goal plan [GOAL] --dry-run [--repo OWNER/REPO] [--json]`: Propose dry-run child ticket packets from a goal issue without mutation. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f708753..8d05949 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -31,6 +31,7 @@ Daily commands: repo Manage global registry entries for repositories adopt Plan or apply adoption for existing repositories and issues ticket Jira-style ticket lifecycle commands + feature Optional issue-backed feature map commands. Alias: feat goal Goal-mode read-only planning and status commands epic Numberless epic status and finish commands milestone Milestone lifecycle and bulk ticket assignment @@ -965,6 +966,34 @@ Flags: -h, --help Show help ` +const featureHelp = `Optional issue-backed feature map commands. + +Usage: + gira feature list [--repo OWNER/REPO] [--limit N] [--json] + gira feature check [--repo OWNER/REPO] [--limit N] [--json] + gira feature for ISSUE [--repo OWNER/REPO] [--limit N] [--json] + gira feat list|check|for ... (alias) + +Commands: + list List feature/capability records backed by GitHub issues + check Validate optional feature map records and work links + for Show which feature/capability a work issue is linked to + +Conventions: + Feature records are GitHub issues labeled type:capability or type:feature, + or issues whose title starts with Capability: or Feature:. + Short daily keys can be recorded in the issue body as Key: VALUE. + Work issues can link back with Related capability: #N or Feature: #N. + GitHub Projects are visibility views; GitHub issues remain canonical. + +Flags: + --repo string Target GitHub repo in OWNER/REPO format. Defaults to .gira config or git origin + --issue int Work issue number for feature for. Can also be numeric positional + --limit int Max issues to inspect. Default: 1000 + --json Emit stable JSON output + -h, --help Show help +` + const opsHelp = `Advanced Gira controls. Usage: @@ -1357,6 +1386,18 @@ var newTicketListReport = func(options gira.TicketListOptions) (gira.TicketListR return gira.BuildTicketListReport(options, devCommandRunner) } +var newFeatureMapListReport = func(options gira.FeatureMapOptions) (gira.FeatureMapListReport, error) { + return gira.BuildFeatureMapListReport(options, devCommandRunner) +} + +var newFeatureMapCheckReport = func(options gira.FeatureMapOptions) (gira.FeatureMapCheckReport, error) { + return gira.BuildFeatureMapCheckReport(options, devCommandRunner) +} + +var newFeatureMapForReport = func(options gira.FeatureForOptions) (gira.FeatureMapForReport, error) { + return gira.BuildFeatureMapForReport(options, devCommandRunner) +} + var newMilestoneNewReport = func(input gira.MilestoneNewInput) (gira.MilestoneReport, error) { return gira.BuildMilestoneNewReport(input, devCommandRunner) } @@ -1506,6 +1547,8 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { return runTicketStart(args[1:], stdout, stderr) case "ticket": return runTicket(args[1:], stdout, stderr) + case "feature", "feat": + return runFeature(args[1:], stdout, stderr) case "goal": return runGoal(args[1:], stdout, stderr) case "epic": @@ -3026,6 +3069,186 @@ func runTicket(args []string, stdout io.Writer, stderr io.Writer) int { } } +func runFeature(args []string, stdout io.Writer, stderr io.Writer) int { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + _, _ = io.WriteString(stdout, featureHelp) + return 0 + } + switch args[0] { + case "list": + return runFeatureList(args[1:], stdout, stderr) + case "check": + return runFeatureCheck(args[1:], stdout, stderr) + case "for": + return runFeatureFor(args[1:], stdout, stderr) + default: + fmt.Fprintf(stderr, "unknown feature command: %s\n\n", args[0]) + _, _ = io.WriteString(stderr, featureHelp) + return 2 + } +} + +func runFeatureList(args []string, stdout io.Writer, stderr io.Writer) int { + fs := flag.NewFlagSet("feature list", flag.ContinueOnError) + fs.SetOutput(io.Discard) + repoValue := fs.String("repo", "", "Target GitHub repo in OWNER/REPO format") + limit := fs.Int("limit", 1000, "Max issues to inspect") + jsonOutput := fs.Bool("json", false, "Emit stable JSON output") + help := fs.Bool("help", false, "Show help") + fs.BoolVar(help, "h", false, "Show help") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(stderr, "%v\n\n", err) + _, _ = io.WriteString(stderr, featureHelp) + return 2 + } + if *help { + _, _ = io.WriteString(stdout, featureHelp) + return 0 + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "unexpected argument: %s\n\n", fs.Arg(0)) + _, _ = io.WriteString(stderr, featureHelp) + return 2 + } + repo, err := gira.ResolveRepoContext(*repoValue, repoContextRunner) + if err != nil { + fmt.Fprintf(stderr, "%v\n", err) + return 2 + } + report, err := newFeatureMapListReport(gira.FeatureMapOptions{Repo: repo, Limit: *limit}) + if err != nil { + if *jsonOutput { + out, _ := json.MarshalIndent(report, "", " ") + fmt.Fprintf(stdout, "%s\n", out) + } + fmt.Fprintf(stderr, "%v\n", err) + return 1 + } + if *jsonOutput { + out, err := json.MarshalIndent(report, "", " ") + if err != nil { + fmt.Fprintf(stderr, "encode feature list JSON: %v\n", err) + return 2 + } + fmt.Fprintf(stdout, "%s\n", out) + return 0 + } + fmt.Fprint(stdout, gira.FormatFeatureMapList(report)) + return 0 +} + +func runFeatureCheck(args []string, stdout io.Writer, stderr io.Writer) int { + fs := flag.NewFlagSet("feature check", flag.ContinueOnError) + fs.SetOutput(io.Discard) + repoValue := fs.String("repo", "", "Target GitHub repo in OWNER/REPO format") + limit := fs.Int("limit", 1000, "Max issues to inspect") + jsonOutput := fs.Bool("json", false, "Emit stable JSON output") + help := fs.Bool("help", false, "Show help") + fs.BoolVar(help, "h", false, "Show help") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(stderr, "%v\n\n", err) + _, _ = io.WriteString(stderr, featureHelp) + return 2 + } + if *help { + _, _ = io.WriteString(stdout, featureHelp) + return 0 + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "unexpected argument: %s\n\n", fs.Arg(0)) + _, _ = io.WriteString(stderr, featureHelp) + return 2 + } + repo, err := gira.ResolveRepoContext(*repoValue, repoContextRunner) + if err != nil { + fmt.Fprintf(stderr, "%v\n", err) + return 2 + } + report, err := newFeatureMapCheckReport(gira.FeatureMapOptions{Repo: repo, Limit: *limit}) + if err != nil { + if *jsonOutput { + out, _ := json.MarshalIndent(report, "", " ") + fmt.Fprintf(stdout, "%s\n", out) + } + fmt.Fprintf(stderr, "%v\n", err) + return 1 + } + if *jsonOutput { + out, err := json.MarshalIndent(report, "", " ") + if err != nil { + fmt.Fprintf(stderr, "encode feature check JSON: %v\n", err) + return 2 + } + fmt.Fprintf(stdout, "%s\n", out) + return 0 + } + fmt.Fprint(stdout, gira.FormatFeatureMapCheck(report)) + return 0 +} + +func runFeatureFor(args []string, stdout io.Writer, stderr io.Writer) int { + args, positionalIssue, ok := extractFeatureForPositional(args, stderr) + if !ok { + _, _ = io.WriteString(stderr, featureHelp) + return 2 + } + fs := flag.NewFlagSet("feature for", flag.ContinueOnError) + fs.SetOutput(io.Discard) + repoValue := fs.String("repo", "", "Target GitHub repo in OWNER/REPO format") + issue := fs.Int("issue", 0, "Work issue number") + limit := fs.Int("limit", 1000, "Max issues to inspect") + jsonOutput := fs.Bool("json", false, "Emit stable JSON output") + help := fs.Bool("help", false, "Show help") + fs.BoolVar(help, "h", false, "Show help") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(stderr, "%v\n\n", err) + _, _ = io.WriteString(stderr, featureHelp) + return 2 + } + if *help { + _, _ = io.WriteString(stdout, featureHelp) + return 0 + } + if positionalIssue > 0 { + if *issue > 0 && *issue != positionalIssue { + fmt.Fprint(stderr, "--issue and positional issue must refer to the same number\n\n") + _, _ = io.WriteString(stderr, featureHelp) + return 2 + } + *issue = positionalIssue + } + if *issue <= 0 { + fmt.Fprint(stderr, "--issue or positional issue is required\n\n") + _, _ = io.WriteString(stderr, featureHelp) + return 2 + } + repo, err := gira.ResolveRepoContext(*repoValue, repoContextRunner) + if err != nil { + fmt.Fprintf(stderr, "%v\n", err) + return 2 + } + report, err := newFeatureMapForReport(gira.FeatureForOptions{Repo: repo, Issue: *issue, Limit: *limit}) + if err != nil { + if *jsonOutput { + out, _ := json.MarshalIndent(report, "", " ") + fmt.Fprintf(stdout, "%s\n", out) + } + fmt.Fprintf(stderr, "%v\n", err) + return 1 + } + if *jsonOutput { + out, err := json.MarshalIndent(report, "", " ") + if err != nil { + fmt.Fprintf(stderr, "encode feature for JSON: %v\n", err) + return 2 + } + fmt.Fprintf(stdout, "%s\n", out) + return 0 + } + fmt.Fprint(stdout, gira.FormatFeatureMapFor(report)) + return 0 +} + func runGoal(args []string, stdout io.Writer, stderr io.Writer) int { if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { _, _ = io.WriteString(stdout, goalHelp) @@ -4666,6 +4889,38 @@ func extractNumericPositional(args []string, noun string, stderr io.Writer) ([]s return cleaned, positional, true } +func extractFeatureForPositional(args []string, stderr io.Writer) ([]string, int, bool) { + cleaned := make([]string, 0, len(args)) + positional := 0 + valueFlags := map[string]struct{}{"--repo": {}, "--issue": {}, "--limit": {}} + for i := 0; i < len(args); i++ { + arg := args[i] + cleaned = append(cleaned, arg) + if _, ok := valueFlags[arg]; ok { + if i+1 < len(args) { + i++ + cleaned = append(cleaned, args[i]) + } + continue + } + if strings.HasPrefix(arg, "-") { + continue + } + n, err := strconv.Atoi(arg) + if err != nil || n <= 0 { + fmt.Fprintf(stderr, "unexpected positional argument %q; use a numeric issue or --issue N\n\n", arg) + return nil, 0, false + } + if positional > 0 && positional != n { + fmt.Fprint(stderr, "only one positional issue can be provided\n\n") + return nil, 0, false + } + positional = n + cleaned = cleaned[:len(cleaned)-1] + } + return cleaned, positional, true +} + func extractTicketPromptRolePositional(args []string, stderr io.Writer) ([]string, string, bool) { cleaned := make([]string, 0, len(args)) role := "" diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 067b2b6..4857cb8 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -134,6 +134,71 @@ func TestConfigUnknownCommand(t *testing.T) { } } +func TestFeatureAliasCheckCommand(t *testing.T) { + original := newFeatureMapCheckReport + t.Cleanup(func() { newFeatureMapCheckReport = original }) + newFeatureMapCheckReport = func(options gira.FeatureMapOptions) (gira.FeatureMapCheckReport, error) { + if options.Repo.FullName() != "StatPan/backlog" || options.Limit != 25 { + t.Fatalf("unexpected feature check options: %+v", options) + } + return gira.FeatureMapCheckReport{ + SchemaVersion: gira.FeatureMapCheckSchemaVersion, + Command: "feature check", + Repo: options.Repo.FullName(), + Source: "github_issues", + Mode: "none", + Limit: options.Limit, + Diagnostics: []gira.FeatureMapDiagnostic{ + {Severity: "info", Code: "feature_map_not_configured", Message: "no issue-backed feature records found"}, + }, + NextStep: "feature map is optional; no action required", + }, nil + } + + var stdout, stderr bytes.Buffer + code := Run([]string{"feat", "check", "--repo", "StatPan/backlog", "--limit", "25"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + for _, want := range []string{"feature map check: StatPan/backlog", "feature_map_not_configured", "feature map is optional"} { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("feature check output missing %q:\n%s", want, stdout.String()) + } + } +} + +func TestFeatureForCommandJSON(t *testing.T) { + original := newFeatureMapForReport + t.Cleanup(func() { newFeatureMapForReport = original }) + newFeatureMapForReport = func(options gira.FeatureForOptions) (gira.FeatureMapForReport, error) { + if options.Repo.FullName() != "StatPan/backlog" || options.Issue != 41 { + t.Fatalf("unexpected feature for options: %+v", options) + } + feature := gira.FeatureMapFeature{Number: 31, Title: "Capability: Ticket lifecycle", Key: "tl", Maturity: "stable"} + return gira.FeatureMapForReport{ + SchemaVersion: gira.FeatureMapForSchemaVersion, + Command: "feature for", + Repo: options.Repo.FullName(), + Issue: gira.FeatureMapWorkIssue{Number: options.Issue, Title: "Add finish receipt validation", LinkedFeature: 31}, + Feature: &feature, + NextStep: "gira ticket status 41 --repo StatPan/backlog", + }, nil + } + + var stdout, stderr bytes.Buffer + code := Run([]string{"feature", "for", "41", "--repo", "StatPan/backlog", "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + var report gira.FeatureMapForReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode feature for JSON: %v\n%s", err, stdout.String()) + } + if report.Feature == nil || report.Feature.Key != "tl" || report.Issue.LinkedFeature != 31 { + t.Fatalf("unexpected feature for JSON: %+v", report) + } +} + func TestSetupGlobalCommandJSON(t *testing.T) { original := newSetupGlobalReport t.Cleanup(func() { newSetupGlobalReport = original }) diff --git a/internal/gira/command_registry.go b/internal/gira/command_registry.go index f3bfa63..f6abd9d 100644 --- a/internal/gira/command_registry.go +++ b/internal/gira/command_registry.go @@ -124,6 +124,55 @@ func CoreCommandSpecs() []CommandSpec { {Summary: "Inspect a bounded subset", Command: "gira workspace status --limit 10 --active-only"}, }, }, + { + Path: []string{"feature", "list"}, + Summary: "List optional issue-backed feature or capability records.", + Usage: "gira feature list [--repo OWNER/REPO] [--limit N] [--json]", + Since: "v1.18.0", + Flags: []FlagSpec{ + {Name: "--repo", Summary: "Target GitHub repo in OWNER/REPO format."}, + {Name: "--limit", Summary: "Max issues to inspect. Default: 1000."}, + {Name: "--json", Summary: "Emit stable feature-map-list/v1 JSON."}, + }, + Docs: []string{"docs/feature-map.md", "docs-site/feature-map.md", "docs-site/command-reference.md"}, + GuideTopics: []string{"agent", "ticket"}, + Examples: []CommandExample{ + {Summary: "List feature records", Command: "gira feat list --repo OWNER/backlog"}, + }, + }, + { + Path: []string{"feature", "check"}, + Summary: "Validate optional feature map records and work links without mutating GitHub.", + Usage: "gira feature check [--repo OWNER/REPO] [--limit N] [--json]", + Since: "v1.18.0", + Flags: []FlagSpec{ + {Name: "--repo", Summary: "Target GitHub repo in OWNER/REPO format."}, + {Name: "--limit", Summary: "Max issues to inspect. Default: 1000."}, + {Name: "--json", Summary: "Emit stable feature-map-check/v1 JSON."}, + }, + Docs: []string{"docs/feature-map.md", "docs-site/feature-map.md", "docs-site/command-reference.md"}, + GuideTopics: []string{"agent", "ticket"}, + Examples: []CommandExample{ + {Summary: "Check feature map health", Command: "gira feat check --repo OWNER/backlog"}, + }, + }, + { + Path: []string{"feature", "for"}, + Summary: "Show which feature or capability a work issue is linked to.", + Usage: "gira feature for ISSUE [--repo OWNER/REPO] [--limit N] [--json]", + Since: "v1.18.0", + Flags: []FlagSpec{ + {Name: "--repo", Summary: "Target GitHub repo in OWNER/REPO format."}, + {Name: "--issue", Summary: "Work issue number. Can also be numeric positional."}, + {Name: "--limit", Summary: "Max issues to inspect. Default: 1000."}, + {Name: "--json", Summary: "Emit stable feature-map-for/v1 JSON."}, + }, + Docs: []string{"docs/feature-map.md", "docs-site/feature-map.md", "docs-site/command-reference.md"}, + GuideTopics: []string{"agent", "ticket"}, + Examples: []CommandExample{ + {Summary: "Inspect one work issue", Command: "gira feat for 123 --repo OWNER/app"}, + }, + }, { Path: []string{"goal", "status"}, Summary: "Summarize a goal issue, child ticket graph, blockers, and next safe action.", @@ -655,6 +704,12 @@ func applyAdapterCapabilities(specs []CommandSpec) { specs[i].Adapter = adapterApply("updates workspace repo allowlist; --dry-run previews selected repositories", JSONSupportStable) case "workspace status": specs[i].Adapter = adapterRead(JSONSupportStable) + case "feature list": + specs[i].Adapter = adapterRead(JSONSupportStable, "gira feat list") + case "feature check": + specs[i].Adapter = adapterRead(JSONSupportStable, "gira feat check") + case "feature for": + specs[i].Adapter = adapterRead(JSONSupportStable, "gira feat for") case "goal status": specs[i].Adapter = adapterRead(JSONSupportStable) case "goal plan": diff --git a/internal/gira/feature_map.go b/internal/gira/feature_map.go new file mode 100644 index 0000000..09ea271 --- /dev/null +++ b/internal/gira/feature_map.go @@ -0,0 +1,593 @@ +package gira + +import ( + "encoding/json" + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +const ( + FeatureMapListSchemaVersion = "feature-map-list/v1" + FeatureMapCheckSchemaVersion = "feature-map-check/v1" + FeatureMapForSchemaVersion = "feature-map-for/v1" +) + +var ( + featureLinkPattern = regexp.MustCompile(`(?im)^\s*(?:related\s+)?(?:capability|feature)\s*:\s*#?(\d+)\b`) + featureKeyPattern = regexp.MustCompile(`(?im)^\s*(?:feature\s+key|key)\s*:\s*([A-Za-z0-9][A-Za-z0-9_-]{0,31})\s*$`) + featureStatusLine = regexp.MustCompile(`(?im)^\s*(?:capability\s+status|feature\s+status|maturity|status)\s*:\s*([A-Za-z][A-Za-z_-]{0,31})\s*$`) +) + +type FeatureMapOptions struct { + Repo RepoRef `json:"repo"` + Limit int `json:"limit"` +} + +type FeatureForOptions struct { + Repo RepoRef `json:"repo"` + Issue int `json:"issue"` + Limit int `json:"limit"` +} + +type FeatureMapListReport struct { + SchemaVersion string `json:"schema_version"` + Command string `json:"command"` + Repo string `json:"repo"` + Source string `json:"source"` + Mode string `json:"mode"` + Limit int `json:"limit"` + Features []FeatureMapFeature `json:"features"` + Counts FeatureMapListCounts `json:"counts"` + NextStep string `json:"next_step"` +} + +type FeatureMapListCounts struct { + Features int `json:"features"` +} + +type FeatureMapCheckReport struct { + SchemaVersion string `json:"schema_version"` + Command string `json:"command"` + Repo string `json:"repo"` + Source string `json:"source"` + Mode string `json:"mode"` + Limit int `json:"limit"` + Features []FeatureMapFeature `json:"features"` + Diagnostics []FeatureMapDiagnostic `json:"diagnostics"` + Counts FeatureMapCheckCounts `json:"counts"` + NextStep string `json:"next_step"` +} + +type FeatureMapCheckCounts struct { + Features int `json:"features"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + LinkedWork int `json:"linked_work"` + MissingLinkWork int `json:"missing_link_work"` +} + +type FeatureMapForReport struct { + SchemaVersion string `json:"schema_version"` + Command string `json:"command"` + Repo string `json:"repo"` + Issue FeatureMapWorkIssue `json:"issue"` + Feature *FeatureMapFeature `json:"feature,omitempty"` + Diagnostics []FeatureMapDiagnostic `json:"diagnostics,omitempty"` + NextStep string `json:"next_step"` +} + +type FeatureMapFeature struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Key string `json:"key,omitempty"` + Area string `json:"area,omitempty"` + Maturity string `json:"maturity,omitempty"` + Labels []string `json:"labels,omitempty"` + URL string `json:"url,omitempty"` +} + +type FeatureMapWorkIssue struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Labels []string `json:"labels,omitempty"` + URL string `json:"url,omitempty"` + LinkedFeature int `json:"linked_feature,omitempty"` +} + +type FeatureMapDiagnostic struct { + Severity string `json:"severity"` + Issue int `json:"issue,omitempty"` + Code string `json:"code"` + Message string `json:"message"` +} + +type featureMapRawIssue struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` + URL string `json:"url"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` +} + +func BuildFeatureMapListReport(options FeatureMapOptions, runner CommandRunner) (FeatureMapListReport, error) { + issues, limit, err := fetchFeatureMapIssues(options, runner) + if err != nil { + return FeatureMapListReport{}, err + } + features := featureMapFeatures(issues) + report := FeatureMapListReport{ + SchemaVersion: FeatureMapListSchemaVersion, + Command: "feature list", + Repo: options.Repo.FullName(), + Source: "github_issues", + Mode: featureMapMode(features), + Limit: limit, + Features: features, + Counts: FeatureMapListCounts{Features: len(features)}, + NextStep: featureMapListNextStep(options.Repo, len(features)), + } + return report, nil +} + +func BuildFeatureMapCheckReport(options FeatureMapOptions, runner CommandRunner) (FeatureMapCheckReport, error) { + issues, limit, err := fetchFeatureMapIssues(options, runner) + if err != nil { + return FeatureMapCheckReport{}, err + } + features := featureMapFeatures(issues) + diagnostics, linkedWork, missingLinkWork := featureMapDiagnostics(issues, features) + counts := FeatureMapCheckCounts{Features: len(features), LinkedWork: linkedWork, MissingLinkWork: missingLinkWork} + for _, diagnostic := range diagnostics { + switch diagnostic.Severity { + case "error": + counts.Errors++ + case "warning": + counts.Warnings++ + } + } + report := FeatureMapCheckReport{ + SchemaVersion: FeatureMapCheckSchemaVersion, + Command: "feature check", + Repo: options.Repo.FullName(), + Source: "github_issues", + Mode: featureMapMode(features), + Limit: limit, + Features: features, + Diagnostics: diagnostics, + Counts: counts, + NextStep: featureMapCheckNextStep(options.Repo, counts), + } + return report, nil +} + +func BuildFeatureMapForReport(options FeatureForOptions, runner CommandRunner) (FeatureMapForReport, error) { + if options.Issue <= 0 { + return FeatureMapForReport{}, fmt.Errorf("issue must be > 0") + } + issues, _, err := fetchFeatureMapIssues(FeatureMapOptions{Repo: options.Repo, Limit: options.Limit}, runner) + if err != nil { + return FeatureMapForReport{}, err + } + features := featureMapFeatures(issues) + featuresByNumber := map[int]FeatureMapFeature{} + for _, feature := range features { + featuresByNumber[feature.Number] = feature + } + for _, issue := range issues { + if issue.Number != options.Issue { + continue + } + work := featureMapWorkIssue(issue) + report := FeatureMapForReport{ + SchemaVersion: FeatureMapForSchemaVersion, + Command: "feature for", + Repo: options.Repo.FullName(), + Issue: work, + } + if isFeatureRecord(issue) { + feature := featureMapFeature(issue) + report.Feature = &feature + report.NextStep = fmt.Sprintf("gira feature check --repo %s", options.Repo.FullName()) + return report, nil + } + if work.LinkedFeature == 0 { + report.Diagnostics = append(report.Diagnostics, FeatureMapDiagnostic{Severity: "warning", Issue: issue.Number, Code: "missing_feature_link", Message: "work issue is not linked to a feature or capability"}) + report.NextStep = "add Related capability: #FEATURE to the issue body" + return report, nil + } + feature, ok := featuresByNumber[work.LinkedFeature] + if !ok { + report.Diagnostics = append(report.Diagnostics, FeatureMapDiagnostic{Severity: "error", Issue: issue.Number, Code: "linked_feature_not_found", Message: fmt.Sprintf("linked feature #%d was not found", work.LinkedFeature)}) + report.NextStep = fmt.Sprintf("update issue #%d feature link", issue.Number) + return report, nil + } + report.Feature = &feature + report.NextStep = fmt.Sprintf("gira ticket status %d --repo %s", issue.Number, options.Repo.FullName()) + return report, nil + } + return FeatureMapForReport{}, fmt.Errorf("issue #%d not found in %s", options.Issue, options.Repo.FullName()) +} + +func fetchFeatureMapIssues(options FeatureMapOptions, runner CommandRunner) ([]featureMapRawIssue, int, error) { + if runner == nil { + runner = ExecCommandRunner{} + } + limit := options.Limit + if limit == 0 { + limit = 1000 + } + if limit < 0 { + return nil, 0, fmt.Errorf("--limit must be greater than 0") + } + output, err := runner.Run("gh", "issue", "list", "--repo", options.Repo.FullName(), "--state", "all", "--limit", fmt.Sprintf("%d", limit), "--json", "number,title,state,labels,body,url") + if err != nil { + return nil, 0, err + } + var rows []featureMapRawIssue + if err := json.Unmarshal(output, &rows); err != nil { + return nil, 0, fmt.Errorf("parse gh issue list JSON: %w", err) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Number < rows[j].Number }) + return rows, limit, nil +} + +func featureMapFeatures(issues []featureMapRawIssue) []FeatureMapFeature { + features := make([]FeatureMapFeature, 0) + for _, issue := range issues { + if isFeatureRecord(issue) { + features = append(features, featureMapFeature(issue)) + } + } + sort.Slice(features, func(i, j int) bool { + if features[i].Area != features[j].Area { + return features[i].Area < features[j].Area + } + return features[i].Number < features[j].Number + }) + return features +} + +func featureMapFeature(issue featureMapRawIssue) FeatureMapFeature { + labels := featureMapLabels(issue) + return FeatureMapFeature{ + Number: issue.Number, + Title: issue.Title, + State: strings.ToLower(issue.State), + Key: featureMapKey(issue, labels), + Area: featureMapArea(labels), + Maturity: featureMapMaturity(issue, labels), + Labels: featureMapKeyLabels(labels), + URL: issue.URL, + } +} + +func featureMapWorkIssue(issue featureMapRawIssue) FeatureMapWorkIssue { + labels := featureMapLabels(issue) + return FeatureMapWorkIssue{ + Number: issue.Number, + Title: issue.Title, + State: strings.ToLower(issue.State), + Labels: featureMapKeyLabels(labels), + URL: issue.URL, + LinkedFeature: extractFeatureLink(issue.Body), + } +} + +func featureMapDiagnostics(issues []featureMapRawIssue, features []FeatureMapFeature) ([]FeatureMapDiagnostic, int, int) { + featureNumbers := map[int]struct{}{} + for _, feature := range features { + featureNumbers[feature.Number] = struct{}{} + } + diagnostics := []FeatureMapDiagnostic{} + linkedWork := 0 + missingLinkWork := 0 + if len(features) == 0 { + diagnostics = append(diagnostics, FeatureMapDiagnostic{Severity: "info", Code: "feature_map_not_configured", Message: "no issue-backed feature records found"}) + return diagnostics, linkedWork, missingLinkWork + } + for _, issue := range issues { + labels := featureMapLabels(issue) + if isFeatureRecord(issue) { + diagnostics = append(diagnostics, featureRecordDiagnostics(issue, labels)...) + continue + } + if strings.EqualFold(issue.State, "closed") { + continue + } + linked := extractFeatureLink(issue.Body) + if linked == 0 { + missingLinkWork++ + continue + } + linkedWork++ + if _, ok := featureNumbers[linked]; !ok { + diagnostics = append(diagnostics, FeatureMapDiagnostic{Severity: "error", Issue: issue.Number, Code: "linked_feature_not_found", Message: fmt.Sprintf("linked feature #%d was not found", linked)}) + } + } + sort.Slice(diagnostics, func(i, j int) bool { + if diagnostics[i].Severity != diagnostics[j].Severity { + return diagnostics[i].Severity < diagnostics[j].Severity + } + if diagnostics[i].Issue != diagnostics[j].Issue { + return diagnostics[i].Issue < diagnostics[j].Issue + } + return diagnostics[i].Code < diagnostics[j].Code + }) + return diagnostics, linkedWork, missingLinkWork +} + +func featureRecordDiagnostics(issue featureMapRawIssue, labels []string) []FeatureMapDiagnostic { + diagnostics := []FeatureMapDiagnostic{} + if featureMapKey(issue, labels) == "" { + diagnostics = append(diagnostics, FeatureMapDiagnostic{Severity: "warning", Issue: issue.Number, Code: "missing_key", Message: "feature record should define a short key for daily UX"}) + } + maturities := featureMapMaturityValues(issue, labels) + if len(maturities) == 0 { + diagnostics = append(diagnostics, FeatureMapDiagnostic{Severity: "warning", Issue: issue.Number, Code: "missing_maturity", Message: "feature record should define one maturity value"}) + } else if len(maturities) > 1 { + diagnostics = append(diagnostics, FeatureMapDiagnostic{Severity: "warning", Issue: issue.Number, Code: "multiple_maturity", Message: "feature record should define only one maturity value"}) + } + for _, maturity := range maturities { + if !validFeatureMaturity(maturity) { + diagnostics = append(diagnostics, FeatureMapDiagnostic{Severity: "error", Issue: issue.Number, Code: "invalid_maturity", Message: fmt.Sprintf("unknown maturity %q", maturity)}) + } + } + for _, section := range []string{"User Need", "Capability", "Surface"} { + if strings.TrimSpace(markdownSection(issue.Body, section)) == "" { + diagnostics = append(diagnostics, FeatureMapDiagnostic{Severity: "warning", Issue: issue.Number, Code: "missing_section", Message: "feature record is missing ## " + section}) + } + } + if featureMapMaturity(issue, labels) == "stable" { + for _, section := range []string{"Docs", "Evidence"} { + if strings.TrimSpace(markdownSection(issue.Body, section)) == "" { + diagnostics = append(diagnostics, FeatureMapDiagnostic{Severity: "warning", Issue: issue.Number, Code: "stable_missing_section", Message: "stable feature is missing ## " + section}) + } + } + } + return diagnostics +} + +func isFeatureRecord(issue featureMapRawIssue) bool { + labels := featureMapLabels(issue) + for _, label := range labels { + if label == "type:capability" || label == "type:feature" { + return true + } + } + title := strings.ToLower(strings.TrimSpace(issue.Title)) + return strings.HasPrefix(title, "capability:") || strings.HasPrefix(title, "feature:") +} + +func featureMapLabels(issue featureMapRawIssue) []string { + labels := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + name := strings.TrimSpace(label.Name) + if name != "" { + labels = append(labels, name) + } + } + sort.Strings(labels) + return labels +} + +func featureMapKey(issue featureMapRawIssue, labels []string) string { + for _, label := range labels { + if key := strings.TrimPrefix(label, "feature-key:"); key != label && key != "" { + return key + } + } + if match := featureKeyPattern.FindStringSubmatch(issue.Body); len(match) == 2 { + return strings.ToLower(strings.TrimSpace(match[1])) + } + return "" +} + +func featureMapArea(labels []string) string { + for _, label := range labels { + if strings.HasPrefix(label, "area:") { + return strings.TrimPrefix(label, "area:") + } + } + return "" +} + +func featureMapMaturity(issue featureMapRawIssue, labels []string) string { + values := featureMapMaturityValues(issue, labels) + if len(values) == 1 && validFeatureMaturity(values[0]) { + return values[0] + } + return "" +} + +func featureMapMaturityValues(issue featureMapRawIssue, labels []string) []string { + values := []string{} + for _, label := range labels { + if strings.HasPrefix(label, "feature-key:") { + continue + } + for _, prefix := range []string{"capability:", "feature:"} { + if strings.HasPrefix(label, prefix) { + value := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(label, prefix))) + if value != "" { + values = append(values, value) + } + } + } + } + if match := featureStatusLine.FindStringSubmatch(issue.Body); len(match) == 2 { + values = append(values, strings.ToLower(strings.TrimSpace(match[1]))) + } + return uniqueFeatureMapStrings(values) +} + +func featureMapKeyLabels(labels []string) []string { + keyLabels := []string{} + for _, label := range labels { + switch { + case label == "type:capability", + label == "type:feature", + strings.HasPrefix(label, "capability:"), + strings.HasPrefix(label, "feature:"), + strings.HasPrefix(label, "area:"): + keyLabels = append(keyLabels, label) + } + } + return keyLabels +} + +func validFeatureMaturity(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "optional", "planned", "preview", "stable", "legacy", "deprecated": + return true + default: + return false + } +} + +func extractFeatureLink(body string) int { + match := featureLinkPattern.FindStringSubmatch(body) + if len(match) != 2 { + return 0 + } + n, err := strconv.Atoi(match[1]) + if err != nil || n <= 0 { + return 0 + } + return n +} + +func uniqueFeatureMapStrings(values []string) []string { + seen := map[string]struct{}{} + out := []string{} + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + sort.Strings(out) + return out +} + +func featureMapMode(features []FeatureMapFeature) string { + if len(features) == 0 { + return "none" + } + return "optional" +} + +func featureMapListNextStep(repo RepoRef, count int) string { + if count == 0 { + return "create an issue-backed feature record when this repo needs a feature map" + } + return fmt.Sprintf("gira feature check --repo %s", repo.FullName()) +} + +func featureMapCheckNextStep(repo RepoRef, counts FeatureMapCheckCounts) string { + if counts.Features == 0 { + return "feature map is optional; no action required" + } + if counts.Errors > 0 || counts.Warnings > 0 { + return "resolve feature map diagnostics or keep the map optional" + } + return fmt.Sprintf("gira feature list --repo %s", repo.FullName()) +} + +func FormatFeatureMapList(report FeatureMapListReport) string { + var b strings.Builder + fmt.Fprintf(&b, "feature map: %s source=%s mode=%s count=%d\n", report.Repo, report.Source, report.Mode, report.Counts.Features) + if len(report.Features) == 0 { + b.WriteString("features: none\n") + } else { + for _, feature := range report.Features { + meta := []string{} + if feature.Key != "" { + meta = append(meta, "key="+feature.Key) + } + if feature.Area != "" { + meta = append(meta, "area="+feature.Area) + } + if feature.Maturity != "" { + meta = append(meta, "maturity="+feature.Maturity) + } + suffix := "" + if len(meta) > 0 { + suffix = " [" + strings.Join(meta, " ") + "]" + } + fmt.Fprintf(&b, "#%d %s%s\n", feature.Number, feature.Title, suffix) + } + } + if report.NextStep != "" { + fmt.Fprintf(&b, "next step: %s\n", report.NextStep) + } + return b.String() +} + +func FormatFeatureMapCheck(report FeatureMapCheckReport) string { + var b strings.Builder + fmt.Fprintf(&b, "feature map check: %s source=%s mode=%s features=%d warnings=%d errors=%d\n", report.Repo, report.Source, report.Mode, report.Counts.Features, report.Counts.Warnings, report.Counts.Errors) + if report.Counts.LinkedWork > 0 || report.Counts.MissingLinkWork > 0 { + fmt.Fprintf(&b, "work links: linked=%d missing=%d\n", report.Counts.LinkedWork, report.Counts.MissingLinkWork) + } + if len(report.Diagnostics) == 0 { + b.WriteString("diagnostics: none\n") + } else { + b.WriteString("diagnostics:\n") + for _, diagnostic := range report.Diagnostics { + target := "" + if diagnostic.Issue > 0 { + target = fmt.Sprintf(" #%d", diagnostic.Issue) + } + fmt.Fprintf(&b, " %s%s %s: %s\n", diagnostic.Severity, target, diagnostic.Code, diagnostic.Message) + } + } + if report.NextStep != "" { + fmt.Fprintf(&b, "next step: %s\n", report.NextStep) + } + return b.String() +} + +func FormatFeatureMapFor(report FeatureMapForReport) string { + var b strings.Builder + fmt.Fprintf(&b, "feature for: %s issue=#%d %s\n", report.Repo, report.Issue.Number, report.Issue.Title) + if report.Feature == nil { + b.WriteString("feature: missing\n") + } else { + feature := report.Feature + meta := []string{} + if feature.Key != "" { + meta = append(meta, "key="+feature.Key) + } + if feature.Maturity != "" { + meta = append(meta, "maturity="+feature.Maturity) + } + if feature.Area != "" { + meta = append(meta, "area="+feature.Area) + } + fmt.Fprintf(&b, "feature: #%d %s", feature.Number, feature.Title) + if len(meta) > 0 { + fmt.Fprintf(&b, " [%s]", strings.Join(meta, " ")) + } + b.WriteString("\n") + } + for _, diagnostic := range report.Diagnostics { + fmt.Fprintf(&b, "%s: %s\n", diagnostic.Severity, diagnostic.Message) + } + if report.NextStep != "" { + fmt.Fprintf(&b, "next step: %s\n", report.NextStep) + } + return b.String() +} diff --git a/internal/gira/feature_map_test.go b/internal/gira/feature_map_test.go new file mode 100644 index 0000000..ead06ba --- /dev/null +++ b/internal/gira/feature_map_test.go @@ -0,0 +1,86 @@ +package gira + +import ( + "fmt" + "strings" + "testing" +) + +func TestBuildFeatureMapListReportEmptyIsOptional(t *testing.T) { + runner := &featureMapRunner{outputs: map[string][]byte{ + "gh issue list --repo StatPan/backlog --state all --limit 1000 --json number,title,state,labels,body,url": []byte(`[]`), + }} + report, err := BuildFeatureMapListReport(FeatureMapOptions{Repo: RepoRef{Owner: "StatPan", Name: "backlog"}}, runner) + if err != nil { + t.Fatalf("BuildFeatureMapListReport returned error: %v", err) + } + if report.Mode != "none" || report.Counts.Features != 0 { + t.Fatalf("unexpected empty feature map report: %+v", report) + } + out := FormatFeatureMapList(report) + if !strings.Contains(out, "features: none") || !strings.Contains(out, "create an issue-backed feature record") { + t.Fatalf("empty output missing optional guidance:\n%s", out) + } +} + +func TestBuildFeatureMapCheckReportDiagnostics(t *testing.T) { + runner := &featureMapRunner{outputs: map[string][]byte{ + "gh issue list --repo StatPan/backlog --state all --limit 1000 --json number,title,state,labels,body,url": []byte(`[ + {"number":31,"title":"Capability: Ticket lifecycle","state":"OPEN","body":"Key: tl\nStatus: stable\n\n## User Need\nStart and finish ticket work.\n\n## Capability\nTicket lifecycle.\n\n## Surface\nCLI and JSON.\n\n## Docs\ndocs-site/ticket-workflow.md\n\n## Evidence\nTests and merged PRs.","url":"u31","labels":[{"name":"area:execution"}]}, + {"number":32,"title":"Capability: Goal mode","state":"OPEN","body":"## Capability\nGoal work.","url":"u32","labels":[{"name":"type:capability"},{"name":"capability:experimental"}]}, + {"number":41,"title":"Add finish receipt validation","state":"OPEN","body":"Related capability: #31","url":"u41","labels":[{"name":"type:task"}]}, + {"number":42,"title":"Unlinked task","state":"OPEN","body":"## Goal\nWork.","url":"u42","labels":[{"name":"type:task"}]}, + {"number":43,"title":"Bad link","state":"OPEN","body":"Feature: #999","url":"u43","labels":[{"name":"type:task"}]} + ]`), + }} + report, err := BuildFeatureMapCheckReport(FeatureMapOptions{Repo: RepoRef{Owner: "StatPan", Name: "backlog"}}, runner) + if err != nil { + t.Fatalf("BuildFeatureMapCheckReport returned error: %v", err) + } + if report.Mode != "optional" || report.Counts.Features != 2 || report.Counts.LinkedWork != 2 || report.Counts.MissingLinkWork != 1 { + t.Fatalf("unexpected check counts: %+v", report.Counts) + } + codes := map[string]bool{} + for _, diagnostic := range report.Diagnostics { + codes[diagnostic.Code] = true + } + for _, want := range []string{"invalid_maturity", "missing_key", "missing_section", "linked_feature_not_found"} { + if !codes[want] { + t.Fatalf("missing diagnostic %q in %+v", want, report.Diagnostics) + } + } +} + +func TestBuildFeatureMapForReportFindsLinkedFeature(t *testing.T) { + runner := &featureMapRunner{outputs: map[string][]byte{ + "gh issue list --repo StatPan/backlog --state all --limit 1000 --json number,title,state,labels,body,url": []byte(`[ + {"number":31,"title":"Capability: Ticket lifecycle","state":"OPEN","body":"Key: tl\nStatus: stable\n\n## User Need\nNeed.\n\n## Capability\nCap.\n\n## Surface\nCLI.\n\n## Docs\nDoc.\n\n## Evidence\nEvidence.","url":"u31","labels":[{"name":"area:execution"}]}, + {"number":41,"title":"Add finish receipt validation","state":"OPEN","body":"Related capability: #31","url":"u41","labels":[{"name":"type:task"}]} + ]`), + }} + report, err := BuildFeatureMapForReport(FeatureForOptions{Repo: RepoRef{Owner: "StatPan", Name: "backlog"}, Issue: 41}, runner) + if err != nil { + t.Fatalf("BuildFeatureMapForReport returned error: %v", err) + } + if report.Feature == nil || report.Feature.Number != 31 || report.Feature.Key != "tl" { + t.Fatalf("unexpected feature link report: %+v", report) + } + out := FormatFeatureMapFor(report) + if !strings.Contains(out, "feature: #31") || !strings.Contains(out, "key=tl") { + t.Fatalf("output missing feature link:\n%s", out) + } +} + +type featureMapRunner struct { + outputs map[string][]byte + calls []string +} + +func (r *featureMapRunner) Run(name string, args ...string) ([]byte, error) { + key := name + " " + strings.Join(args, " ") + r.calls = append(r.calls, key) + if out, ok := r.outputs[key]; ok { + return out, nil + } + return nil, fmt.Errorf("unexpected call: %s", key) +}