From 06272640ff5b9fd785f84c2741ef0908683318db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 02:28:59 +0000 Subject: [PATCH 01/36] docs: add plan spec for external issue linking feature New feature plan for optionally linking beads to external issue trackers (GitHub Issues for v1). Covers schema changes, URL parsing, parent-child inheritance, bidirectional status/label sync, and gh CLI health checks. https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- .../plan-2026-02-10-external-issue-linking.md | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 docs/project/specs/active/plan-2026-02-10-external-issue-linking.md diff --git a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md new file mode 100644 index 00000000..05f1e2d8 --- /dev/null +++ b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md @@ -0,0 +1,456 @@ +# Feature: External Issue Linking + +**Date:** 2026-02-10 (last updated 2026-02-10) + +**Author:** Joshua Levy / Claude + +**Status:** Draft + +## Overview + +Add support for optionally linking tbd beads to external issue tracker issues, +starting with GitHub Issues as the v1 provider. This enables bidirectional status +sync, label sync, and provides easy clickable URLs to the external issue from +any bead rendering. + +The feature follows the same architectural pattern as `spec_path` linking: an optional +field on the bead, with inheritance from parent epics to child beads, and propagation +on updates. + +## Goals + +- Add an optional `external_issue_url` field to the bead schema for linking to + external issue tracker URLs (GitHub Issues for v1) +- Parse and validate GitHub issue URLs to extract owner, repo, and issue number +- Verify at link time that the issue exists and is accessible via `gh` CLI +- Inherit external issue links from parent beads to children (same pattern as + `spec_path`) +- Propagate external issue link changes from parent to children +- Sync bead status changes to linked GitHub issues (closing a bead closes the + GitHub issue) +- Sync label changes bidirectionally between beads and GitHub issues +- Ensure `gh` CLI availability is checked in health/doctor commands +- Update the design doc to reflect the new field, status mapping, and sync behaviors + +## Non-Goals + +- Full bidirectional comment sync (future GitHub Bridge feature) +- Webhook-driven real-time sync (future enhancement; this is CLI-triggered sync) +- Support for non-GitHub providers in v1 (Jira, Linear, etc. are future work) +- Required linking (it remains optional, like `spec_path`) +- Multiple external issue links per bead (single URL is sufficient for v1) +- Automatic issue creation on GitHub when a bead is created (manual linking only) + +## Background + +### Current State + +Beads have rich metadata including `spec_path` for linking to spec documents, but +no way to link to external issue trackers. The design doc (§8.7) describes external +issue tracker linking as a planned future feature, recommending a `linked` metadata +structure with provider-specific fields. + +**Existing patterns we build on:** + +1. **`spec_path` field and inheritance** (`schemas.ts:149-150`, `create.ts:113-119`, + `update.ts:151-164`): Optional string field with parent-to-child inheritance on + create and propagation on update. This is the direct template for + `external_issue_url`. + +2. **GitHub URL parsing** (`github-fetch.ts`): Existing regex patterns for parsing + GitHub blob/raw URLs. No issue URL parsing exists yet, but the pattern is + established. + +3. **`gh` CLI availability** (`setup.ts`, `ensure-gh-cli.sh`): The `use_gh_cli` + config setting and SessionStart hook ensure `gh` CLI is installed. But the `doctor` + command does not currently check for `gh` availability. + +4. **Merge strategy** (`git.ts:277-308`): `spec_path` uses `lww` (last-write-wins). + The same strategy applies to `external_issue_url`. + +5. **Design doc §8.7** (`tbd-design.md:5717-5779`): Describes the metadata model + for external issue linking with `linked` array and provider-specific fields. + +### GitHub Issues: States and Labels + +GitHub Issues have a simple state model: + +| `state` | `state_reason` | Meaning | +| --- | --- | --- | +| `open` | `null` | Issue is open | +| `open` | `reopened` | Issue was reopened | +| `closed` | `completed` | Closed as done/resolved (default) | +| `closed` | `not_planned` | Closed as won't fix / not planned | +| `closed` | `duplicate` | Closed as duplicate (undocumented) | + +GitHub labels are free-form strings attached to issues, similar to tbd labels. + +### tbd Bead Status States + +| Status | Meaning | +| --- | --- | +| `open` | Not started | +| `in_progress` | Actively being worked on | +| `blocked` | Waiting on a dependency | +| `deferred` | Postponed | +| `closed` | Complete | + +### Status Mapping: tbd → GitHub + +The following mapping defines how bead status changes propagate to linked GitHub +issues. This mapping is defined in one place and could be extended for other +providers in the future. + +| tbd Status | GitHub Action | GitHub State | GitHub `state_reason` | +| --- | --- | --- | --- | +| `open` | Reopen issue (if closed) | `open` | — | +| `in_progress` | Reopen issue (if closed) | `open` | — | +| `blocked` | No change | — | — | +| `deferred` | Close as not planned | `closed` | `not_planned` | +| `closed` | Close as completed | `closed` | `completed` | + +### Status Mapping: GitHub → tbd + +When syncing from GitHub (on next bead update or explicit sync), the reverse mapping: + +| GitHub State | GitHub `state_reason` | tbd Status | +| --- | --- | --- | +| `open` | `null` or `reopened` | `open` (only if bead is `closed` or `deferred`) | +| `closed` | `completed` | `closed` | +| `closed` | `not_planned` | `deferred` | +| `closed` | `duplicate` | `closed` | + +Note: `blocked` and `in_progress` have no GitHub equivalent. If GitHub reopens an +issue that was `in_progress`, the bead stays `in_progress`. If GitHub closes an issue +that was `blocked`, the bead moves to `closed`. + +### Label Mapping + +Labels sync bidirectionally: + +- **tbd → GitHub**: When a label is added/removed on a bead, the same label is + added/removed on the linked GitHub issue. +- **GitHub → tbd**: When a label is added/removed on the GitHub issue, the same + label is added/removed on the bead during the next sync. +- Labels are matched by exact string equality. +- Label sync is additive for union merges: if both sides add different labels, both + end up with the union. + +## Design + +### Approach + +1. **New schema field**: Add `external_issue_url` as an optional nullable string + field on `IssueSchema`, following the `spec_path` pattern. + +2. **GitHub URL parser**: Create a `github-issues.ts` module with functions to + parse GitHub issue URLs, extract `{owner, repo, number}`, validate via `gh` CLI, + and perform status/label operations. + +3. **Inheritance and propagation**: Reuse the exact same parent-to-child inheritance + pattern as `spec_path` in `create.ts` and `update.ts`. + +4. **Status sync on close/update**: When a bead's status changes, if it has a linked + external issue, sync the status to GitHub using the mapping table. + +5. **Label sync**: On label add/remove operations, sync to the linked GitHub issue. + On explicit sync or bead update, pull label changes from GitHub. + +6. **Doctor check**: Add a `gh` CLI availability check to the `doctor` command. + +### Components + +| Component | File(s) | Purpose | +| --- | --- | --- | +| Schema | `schemas.ts` | Add `external_issue_url` field | +| URL Parser | `github-issues.ts` (new) | Parse, validate, and operate on GitHub issues | +| Create | `create.ts` | `--external-issue` flag, parent inheritance | +| Update | `update.ts` | `--external-issue` flag, propagation to children | +| Close | `close.ts` | Sync status to GitHub on close | +| Reopen | `reopen.ts` | Sync status to GitHub on reopen | +| Show | `show.ts` | Display external issue URL with highlighting | +| List | `list.ts` | `--external-issue` filter option | +| Label | `label.ts` | Sync label changes to GitHub | +| Doctor | `doctor.ts` | Add `gh` CLI health check | +| Merge rules | `git.ts` | Add `external_issue_url: 'lww'` | +| Status mapping | `github-issues.ts` | Hardcoded mapping table | + +### Schema Changes + +Add to `IssueSchema` in `packages/tbd/src/lib/schemas.ts`: + +```typescript +// External issue linking - URL to linked external issue (e.g., GitHub Issues) +external_issue_url: z.string().url().nullable().optional(), +``` + +### GitHub Issue URL Parsing + +New module `packages/tbd/src/file/github-issues.ts`: + +```typescript +// Matches: https://github.com/{owner}/{repo}/issues/{number} +const GITHUB_ISSUE_RE = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)$/; + +interface GitHubIssueRef { + owner: string; + repo: string; + number: number; + url: string; +} + +function parseGitHubIssueUrl(url: string): GitHubIssueRef | null; +function isGitHubIssueUrl(url: string): boolean; +async function validateGitHubIssue(ref: GitHubIssueRef): Promise; +async function closeGitHubIssue(ref: GitHubIssueRef, reason: 'completed' | 'not_planned'): Promise; +async function reopenGitHubIssue(ref: GitHubIssueRef): Promise; +async function addGitHubLabel(ref: GitHubIssueRef, label: string): Promise; +async function removeGitHubLabel(ref: GitHubIssueRef, label: string): Promise; +async function getGitHubIssueState(ref: GitHubIssueRef): Promise<{state: string, state_reason: string | null, labels: string[]}>; +``` + +All GitHub API operations use `gh api` via child process, leveraging the existing +`gh` CLI that `ensure-gh-cli.sh` installs. + +### Inheritance Logic (Reuse from `spec_path`) + +**On create with `--parent`** (in `create.ts`): +- If `--external-issue` is not provided but parent has `external_issue_url`, + inherit from parent. + +**On update** (in `update.ts`): +- If `external_issue_url` changes on a parent, propagate to children whose + `external_issue_url` is null or matches the old value. +- On re-parenting without explicit `--external-issue`, inherit from new parent + if the bead has no `external_issue_url`. + +This is the exact same logic as `spec_path` inheritance and propagation. + +### API Changes + +#### CLI Flags + +| Command | New Flag | Purpose | +| --- | --- | --- | +| `tbd create` | `--external-issue ` | Link to external issue | +| `tbd update` | `--external-issue ` | Set/change external issue link | +| `tbd list` | `--external-issue [url]` | Filter by external issue link | +| `tbd show` | (no flag; auto-displayed) | Shows URL in output | + +#### Merge Rules Addition + +In `git.ts` `FIELD_STRATEGIES`: +```typescript +external_issue_url: 'lww', +``` + +### Error Handling + +- If `gh` CLI is not available when attempting to sync, log a warning and return + a non-zero exit code. The bead update itself still succeeds. +- If the GitHub API call fails (network error, auth error, permission error), + log the error, return non-zero exit code, but the bead update still succeeds. +- At link time (`--external-issue`), validate the issue exists and is readable. + If not accessible, fail the command with a clear error message. +- All errors are surfaced to the user, never silently swallowed. + +## Implementation Plan + +### Phase 1: Schema, URL Parsing, and Core Linking + +Add the field, parse GitHub URLs, validate issues, and wire up the basic +create/update/show/list functionality. No status or label sync yet. + +- [ ] Add `external_issue_url` field to `IssueSchema` in `schemas.ts` +- [ ] Add `external_issue_url: 'lww'` to merge rules in `git.ts` +- [ ] Create `github-issues.ts` module with: + - [ ] `parseGitHubIssueUrl()` - regex-based URL parsing + - [ ] `isGitHubIssueUrl()` - URL type detection + - [ ] `validateGitHubIssue()` - verify issue exists via `gh api` + - [ ] `formatGitHubIssueRef()` - format as `owner/repo#number` for display +- [ ] Add `--external-issue ` flag to `create` command with: + - [ ] URL validation (must be a valid GitHub issue URL) + - [ ] Issue accessibility check via `gh api` + - [ ] Parent inheritance (same pattern as `spec_path`) +- [ ] Add `--external-issue ` flag to `update` command with: + - [ ] URL validation and accessibility check + - [ ] Propagation to children (same pattern as `spec_path`) + - [ ] Clear with `--external-issue ""` +- [ ] Update `show` command to display `external_issue_url` with color highlighting +- [ ] Add `--external-issue` filter to `list` command +- [ ] Write unit tests for `github-issues.ts` (URL parsing, format detection) +- [ ] Write unit tests for schema changes +- [ ] Create golden tryscript tests for: + - [ ] Create with `--external-issue` + - [ ] Show displaying external issue URL + - [ ] List filtering by external issue + - [ ] Update to set/clear external issue + - [ ] Parent-to-child inheritance + - [ ] Propagation on parent update + +### Phase 2: `gh` CLI Health Check and Setup Validation + +Ensure `gh` CLI availability is verified in `doctor` and that the setup flow +properly validates GitHub access. + +- [ ] Add `gh` CLI availability check to `doctor` command: + - [ ] Check if `gh` is in PATH + - [ ] Check if `gh auth status` succeeds + - [ ] Report as integration check (not blocking, but informational) +- [ ] Add `--fix` support: if `gh` missing and `use_gh_cli` is true, suggest + running `tbd setup --auto` +- [ ] Write tests for the new doctor check + +### Phase 3: Status Sync (tbd → GitHub) + +When bead status changes, propagate to the linked GitHub issue. + +- [ ] Add status mapping table to `github-issues.ts`: + - [ ] `tbd closed` → GitHub `closed` (`completed`) + - [ ] `tbd deferred` → GitHub `closed` (`not_planned`) + - [ ] `tbd open` / `in_progress` → GitHub `open` (reopen if closed) + - [ ] `tbd blocked` → no change +- [ ] Add `closeGitHubIssue()` function using `gh api` +- [ ] Add `reopenGitHubIssue()` function using `gh api` +- [ ] Hook into `close` command: after closing bead, sync to GitHub +- [ ] Hook into `reopen` command: after reopening bead, sync to GitHub +- [ ] Hook into `update` command: when status changes, sync to GitHub +- [ ] Error handling: log warning on failure, non-zero exit code, but bead + update still succeeds +- [ ] Write tests for status sync (mock `gh` CLI calls) +- [ ] Create golden tryscript tests for close/reopen sync behavior + +### Phase 4: Status Sync (GitHub → tbd) and Label Sync + +Pull status and label changes from GitHub into tbd beads. This phase is optional +and can be deferred. + +- [ ] Add `getGitHubIssueState()` function to fetch current state and labels +- [ ] Add GitHub → tbd status mapping logic +- [ ] Add label sync functions: + - [ ] `addGitHubLabel()` - add label on GitHub + - [ ] `removeGitHubLabel()` - remove label on GitHub + - [ ] `syncLabelsToGitHub()` - push local label diff to GitHub + - [ ] `syncLabelsFromGitHub()` - pull GitHub label diff to local +- [ ] Hook into `label add` / `label remove`: sync to GitHub +- [ ] Add pull-from-GitHub logic that runs on bead update or explicit sync: + - [ ] Fetch GitHub issue state + - [ ] Apply reverse status mapping if state changed + - [ ] Apply label diff (union/remove) +- [ ] Error handling: log warning on failure, non-zero exit code +- [ ] Write tests for label sync (mock `gh` CLI calls) +- [ ] Write tests for reverse status mapping +- [ ] Create golden tryscript tests for bidirectional sync + +### Design Doc Updates + +- [ ] Update `tbd-design.md` §2.6.3 (IssueSchema) to add `external_issue_url` +- [ ] Update `tbd-design.md` merge rules (§5.5) to add `external_issue_url: 'lww'` +- [ ] Update `tbd-design.md` §8.7 to reflect the implemented design +- [ ] Add status mapping table to design doc +- [ ] Add label sync design to design doc +- [ ] Document the inheritance and propagation rules alongside `spec_path` + +## Testing Strategy + +### Unit Tests + +1. **GitHub Issue URL Parsing** (`github-issues.test.ts`) + - Valid GitHub issue URLs (http and https) + - URLs with trailing slashes or query params (reject) + - Non-issue GitHub URLs (PRs, repos, blob URLs) + - Non-GitHub URLs (Jira, Linear, arbitrary) + - Edge cases: no issue number, non-numeric issue number + +2. **Schema Validation** (add to `schemas.test.ts`) + - `external_issue_url` accepts valid URLs + - `external_issue_url` accepts null/undefined + - `external_issue_url` rejects non-URL strings + +3. **Status Mapping** (`github-issues.test.ts`) + - Each tbd status maps to correct GitHub action + - Each GitHub state+reason maps to correct tbd status + - Edge cases: `blocked` bead + GitHub close, `in_progress` bead + GitHub reopen + +4. **Label Sync** (`github-issues.test.ts`) + - Diff calculation (added, removed, unchanged) + - Union behavior + - Empty label lists + +5. **Merge Rules** (add to `git.test.ts`) + - `external_issue_url` uses LWW correctly + - Concurrent edits to `external_issue_url` resolved by timestamp + +### Golden Tryscript Tests + +New tryscript file: `tests/cli-external-issue-linking.tryscript.md` + +Covers: +- Create with and without `--external-issue` (backward compatibility) +- Show displays external issue URL +- List filtering by external issue +- Update to set/change/clear external issue URL +- Parent-to-child inheritance +- Propagation on parent update +- Close bead → status message about GitHub sync +- Error cases: invalid URL, inaccessible issue + +### Integration Tests + +- End-to-end with real GitHub repo (can use a test repo) +- Verify `gh api` calls are correct +- Verify status and label sync round-trips + +## Rollout Plan + +1. Phase 1 shipped first — safe, backward-compatible schema addition +2. Phase 2 adds health checks — no behavioral change +3. Phase 3 adds one-way status sync — low risk, always succeeds locally +4. Phase 4 adds bidirectional sync — optional, can be feature-flagged if needed + +All phases are backward compatible. The field is optional, so older tbd versions +simply ignore it (it may be stripped by older schemas, but merge rules preserve it +via LWW). + +## Open Questions + +1. **Should we use `external_issue_url` (string URL) or a structured `linked` field + (as described in design doc §8.7)?** + Recommendation: Start with `external_issue_url` as a simple string for v1. It's + simpler, matches the `spec_path` pattern, and the URL contains all necessary + information. The structured `linked` field can be added later (possibly in + `extensions`) if we need multi-provider support. + +2. **Should status sync be opt-in via a config setting?** + Recommendation: Default to enabled when `use_gh_cli` is true. No additional + config needed for v1. + +3. **Should we sync on every bead operation or only on explicit `tbd sync`?** + Recommendation: Sync to GitHub on status-changing operations (close, reopen, + update status) and label operations. Pull from GitHub on explicit `tbd sync` + or when showing/updating a bead with a linked issue. This avoids excessive + API calls. + +4. **How should we handle GitHub rate limits?** + Recommendation: Rely on `gh` CLI's built-in rate limit handling for v1. If + rate-limited, the error is surfaced to the user. Future enhancement could add + batching or queuing. + +5. **Should the `--external-issue` flag accept shorthand like `#123` for the current + repo?** + Recommendation: For v1, require full URLs for clarity and unambiguity. Shorthand + can be added as a convenience enhancement later. + +## References + +- `packages/tbd/src/lib/schemas.ts` — Current bead schema (line 118-151) +- `packages/tbd/src/cli/commands/create.ts` — `spec_path` inheritance pattern (lines 113-119) +- `packages/tbd/src/cli/commands/update.ts` — `spec_path` propagation pattern (lines 151-164) +- `packages/tbd/src/file/git.ts` — Merge strategy rules (lines 277-308) +- `packages/tbd/src/file/github-fetch.ts` — Existing GitHub URL parsing patterns +- `packages/tbd/src/cli/commands/doctor.ts` — Health check infrastructure +- `packages/tbd/docs/tbd-design.md` §8.7 — External Issue Tracker Linking design (lines 5717-5779) +- `packages/tbd/docs/tbd-design.md` §7.2 — Future GitHub Bridge architecture (lines 4958-4976) +- `docs/project/specs/done/plan-2026-01-26-spec-linking.md` — Reference spec (similar feature) +- [GitHub Issues API: state and state_reason](https://docs.github.com/en/rest/issues) +- [GitHub REST API: Labels](https://docs.github.com/en/rest/issues/labels) From 4bc8509a2454eaf52f03e8aa9fa390715e00e2c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 02:29:57 +0000 Subject: [PATCH 02/36] chore: update tbd docs_cache config entries Reflects updated skill shortcut file names in doc cache mappings. https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- .tbd/config.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.tbd/config.yml b/.tbd/config.yml index 7913bd6a..1fa02bb7 100644 --- a/.tbd/config.yml +++ b/.tbd/config.yml @@ -70,9 +70,8 @@ docs_cache: shortcuts/standard/update-specs-status.md: internal:shortcuts/standard/update-specs-status.md shortcuts/standard/welcome-user.md: internal:shortcuts/standard/welcome-user.md shortcuts/system/shortcut-explanation.md: internal:shortcuts/system/shortcut-explanation.md - shortcuts/system/skill-baseline.md: internal:shortcuts/system/skill-baseline.md shortcuts/system/skill-brief.md: internal:shortcuts/system/skill-brief.md - shortcuts/system/skill-minimal.md: internal:shortcuts/system/skill-minimal.md + shortcuts/system/skill.md: internal:shortcuts/system/skill.md templates/architecture-doc.md: internal:templates/architecture-doc.md templates/plan-spec.md: internal:templates/plan-spec.md templates/research-brief.md: internal:templates/research-brief.md From d3c33111ad81ea9e57cb1c10da60574074169bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 08:28:35 +0000 Subject: [PATCH 03/36] docs: extract generic inheritable field system in spec Refactor the spec to describe a reusable inheritable-fields module instead of copy-pasting spec_path inheritance logic for external_issue_url. Both fields use a shared registry and generic inherit/propagate functions. https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- .../plan-2026-02-10-external-issue-linking.md | 118 +++++++++++++++--- 1 file changed, 98 insertions(+), 20 deletions(-) diff --git a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md index 05f1e2d8..60ce66e3 100644 --- a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +++ b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md @@ -147,8 +147,10 @@ Labels sync bidirectionally: parse GitHub issue URLs, extract `{owner, repo, number}`, validate via `gh` CLI, and perform status/label operations. -3. **Inheritance and propagation**: Reuse the exact same parent-to-child inheritance - pattern as `spec_path` in `create.ts` and `update.ts`. +3. **Generic inheritable field system**: Extract the existing `spec_path` + parent-to-child inheritance logic into a reusable module that any field can opt + into. Both `spec_path` and `external_issue_url` use this shared logic — no + copy-pasting of inheritance code. 4. **Status sync on close/update**: When a bead's status changes, if it has a linked external issue, sync the status to GitHub using the mapping table. @@ -163,9 +165,10 @@ Labels sync bidirectionally: | Component | File(s) | Purpose | | --- | --- | --- | | Schema | `schemas.ts` | Add `external_issue_url` field | +| Inheritable fields | `inheritable-fields.ts` (new) | Generic parent→child field inheritance/propagation | | URL Parser | `github-issues.ts` (new) | Parse, validate, and operate on GitHub issues | -| Create | `create.ts` | `--external-issue` flag, parent inheritance | -| Update | `update.ts` | `--external-issue` flag, propagation to children | +| Create | `create.ts` | `--external-issue` flag, uses inheritable fields | +| Update | `update.ts` | `--external-issue` flag, uses inheritable fields | | Close | `close.ts` | Sync status to GitHub on close | | Reopen | `reopen.ts` | Sync status to GitHub on reopen | | Show | `show.ts` | Display external issue URL with highlighting | @@ -212,19 +215,83 @@ async function getGitHubIssueState(ref: GitHubIssueRef): Promise<{state: string, All GitHub API operations use `gh api` via child process, leveraging the existing `gh` CLI that `ensure-gh-cli.sh` installs. -### Inheritance Logic (Reuse from `spec_path`) +### Generic Inheritable Field System -**On create with `--parent`** (in `create.ts`): -- If `--external-issue` is not provided but parent has `external_issue_url`, - inherit from parent. +Currently, `spec_path` inheritance is implemented with inline logic in `create.ts` +(lines 113-119) and `update.ts` (lines 94-104, 151-164). Rather than copy-pasting +this logic for `external_issue_url`, we extract it into a generic system. -**On update** (in `update.ts`): -- If `external_issue_url` changes on a parent, propagate to children whose - `external_issue_url` is null or matches the old value. -- On re-parenting without explicit `--external-issue`, inherit from new parent - if the bead has no `external_issue_url`. +New module `packages/tbd/src/lib/inheritable-fields.ts`: -This is the exact same logic as `spec_path` inheritance and propagation. +```typescript +import type { Issue } from './types.js'; + +/** + * Configuration for a field that inherits from parent to child beads. + * All inheritable fields follow the same three rules: + * + * 1. On create with --parent: if the field is not explicitly set, inherit + * from parent. + * 2. On re-parenting: if the field is not explicitly set and the child has + * no value, inherit from new parent. + * 3. On parent update: if the field changes on the parent, propagate to + * children whose field is null or matches the old value. + */ +interface InheritableFieldConfig { + /** The field name on the Issue type */ + field: keyof Issue; +} + +/** Registry of all inheritable fields */ +const INHERITABLE_FIELDS: InheritableFieldConfig[] = [ + { field: 'spec_path' }, + { field: 'external_issue_url' }, +]; + +/** + * Inherit fields from a parent issue to a child issue being created. + * For each inheritable field: if the child has no explicit value, + * copy from parent. + */ +function inheritFromParent( + child: Partial, + parent: Issue, + explicitlySet: Set, +): void; + +/** + * Propagate field changes from a parent to its children. + * For each inheritable field that changed on the parent: + * update children whose value is null or matches the old value. + */ +async function propagateToChildren( + parent: Issue, + oldValues: Partial, + children: Issue[], + writeIssueFn: (issue: Issue) => Promise, +): Promise; +``` + +**Key design points:** + +- `INHERITABLE_FIELDS` is the single registry. Adding a new inheritable field + means adding one entry here — no other code changes needed for inheritance. +- The `create` and `update` commands call these shared functions instead of + inline field-specific logic. +- The existing `spec_path` inline logic is refactored to use this system as + part of this feature (not just `external_issue_url`). +- `explicitlySet` tracks which fields the user provided via CLI flags, so we + only inherit fields the user didn't explicitly set. + +**Three inheritance rules (same for all fields):** + +1. **On create with `--parent`**: If the field was not explicitly provided via + a CLI flag but the parent has a value, the child inherits it. +2. **On re-parenting** (via `update --parent`): If the field was not explicitly + provided and the child's current value is null, inherit from the new parent. +3. **On parent field update**: When a parent's inheritable field changes, + propagate to all children whose field is null or still matches the old + (inherited) value. Children with explicitly different values are untouched. ### API Changes @@ -256,13 +323,23 @@ external_issue_url: 'lww', ## Implementation Plan -### Phase 1: Schema, URL Parsing, and Core Linking +### Phase 1: Schema, URL Parsing, Inheritable Fields, and Core Linking -Add the field, parse GitHub URLs, validate issues, and wire up the basic -create/update/show/list functionality. No status or label sync yet. +Add the field, extract inheritable field logic, parse GitHub URLs, validate +issues, and wire up the basic create/update/show/list functionality. No status +or label sync yet. - [ ] Add `external_issue_url` field to `IssueSchema` in `schemas.ts` - [ ] Add `external_issue_url: 'lww'` to merge rules in `git.ts` +- [ ] Create `inheritable-fields.ts` module: + - [ ] Define `INHERITABLE_FIELDS` registry (`spec_path`, `external_issue_url`) + - [ ] Implement `inheritFromParent()` - inherit on create with `--parent` + - [ ] Implement `propagateToChildren()` - propagate on parent field update + - [ ] Write unit tests for inheritable field logic +- [ ] Refactor `create.ts` to use `inheritFromParent()` instead of inline + `spec_path` logic (existing behavior preserved, now generic) +- [ ] Refactor `update.ts` to use `propagateToChildren()` instead of inline + `spec_path` logic (existing behavior preserved, now generic) - [ ] Create `github-issues.ts` module with: - [ ] `parseGitHubIssueUrl()` - regex-based URL parsing - [ ] `isGitHubIssueUrl()` - URL type detection @@ -271,21 +348,22 @@ create/update/show/list functionality. No status or label sync yet. - [ ] Add `--external-issue ` flag to `create` command with: - [ ] URL validation (must be a valid GitHub issue URL) - [ ] Issue accessibility check via `gh api` - - [ ] Parent inheritance (same pattern as `spec_path`) + - [ ] Parent inheritance (via generic `inheritFromParent()`) - [ ] Add `--external-issue ` flag to `update` command with: - [ ] URL validation and accessibility check - - [ ] Propagation to children (same pattern as `spec_path`) + - [ ] Propagation to children (via generic `propagateToChildren()`) - [ ] Clear with `--external-issue ""` - [ ] Update `show` command to display `external_issue_url` with color highlighting - [ ] Add `--external-issue` filter to `list` command - [ ] Write unit tests for `github-issues.ts` (URL parsing, format detection) - [ ] Write unit tests for schema changes +- [ ] Verify existing `spec_path` tryscript tests still pass after refactor - [ ] Create golden tryscript tests for: - [ ] Create with `--external-issue` - [ ] Show displaying external issue URL - [ ] List filtering by external issue - [ ] Update to set/clear external issue - - [ ] Parent-to-child inheritance + - [ ] Parent-to-child inheritance (both `spec_path` and `external_issue_url`) - [ ] Propagation on parent update ### Phase 2: `gh` CLI Health Check and Setup Validation From 543ef942f2203630f8ea3cf325dd3e1eac6b8a7b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 08:31:36 +0000 Subject: [PATCH 04/36] docs: expand golden tests for inheritance and URL validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 5 detailed inheritance test scenarios covering parent→child, propagation, re-parenting, clearing, and mixed field inheritance - Add explicit URL validation error test cases (PR URLs, blob URLs, non-GitHub URLs, malformed URLs, inaccessible issues) - Document GitHub label auto-creation requirement (API does not auto-create) - Add inheritable-fields unit test section https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- .../plan-2026-02-10-external-issue-linking.md | 122 +++++++++++++++--- 1 file changed, 103 insertions(+), 19 deletions(-) diff --git a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md index 60ce66e3..c69d9e78 100644 --- a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +++ b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md @@ -136,6 +136,16 @@ Labels sync bidirectionally: - Label sync is additive for union merges: if both sides add different labels, both end up with the union. +**Label auto-creation on GitHub**: The GitHub API does NOT auto-create labels +when adding them to an issue. If a tbd bead has a label that doesn't exist as a +GitHub repo label, we must create it first. The implementation should: + +1. Attempt `POST /repos/{owner}/{repo}/labels` with the label name (use a default + color). If the label already exists, GitHub returns 422 — ignore that error. +2. Then `POST /repos/{owner}/{repo}/issues/{number}/labels` to add it to the issue. + +This two-step approach is idempotent and handles both new and existing labels. + ## Design ### Approach @@ -358,13 +368,7 @@ or label sync yet. - [ ] Write unit tests for `github-issues.ts` (URL parsing, format detection) - [ ] Write unit tests for schema changes - [ ] Verify existing `spec_path` tryscript tests still pass after refactor -- [ ] Create golden tryscript tests for: - - [ ] Create with `--external-issue` - - [ ] Show displaying external issue URL - - [ ] List filtering by external issue - - [ ] Update to set/clear external issue - - [ ] Parent-to-child inheritance (both `spec_path` and `external_issue_url`) - - [ ] Propagation on parent update +- [ ] Create golden tryscript tests (see Testing Strategy for detailed scenarios) ### Phase 2: `gh` CLI Health Check and Setup Validation @@ -433,12 +437,19 @@ and can be deferred. ### Unit Tests -1. **GitHub Issue URL Parsing** (`github-issues.test.ts`) - - Valid GitHub issue URLs (http and https) - - URLs with trailing slashes or query params (reject) - - Non-issue GitHub URLs (PRs, repos, blob URLs) - - Non-GitHub URLs (Jira, Linear, arbitrary) - - Edge cases: no issue number, non-numeric issue number +1. **GitHub Issue URL Parsing and Validation** (`github-issues.test.ts`) + - Valid GitHub issue URLs: `https://github.com/owner/repo/issues/123` → parses + - Valid with http: `http://github.com/owner/repo/issues/456` → parses + - Trailing slash rejected: `https://github.com/owner/repo/issues/123/` → null + - Query params rejected: `https://github.com/owner/repo/issues/123?foo=bar` → null + - PR URL rejected: `https://github.com/owner/repo/pull/123` → null + - Repo URL rejected: `https://github.com/owner/repo` → null + - Blob URL rejected: `https://github.com/owner/repo/blob/main/file.ts` → null + - Non-GitHub URL rejected: `https://jira.example.com/PROJ-123` → null + - Malformed: `not-a-url` → null + - No issue number: `https://github.com/owner/repo/issues/` → null + - Non-numeric issue number: `https://github.com/owner/repo/issues/abc` → null + - Extracts correct owner, repo, number from valid URLs 2. **Schema Validation** (add to `schemas.test.ts`) - `external_issue_url` accepts valid URLs @@ -455,23 +466,96 @@ and can be deferred. - Union behavior - Empty label lists -5. **Merge Rules** (add to `git.test.ts`) +5. **Inheritable Fields** (`inheritable-fields.test.ts`) + - `inheritFromParent()` copies all registered fields from parent when not + explicitly set on child + - `inheritFromParent()` does NOT overwrite fields the user explicitly set + - `propagateToChildren()` updates children with null or old-matching values + - `propagateToChildren()` skips children with explicitly different values + - Adding a new field to `INHERITABLE_FIELDS` automatically includes it in + inherit and propagate operations (no other code changes) + +6. **Merge Rules** (add to `git.test.ts`) - `external_issue_url` uses LWW correctly - Concurrent edits to `external_issue_url` resolved by timestamp ### Golden Tryscript Tests -New tryscript file: `tests/cli-external-issue-linking.tryscript.md` +New tryscript files covering the full range of inheritance and linking scenarios. + +#### `tests/cli-external-issue-linking.tryscript.md` -Covers: +Basic external issue linking operations: - Create with and without `--external-issue` (backward compatibility) - Show displays external issue URL - List filtering by external issue - Update to set/change/clear external issue URL -- Parent-to-child inheritance -- Propagation on parent update - Close bead → status message about GitHub sync -- Error cases: invalid URL, inaccessible issue + +**URL parsing and validation error scenarios** (must be golden-tested): +- Valid GitHub issue URL → succeeds + (`https://github.com/owner/repo/issues/123`) +- GitHub PR URL → error: "not a GitHub issue URL" (must reject PRs) + (`https://github.com/owner/repo/pull/123`) +- GitHub repo URL (no issue number) → error + (`https://github.com/owner/repo`) +- GitHub blob URL → error + (`https://github.com/owner/repo/blob/main/file.ts`) +- Non-GitHub URL → error: "only GitHub issue URLs are supported" + (`https://jira.example.com/browse/PROJ-123`) +- Malformed URL → error + (`not-a-url`, `github.com/owner/repo/issues/123` without scheme) +- Inaccessible issue (valid URL format but 404) → error: "issue not found + or not accessible" + +#### `tests/cli-inheritable-fields.tryscript.md` + +Comprehensive tests for the generic inheritable field system. These tests +must exercise both `spec_path` and `external_issue_url` to prove the +shared logic works for any registered field. + +**Scenario 1: Parent-to-child inheritance on create** +1. Create parent epic with `--spec` and `--external-issue` +2. Create child under parent (no explicit `--spec` or `--external-issue`) +3. Verify child inherited both `spec_path` and `external_issue_url` from parent +4. Create another child with explicit `--spec` (different from parent) +5. Verify that child has the explicit `spec_path` but inherited `external_issue_url` + +**Scenario 2: Propagation from parent to children on update** +1. Create parent epic with `--spec` and `--external-issue` +2. Create 3 children under parent (all inherit both fields) +3. Manually set child-3's `external_issue_url` to a different value +4. Update parent's `--external-issue` to a new URL +5. Verify child-1 and child-2 got the new `external_issue_url` (they had + the inherited value) +6. Verify child-3 was NOT updated (it had an explicitly different value) +7. Update parent's `--spec` to a new path +8. Verify same propagation logic applies to `spec_path` + +**Scenario 3: Re-parenting inherits from new parent** +1. Create parent-A with `--external-issue URL-A` +2. Create parent-B with `--external-issue URL-B` +3. Create orphan child (no parent, no external issue) +4. Re-parent child under parent-A +5. Verify child inherited `external_issue_url` from parent-A +6. Re-parent child under parent-B (child still has URL-A from first parent) +7. Verify child kept URL-A (not overwritten — only inherits if null) + +**Scenario 4: Clearing and re-inheriting** +1. Create parent with `--external-issue` +2. Create child (inherits from parent) +3. Clear child's `external_issue_url` with `--external-issue ""` +4. Verify child's `external_issue_url` is now null +5. Update parent's `--external-issue` to a new URL +6. Verify child gets the new URL (its value was null, so it's eligible + for propagation) + +**Scenario 5: Mixed inheritance — some fields set, some inherited** +1. Create parent with `--spec` only (no `--external-issue`) +2. Create child — inherits `spec_path`, no `external_issue_url` +3. Update parent to add `--external-issue` +4. Verify child now has `external_issue_url` propagated (was null) +5. Verify child still has original `spec_path` (unchanged) ### Integration Tests From 2fd00c882a1b7fc21aeff89a9f544c63940373f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 08:40:14 +0000 Subject: [PATCH 05/36] docs: redesign sync to use scoped sync-at-sync-time model External issue sync now happens only at `tbd sync` time, not on individual bead operations. Local operations act as a staging area. Adds --external as a third sync scope alongside --issues and --docs. Default `tbd sync` (no flags) syncs all three scopes. Key changes: - New Sync Architecture section with staging model and scope table - Phases 3/4 rewritten around sync.ts integration (no hooks in close/reopen) - Updated error handling for per-issue failure tolerance during sync - Resolved open question 3 (sync only at sync time) - Updated golden tests to verify scope isolation and staging behavior https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- .../plan-2026-02-10-external-issue-linking.md | 169 ++++++++++++------ 1 file changed, 118 insertions(+), 51 deletions(-) diff --git a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md index c69d9e78..7494b1a1 100644 --- a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +++ b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md @@ -111,7 +111,7 @@ providers in the future. ### Status Mapping: GitHub → tbd -When syncing from GitHub (on next bead update or explicit sync), the reverse mapping: +When pulling from GitHub during `tbd sync --external`, the reverse mapping: | GitHub State | GitHub `state_reason` | tbd Status | | --- | --- | --- | @@ -128,10 +128,10 @@ that was `blocked`, the bead moves to `closed`. Labels sync bidirectionally: -- **tbd → GitHub**: When a label is added/removed on a bead, the same label is - added/removed on the linked GitHub issue. -- **GitHub → tbd**: When a label is added/removed on the GitHub issue, the same - label is added/removed on the bead during the next sync. +- **tbd → GitHub**: At sync time, labels added/removed on a bead since last sync + are pushed to the linked GitHub issue. +- **GitHub → tbd**: At sync time, labels added/removed on the GitHub issue since + last sync are pulled into the bead. - Labels are matched by exact string equality. - Label sync is additive for union merges: if both sides add different labels, both end up with the union. @@ -162,11 +162,16 @@ This two-step approach is idempotent and handles both new and existing labels. into. Both `spec_path` and `external_issue_url` use this shared logic — no copy-pasting of inheritance code. -4. **Status sync on close/update**: When a bead's status changes, if it has a linked - external issue, sync the status to GitHub using the mapping table. +4. **Sync-at-sync-time**: External issue sync happens only when `tbd sync` is + called, not on individual bead operations. This makes local operations a + staging area — you can create, update, close, and label beads freely, then + sync everything in one batch. This mirrors how `--issues` syncs to the git + sync branch and `--docs` syncs doc caches: `--external` syncs to linked + external issues. -5. **Label sync**: On label add/remove operations, sync to the linked GitHub issue. - On explicit sync or bead update, pull label changes from GitHub. +5. **Bidirectional status and label sync**: At sync time, push local status/label + changes to GitHub and pull GitHub status/label changes to local beads, using + the mapping tables. 6. **Doctor check**: Add a `gh` CLI availability check to the `doctor` command. @@ -179,11 +184,9 @@ This two-step approach is idempotent and handles both new and existing labels. | URL Parser | `github-issues.ts` (new) | Parse, validate, and operate on GitHub issues | | Create | `create.ts` | `--external-issue` flag, uses inheritable fields | | Update | `update.ts` | `--external-issue` flag, uses inheritable fields | -| Close | `close.ts` | Sync status to GitHub on close | -| Reopen | `reopen.ts` | Sync status to GitHub on reopen | | Show | `show.ts` | Display external issue URL with highlighting | | List | `list.ts` | `--external-issue` filter option | -| Label | `label.ts` | Sync label changes to GitHub | +| Sync | `sync.ts` | Add `--external` scope for external issue sync | | Doctor | `doctor.ts` | Add `gh` CLI health check | | Merge rules | `git.ts` | Add `external_issue_url: 'lww'` | | Status mapping | `github-issues.ts` | Hardcoded mapping table | @@ -321,14 +324,64 @@ In `git.ts` `FIELD_STRATEGIES`: external_issue_url: 'lww', ``` +### Sync Architecture: Scoped Sync with Staging + +**Key principle**: Individual bead operations (`create`, `update`, `close`, +`label add`, etc.) only modify the local data. No external side effects. +External sync happens only when `tbd sync` is called. + +This means the local worktree acts as a **staging area**. You can make a +batch of changes — close several beads, add labels, update statuses — and +none of it touches GitHub until you explicitly sync. This also means you +can abort changes (e.g., via workspace operations) before they're pushed +externally. + +**Existing sync scopes** (`sync.ts`): + +| Flag | Scope | What it syncs | +| --- | --- | --- | +| `--issues` | Git sync branch | Push/pull bead data via `tbd-sync` branch | +| `--docs` | Doc cache | Sync docs from configured sources | + +**New sync scope**: + +| Flag | Scope | What it syncs | +| --- | --- | --- | +| `--external` | External issues | Push/pull status and labels to/from linked GitHub issues | + +**Default behavior**: `tbd sync` (no flags) syncs all scopes: issues, docs, +and external. Selective flags (`--issues`, `--docs`, `--external`) let you +choose which scopes to sync. + +**External sync flow** (what `--external` does): + +1. Find all beads with a non-null `external_issue_url` +2. For each linked bead: + a. Fetch current GitHub issue state (status, labels) via `gh api` + b. **Push local → GitHub**: If bead status or labels differ from what + GitHub has, push local changes to GitHub using the mapping tables + c. **Pull GitHub → local**: If GitHub state or labels differ from bead, + pull changes into the bead +3. Conflict resolution: If both sides changed since last sync, local wins + (consistent with LWW merge strategy). The losing value is logged. +4. Report a summary of synced issues (like the existing issue sync summary) + +**Ordering**: External sync runs after issue sync and doc sync. This ensures +that any local bead changes are committed to the sync branch first, then +pushed to external trackers. + ### Error Handling -- If `gh` CLI is not available when attempting to sync, log a warning and return - a non-zero exit code. The bead update itself still succeeds. -- If the GitHub API call fails (network error, auth error, permission error), - log the error, return non-zero exit code, but the bead update still succeeds. -- At link time (`--external-issue`), validate the issue exists and is readable. - If not accessible, fail the command with a clear error message. +- At link time (`--external-issue` on `create`/`update`), validate the issue + exists and is readable. If not accessible, fail the command with a clear + error message. +- During `tbd sync --external`: + - If `gh` CLI is not available, log a warning and skip external sync. + Return non-zero exit code. + - If a GitHub API call fails for a specific issue (network error, auth error, + permission error), log the error for that issue, continue syncing other + issues, and return non-zero exit code at the end. + - Individual issue sync failures do not block other issues from syncing. - All errors are surfaced to the user, never silently swallowed. ## Implementation Plan @@ -383,46 +436,54 @@ properly validates GitHub access. running `tbd setup --auto` - [ ] Write tests for the new doctor check -### Phase 3: Status Sync (tbd → GitHub) +### Phase 3: External Sync Scope and Status Sync -When bead status changes, propagate to the linked GitHub issue. +Add `--external` scope to `tbd sync` and implement bidirectional status sync. +- [ ] Add `--external` flag to `tbd sync` command: + - [ ] Default behavior: `tbd sync` (no flags) syncs issues + docs + external + - [ ] `tbd sync --external` syncs only external issues + - [ ] `tbd sync --issues` and `tbd sync --docs` continue to work as before + (no external sync unless `--external` also given or no flags at all) + - [ ] External sync runs after issue sync and doc sync +- [ ] Implement external sync loop in `sync.ts`: + - [ ] Find all beads with non-null `external_issue_url` + - [ ] For each: fetch GitHub state, compute diff, push/pull changes + - [ ] Continue on per-issue failures, report summary at end - [ ] Add status mapping table to `github-issues.ts`: - [ ] `tbd closed` → GitHub `closed` (`completed`) - [ ] `tbd deferred` → GitHub `closed` (`not_planned`) - [ ] `tbd open` / `in_progress` → GitHub `open` (reopen if closed) - [ ] `tbd blocked` → no change -- [ ] Add `closeGitHubIssue()` function using `gh api` -- [ ] Add `reopenGitHubIssue()` function using `gh api` -- [ ] Hook into `close` command: after closing bead, sync to GitHub -- [ ] Hook into `reopen` command: after reopening bead, sync to GitHub -- [ ] Hook into `update` command: when status changes, sync to GitHub -- [ ] Error handling: log warning on failure, non-zero exit code, but bead - update still succeeds +- [ ] Add GitHub API functions using `gh api`: + - [ ] `getGitHubIssueState()` - fetch current state, state_reason, labels + - [ ] `closeGitHubIssue()` - close with reason + - [ ] `reopenGitHubIssue()` - reopen +- [ ] Implement GitHub → tbd status mapping (reverse direction) +- [ ] Add sync summary output (e.g., "Synced 3 external issues: 2 updated, 1 unchanged") +- [ ] Error handling: per-issue failures logged, non-zero exit code, other + issues still sync - [ ] Write tests for status sync (mock `gh` CLI calls) -- [ ] Create golden tryscript tests for close/reopen sync behavior +- [ ] Write tests for sync scope selection logic +- [ ] Create golden tryscript tests for sync behavior -### Phase 4: Status Sync (GitHub → tbd) and Label Sync +### Phase 4: Label Sync (bidirectional, optional) -Pull status and label changes from GitHub into tbd beads. This phase is optional -and can be deferred. +Add bidirectional label sync as part of the external sync scope. This phase +is optional and can be deferred. -- [ ] Add `getGitHubIssueState()` function to fetch current state and labels -- [ ] Add GitHub → tbd status mapping logic -- [ ] Add label sync functions: - - [ ] `addGitHubLabel()` - add label on GitHub +- [ ] Add label sync functions to `github-issues.ts`: + - [ ] `addGitHubLabel()` - add label on GitHub (with auto-creation) - [ ] `removeGitHubLabel()` - remove label on GitHub - - [ ] `syncLabelsToGitHub()` - push local label diff to GitHub - - [ ] `syncLabelsFromGitHub()` - pull GitHub label diff to local -- [ ] Hook into `label add` / `label remove`: sync to GitHub -- [ ] Add pull-from-GitHub logic that runs on bead update or explicit sync: - - [ ] Fetch GitHub issue state - - [ ] Apply reverse status mapping if state changed - - [ ] Apply label diff (union/remove) -- [ ] Error handling: log warning on failure, non-zero exit code +- [ ] Extend the external sync loop to also sync labels: + - [ ] Compute label diff between bead and GitHub issue + - [ ] Push local label additions/removals to GitHub + - [ ] Pull GitHub label additions/removals to local bead + - [ ] Union semantics: if both sides added different labels, both get the union +- [ ] Handle label auto-creation on GitHub (two-step POST, ignore 422) - [ ] Write tests for label sync (mock `gh` CLI calls) -- [ ] Write tests for reverse status mapping -- [ ] Create golden tryscript tests for bidirectional sync +- [ ] Write tests for label diff computation +- [ ] Create golden tryscript tests for bidirectional label sync ### Design Doc Updates @@ -431,6 +492,8 @@ and can be deferred. - [ ] Update `tbd-design.md` §8.7 to reflect the implemented design - [ ] Add status mapping table to design doc - [ ] Add label sync design to design doc +- [ ] Document the scoped sync architecture (`--external` as third sync scope) +- [ ] Document the staging model (local operations don't touch external systems) - [ ] Document the inheritance and propagation rules alongside `spec_path` ## Testing Strategy @@ -490,7 +553,10 @@ Basic external issue linking operations: - Show displays external issue URL - List filtering by external issue - Update to set/change/clear external issue URL -- Close bead → status message about GitHub sync +- Close bead locally → verify no GitHub API call happens (staging only) +- `tbd sync --external` → verify GitHub API calls happen for linked beads +- `tbd sync --issues` → verify no external sync happens (scope isolation) +- `tbd sync` (no flags) → verify all three scopes run (issues + docs + external) **URL parsing and validation error scenarios** (must be golden-tested): - Valid GitHub issue URL → succeeds @@ -587,11 +653,12 @@ via LWW). Recommendation: Default to enabled when `use_gh_cli` is true. No additional config needed for v1. -3. **Should we sync on every bead operation or only on explicit `tbd sync`?** - Recommendation: Sync to GitHub on status-changing operations (close, reopen, - update status) and label operations. Pull from GitHub on explicit `tbd sync` - or when showing/updating a bead with a linked issue. This avoids excessive - API calls. +3. ~~**Should we sync on every bead operation or only on explicit `tbd sync`?**~~ + **RESOLVED**: Sync only at `tbd sync` time, never on individual operations. + Local operations are a staging area. This is consistent with how issue sync + (git) and doc sync already work, and allows batching changes before pushing + them externally. The `--external` scope flag selects this sync, and it's + included by default when no scope flags are given. 4. **How should we handle GitHub rate limits?** Recommendation: Rely on `gh` CLI's built-in rate limit handling for v1. If From b9a572e2fdff2c52fbc8ffa0b30e237ff38d403f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 08:42:13 +0000 Subject: [PATCH 06/36] docs: specify sync phase ordering for external issue sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External sync is split into two phases with careful ordering: 1. External-pull (before git commit) — capture GitHub changes into beads 2. External-push (after git commit) — push local changes to GitHub This ensures git sync branch is always the source of truth and failures at any step leave data in a consistent state. https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- .../plan-2026-02-10-external-issue-linking.md | 84 ++++++++++++++----- 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md index 7494b1a1..e25d6850 100644 --- a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +++ b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md @@ -353,22 +353,61 @@ externally. and external. Selective flags (`--issues`, `--docs`, `--external`) let you choose which scopes to sync. -**External sync flow** (what `--external` does): +**Full sync ordering** (when `tbd sync` runs all scopes): +The sync phases are ordered so that on failure at any step, the data is in +a sane, consistent state. The key insight: pull from external sources +*before* committing issues to git, and push to external sources *after* +committing to git. + +``` +Phase 1: Pull from external → local beads (external-pull) +Phase 2: Sync docs (docs) +Phase 3: Sync issues to git (push/pull) (issues) +Phase 4: Push from local beads → external (external-push) +``` + +**Why this order:** + +- **External-pull first** (Phase 1): Captures any changes made on GitHub + (status changes, label changes by collaborators) into local beads before + those beads are committed to the git sync branch. This means the git + commit reflects the latest merged state from all sources. + +- **Issues sync in the middle** (Phase 3): After pulling external changes, + commit the local bead data (which now includes merged external state) + to the git sync branch. If this fails (e.g., merge conflict, push + rejection), the external state has already been captured locally but + nothing has been pushed externally yet — a safe state. + +- **External-push last** (Phase 4): Only after the local bead state has + been successfully committed to git do we push changes to external + trackers. This means the git sync branch is the source of truth. If + the external push fails partway through, the git state is already + consistent and the next sync will retry the external push. + +**If any phase fails**, subsequent phases still attempt to run (best-effort), +but the overall sync returns a non-zero exit code. + +**External sync flow** (detail of Phases 1 and 4): + +*Phase 1 — External-pull:* 1. Find all beads with a non-null `external_issue_url` -2. For each linked bead: - a. Fetch current GitHub issue state (status, labels) via `gh api` - b. **Push local → GitHub**: If bead status or labels differ from what - GitHub has, push local changes to GitHub using the mapping tables - c. **Pull GitHub → local**: If GitHub state or labels differ from bead, - pull changes into the bead -3. Conflict resolution: If both sides changed since last sync, local wins +2. For each linked bead, fetch current GitHub issue state via `gh api` +3. If GitHub state differs from bead, apply the reverse mapping: + - Update bead status per the GitHub → tbd mapping table + - Pull label changes from GitHub into the bead +4. Conflict resolution: If both sides changed since last sync, local wins (consistent with LWW merge strategy). The losing value is logged. -4. Report a summary of synced issues (like the existing issue sync summary) -**Ordering**: External sync runs after issue sync and doc sync. This ensures -that any local bead changes are committed to the sync branch first, then -pushed to external trackers. +*Phase 4 — External-push:* +1. For each bead with a non-null `external_issue_url` +2. If bead status or labels differ from what GitHub has (based on the + state fetched in Phase 1), push local changes to GitHub: + - Map bead status to GitHub state and update via `gh api` + - Sync label diff to GitHub (with auto-creation as needed) +3. Report a summary of synced issues (e.g., "Synced 3 external issues: + 2 pushed, 1 pulled, 1 unchanged") ### Error Handling @@ -438,18 +477,25 @@ properly validates GitHub access. ### Phase 3: External Sync Scope and Status Sync -Add `--external` scope to `tbd sync` and implement bidirectional status sync. +Add `--external` scope to `tbd sync` and implement bidirectional status sync +with the correct two-phase ordering (pull before git commit, push after). - [ ] Add `--external` flag to `tbd sync` command: - - [ ] Default behavior: `tbd sync` (no flags) syncs issues + docs + external + - [ ] Default behavior: `tbd sync` (no flags) syncs all scopes - [ ] `tbd sync --external` syncs only external issues - [ ] `tbd sync --issues` and `tbd sync --docs` continue to work as before (no external sync unless `--external` also given or no flags at all) - - [ ] External sync runs after issue sync and doc sync -- [ ] Implement external sync loop in `sync.ts`: - - [ ] Find all beads with non-null `external_issue_url` - - [ ] For each: fetch GitHub state, compute diff, push/pull changes - - [ ] Continue on per-issue failures, report summary at end +- [ ] Implement two-phase external sync in `sync.ts`: + - [ ] **External-pull phase** (before issue git sync): + - [ ] Find all beads with non-null `external_issue_url` + - [ ] For each: fetch GitHub state, apply reverse status mapping, pull labels + - [ ] Write updated beads to local storage + - [ ] **External-push phase** (after issue git sync succeeds): + - [ ] For each linked bead: compare local state to fetched GitHub state + - [ ] Push status changes and label diffs to GitHub + - [ ] Continue on per-issue failures, report summary at end + - [ ] Integrate phases into existing sync ordering: + external-pull → docs → issues (git) → external-push - [ ] Add status mapping table to `github-issues.ts`: - [ ] `tbd closed` → GitHub `closed` (`completed`) - [ ] `tbd deferred` → GitHub `closed` (`not_planned`) From 179a70573a4c16109bb970e7d1e26053138ad9b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 08:44:59 +0000 Subject: [PATCH 07/36] =?UTF-8?q?docs:=20update=20design=20doc=20with=20ex?= =?UTF-8?q?ternal=20issue=20linking=20(=C2=A78.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add external_issue_url field to IssueSchema with design notes - Add external_issue_url: lww merge rule - Rewrite §8.7 with implemented design: simple URL field, sync architecture with 4-phase ordering, status/label mapping tables - Move multi-provider linked array and convention-based linking to future extensions section - Update GitHub Bridge (§7.2) to reference v1 implementation https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- packages/tbd/docs/tbd-design.md | 153 +++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 53 deletions(-) diff --git a/packages/tbd/docs/tbd-design.md b/packages/tbd/docs/tbd-design.md index b6b56726..164b11b0 100644 --- a/packages/tbd/docs/tbd-design.md +++ b/packages/tbd/docs/tbd-design.md @@ -1433,6 +1433,9 @@ const IssueSchema = BaseEntity.extend({ // Spec linking - path to related spec/doc (relative to repo root) spec_path: z.string().optional(), + // External issue linking - URL to linked external issue (e.g., GitHub Issues) + external_issue_url: z.string().url().optional(), + // Beads compatibility due_date: Timestamp.optional(), deferred_until: Timestamp.optional(), @@ -1479,9 +1482,24 @@ type Issue = z.infer; parent’s `spec_path` is updated, the new value propagates to all children whose `spec_path` was null or matched the parent’s old value (i.e., was inherited). Children with explicitly different `spec_path` values are not affected. - Re-parenting a child (via `tbd update --parent`) also inherits the new parent’s + Re-parenting a child (via `tbd update --parent`) also inherits the new parent's `spec_path` if the child has no existing `spec_path`. +- `external_issue_url`: Optional URL to a linked external issue tracker issue. + For v1, only GitHub issue URLs are supported + (e.g., `https://github.com/owner/repo/issues/123`). The URL is validated at link + time to ensure the issue exists and is accessible via `gh` CLI. + + **Inheritance from Parent:** Follows the same inheritance rules as `spec_path`: + both fields are registered in the generic inheritable field system. When creating + a child with `--parent`, the child inherits `external_issue_url` from the parent + if not explicitly set. When a parent's URL is updated, it propagates to children + whose URL was null or matched the old value. + + **Sync Behavior:** External issue sync (status and labels) happens only at + `tbd sync` time via the `--external` scope. See §8.7 for the full sync + architecture. + - `child_order_hints`: Optional array of internal IssueIds specifying preferred display order for children of this issue. Used by `tbd list --pretty` to control child ordering in tree views. @@ -2198,6 +2216,7 @@ const issueMergeRules: MergeRules = { dependencies: { strategy: 'merge_by_id', key: (d) => d.target }, parent_id: { strategy: 'lww' }, spec_path: { strategy: 'lww' }, + external_issue_url: { strategy: 'lww' }, due_date: { strategy: 'lww' }, deferred_until: { strategy: 'lww' }, created_by: { strategy: 'preserve_oldest' }, @@ -4957,17 +4976,19 @@ Post-process results to: #### GitHub Bridge -**Architecture**: +**v1 (implemented):** CLI-driven external issue linking via `external_issue_url` field +and `tbd sync --external`. See §8.7 for details. Supports bidirectional status and +label sync via `gh` CLI. -- Optional bridge process +**Future architecture** (beyond v1): -- Webhook-driven sync +- Optional bridge process with webhook-driven sync -- Outbox/inbox pattern +- Outbox/inbox pattern for offline resilience -- Rate limit aware +- Rate limit aware batching -**Use cases**: +**Future use cases** (beyond v1): - Mirror issues to GitHub for visibility @@ -5714,69 +5735,95 @@ branch. The gitignored working copy approach (Option 4) seems promising as it preserves the single-source-of-truth model while adding convenience. -### 8.7 External Issue Tracker Linking - -**Linking tbd issues to GitHub issues (and other providers)** - -A common workflow need is linking tbd issues to external issue trackers like GitHub -Issues, Jira, Linear, etc. -This would enable bidirectional sync of status and comments. - -**ID Convention Approach:** - -If all issue systems use clean, identifiable prefixes with unique patterns, linking -could be convention-based: - -- tbd: `is-a1b2c3` internal, `proj-a1b2c3` display (configurable prefix) +### 8.7 External Issue Linking -- GitHub: `github#456` or `gh#456` +**Linking tbd issues to external issue trackers (v1: GitHub Issues)** -- Jira: `PROJ-123` +tbd issues can be optionally linked to an external issue tracker via the +`external_issue_url` field. For v1, only GitHub issue URLs are supported. -- Linear: `LIN-abc` +See: `docs/project/specs/active/plan-2026-02-10-external-issue-linking.md` -These patterns are recognizable via regex, allowing automatic detection and linking when -referenced in descriptions, comments, or commit messages. +**Schema:** -**Metadata Model:** - -Issues could have a `linked` field (or use `extensions`) to store external references: - -```yaml -linked: - - provider: github - repo: owner/repo - issue: 456 - synced_at: 2025-01-10T10:00:00Z - - provider: jira - project: PROJ - key: PROJ-123 +```typescript +// In IssueSchema +external_issue_url: z.string().url().optional(), ``` -**Sync Behaviors:** - -- Closing a tbd issue could automatically close the linked GitHub issue (or vice versa) +The field stores the full GitHub issue URL (e.g., +`https://github.com/owner/repo/issues/123`). The URL is parsed to extract +`{owner, repo, number}` for API operations via `gh` CLI. -- Comments could sync bidirectionally +**Inheritance:** `external_issue_url` uses the same generic inheritable field system +as `spec_path`. When creating a child with `--parent`, the child inherits the URL +from the parent if not explicitly set. When a parent's URL changes, it propagates +to children whose URL was null or matched the old value. -- Status changes could propagate +**Sync Architecture:** -- Labels/tags could map between systems +External issue sync happens only at `tbd sync` time via the `--external` scope, +not on individual bead operations. Local operations act as a staging area. -**Implementation Considerations:** +The full sync ordering when `tbd sync` runs all scopes: -- Provider plugins/adapters for different external systems +``` +Phase 1: Pull from external → local beads (external-pull) +Phase 2: Sync docs (docs) +Phase 3: Sync issues to git (push/pull) (issues) +Phase 4: Push from local beads → external (external-push) +``` -- Conflict resolution when both sides change +External-pull runs first so that changes from GitHub are captured into local beads +before those beads are committed to the git sync branch. External-push runs last +so that only committed, consistent state is pushed to external trackers. -- Rate limiting and API authentication +**Status Mapping (tbd → GitHub):** -- Webhook-driven vs polling sync +| tbd Status | GitHub Action | GitHub State | `state_reason` | +| --- | --- | --- | --- | +| `open` | Reopen (if closed) | `open` | — | +| `in_progress` | Reopen (if closed) | `open` | — | +| `blocked` | No change | — | — | +| `deferred` | Close | `closed` | `not_planned` | +| `closed` | Close | `closed` | `completed` | -- Which system is authoritative for which fields +**Status Mapping (GitHub → tbd):** -**Recommendation:** Design the `linked` metadata structure now (even if unused), -implement GitHub bridge later with plugin architecture for other providers. +| GitHub State | `state_reason` | tbd Status | +| --- | --- | --- | +| `open` | `null`/`reopened` | `open` (only if bead is `closed`/`deferred`) | +| `closed` | `completed` | `closed` | +| `closed` | `not_planned` | `deferred` | +| `closed` | `duplicate` | `closed` | + +`blocked` and `in_progress` have no GitHub equivalent and are preserved. + +**Label Sync:** Labels sync bidirectionally at sync time via exact string matching +with union semantics. Labels are auto-created on GitHub if they don't exist +(two-step API: create label, then add to issue). + +**Future Extensions:** + +The current design uses a simple `external_issue_url` string field for v1 simplicity. +Future enhancements could include: + +- **Multiple external links per issue**: A structured `linked` array (possibly in + `extensions`) with provider-specific fields: + ```yaml + linked: + - provider: github + repo: owner/repo + issue: 456 + - provider: jira + project: PROJ + key: PROJ-123 + ``` +- **Additional providers**: Jira, Linear, and other project management systems, + each with provider-specific adapters for status/label mapping +- **Bidirectional comment sync** (see GitHub Bridge in §7.2) +- **Convention-based linking**: Auto-detecting issue references like `gh#456`, + `PROJ-123` in descriptions and commit messages * * * From e7ca3ef63316601710dfd25112cafa2e82ea5a65 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 21:40:53 +0000 Subject: [PATCH 08/36] docs: clarify use_gh_cli gating for external issue features Document that use_gh_cli: false disables all external issue features (linking, sync, validation). Updated across spec, design doc, and README. Also resolved open questions: no rate limit handling (let user/agent decide), full URLs only with clear error messages. https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- README.md | 6 +- .../plan-2026-02-10-external-issue-linking.md | 107 ++++++++++++++---- packages/tbd/docs/tbd-design.md | 26 ++++- 3 files changed, 114 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index cbcd557c..2537d315 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,11 @@ For local CLI usage, add them to your shell profile (`~/.zshrc` or `~/.bashrc`). for details. To disable automatic `gh` installation, pass `--no-gh-cli` during setup or set -`use_gh_cli: false` in `.tbd/config.yml` under `settings:`. +`use_gh_cli: false` in `.tbd/config.yml` under `settings:`. Note: disabling +`gh` also disables all external issue features — linking beads to GitHub issues +(`--external-issue`), bidirectional status/label sync (`tbd sync --external`), +and GitHub issue validation. The `external_issue_url` field can still exist on +beads from collaborators, but no sync or validation occurs locally. ### Migrating from Beads diff --git a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md index e25d6850..8893473a 100644 --- a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +++ b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md @@ -409,14 +409,62 @@ but the overall sync returns a non-zero exit code. 3. Report a summary of synced issues (e.g., "Synced 3 external issues: 2 pushed, 1 pulled, 1 unchanged") +### `use_gh_cli` Configuration Gate + +All external issue features require the GitHub CLI (`gh`). The existing +`use_gh_cli` config setting (in `.tbd/config.yml` under `settings:`, +default `true`) serves as the master gate for all external issue functionality. + +**When `use_gh_cli` is `false`:** + +- `--external-issue` flag on `create`/`update` is **rejected** with a clear + error: "External issue linking requires GitHub CLI. Set `use_gh_cli: true` + in config or run `tbd setup --auto`." +- `tbd sync --external` is a **no-op** with a warning: "External sync skipped: + GitHub CLI is disabled (use_gh_cli: false)." +- `tbd sync` (no flags) silently skips the external sync phases (phases 1 and + 4) — issues and docs sync still run normally. +- The `external_issue_url` field on the schema is unaffected — beads may still + have the field populated (e.g., from a collaborator who has `gh` enabled), + but no sync or validation occurs locally. +- The `doctor` command's `gh` CLI check reports "skipped" rather than a warning + when `use_gh_cli` is `false`. + +**When `use_gh_cli` is `true` (default):** + +- All external issue features are available. +- The `--external-issue` flag validates the URL and verifies the issue exists + via `gh api`. +- `tbd sync` includes the external sync phases. +- The `doctor` command checks `gh` availability and auth status. + +This gating behavior must be clearly documented in: +- CLI `--help` text for `--external-issue` flags +- Error messages (always suggest how to enable) +- The design doc §8.7 +- The README (GitHub authentication section) +- The `setup-github-cli` shortcut + ### Error Handling -- At link time (`--external-issue` on `create`/`update`), validate the issue - exists and is readable. If not accessible, fail the command with a clear - error message. +- At link time (`--external-issue` on `create`/`update`): + - If `use_gh_cli` is `false`, reject immediately with a clear error. + - If `use_gh_cli` is `true`, validate the URL format first (must be a full + GitHub issue URL like `https://github.com/owner/repo/issues/123`), then + verify the issue exists via `gh api`. Clear error messages for each + failure mode: + - Not a URL → "Invalid URL. Expected a full GitHub issue URL like + https://github.com/owner/repo/issues/123" + - GitHub PR URL → "This is a pull request URL, not an issue URL. + Expected: https://github.com/owner/repo/issues/123" + - Non-GitHub URL → "Only GitHub issue URLs are supported. Expected: + https://github.com/owner/repo/issues/123" + - Valid URL but 404 → "Issue not found or not accessible. Check the URL + and your GitHub authentication (`gh auth status`)." - During `tbd sync --external`: - - If `gh` CLI is not available, log a warning and skip external sync. - Return non-zero exit code. + - If `use_gh_cli` is `false`, skip with warning (see above). + - If `gh` CLI is not installed or not authenticated, log a warning and skip + external sync. Return non-zero exit code. - If a GitHub API call fails for a specific issue (network error, auth error, permission error), log the error for that issue, continue syncing other issues, and return non-zero exit code at the end. @@ -533,14 +581,26 @@ is optional and can be deferred. ### Design Doc Updates -- [ ] Update `tbd-design.md` §2.6.3 (IssueSchema) to add `external_issue_url` -- [ ] Update `tbd-design.md` merge rules (§5.5) to add `external_issue_url: 'lww'` -- [ ] Update `tbd-design.md` §8.7 to reflect the implemented design -- [ ] Add status mapping table to design doc -- [ ] Add label sync design to design doc -- [ ] Document the scoped sync architecture (`--external` as third sync scope) -- [ ] Document the staging model (local operations don't touch external systems) -- [ ] Document the inheritance and propagation rules alongside `spec_path` +- [x] Update `tbd-design.md` §2.6.3 (IssueSchema) to add `external_issue_url` +- [x] Update `tbd-design.md` merge rules (§5.5) to add `external_issue_url: 'lww'` +- [x] Update `tbd-design.md` §8.7 to reflect the implemented design +- [x] Add status mapping table to design doc +- [x] Add label sync design to design doc +- [x] Document the scoped sync architecture (`--external` as third sync scope) +- [x] Document the staging model (local operations don't touch external systems) +- [x] Document the inheritance and propagation rules alongside `spec_path` +- [ ] Add `use_gh_cli` to design doc ConfigSchema (§2.7.4 settings) +- [ ] Document `use_gh_cli` gating behavior in design doc §8.7 + +### User-Facing Doc Updates + +- [ ] Update README GitHub authentication section to mention external issue + features depend on `use_gh_cli: true` +- [ ] Update `setup-github-cli` shortcut to mention external issue linking + as a feature that requires `gh` CLI +- [ ] Ensure all `--external-issue` `--help` text includes format example + and mentions `use_gh_cli` requirement +- [ ] Ensure error messages include the expected URL format example ## Testing Strategy @@ -706,15 +766,18 @@ via LWW). them externally. The `--external` scope flag selects this sync, and it's included by default when no scope flags are given. -4. **How should we handle GitHub rate limits?** - Recommendation: Rely on `gh` CLI's built-in rate limit handling for v1. If - rate-limited, the error is surfaced to the user. Future enhancement could add - batching or queuing. - -5. **Should the `--external-issue` flag accept shorthand like `#123` for the current - repo?** - Recommendation: For v1, require full URLs for clarity and unambiguity. Shorthand - can be added as a convenience enhancement later. +4. ~~**How should we handle GitHub rate limits?**~~ + **RESOLVED**: We don't handle rate limits. If rate-limited, the `gh` CLI + surfaces the error. The user or agent decides whether to back off and retry. + No special retry logic, batching, or queuing in tbd. + +5. ~~**Should the `--external-issue` flag accept shorthand like `#123` for the current + repo?**~~ + **RESOLVED**: No. Only full GitHub issue URLs are accepted + (`https://github.com/owner/repo/issues/123`). However, `--help` text, error + messages, and documentation must be very clear about the expected format so + that agents have no confusion. Every error message should include an example + of the correct URL format. ## References diff --git a/packages/tbd/docs/tbd-design.md b/packages/tbd/docs/tbd-design.md index 164b11b0..5fbef08e 100644 --- a/packages/tbd/docs/tbd-design.md +++ b/packages/tbd/docs/tbd-design.md @@ -1582,11 +1582,19 @@ const ConfigSchema = z.object({ .object({ auto_sync: z.boolean().default(false), index_enabled: z.boolean().default(true), + use_gh_cli: z.boolean().default(true), // Master gate for GitHub CLI features }) .default({}), }); ``` +**`use_gh_cli` setting:** Controls whether GitHub CLI (`gh`) features are +enabled. When `true` (default), `tbd setup` installs a SessionStart hook that +ensures `gh` is available, and all external issue features (linking, sync, +validation) are active. When `false`, the hook is not installed and all `gh`- +dependent features are disabled — including external issue linking (§8.7) and +`tbd sync --external`. Set via `tbd setup --no-gh-cli` or directly in config. + > **Forward Compatibility Policy:** ConfigSchema uses Zod’s `strip()` mode, which > discards unknown fields. > To prevent data loss when users mix tbd versions: @@ -5744,6 +5752,19 @@ tbd issues can be optionally linked to an external issue tracker via the See: `docs/project/specs/active/plan-2026-02-10-external-issue-linking.md` +**Prerequisite: `use_gh_cli` must be `true`** + +All external issue features require the GitHub CLI (`gh`). The `use_gh_cli` +setting in ConfigSchema (see §2.7.4) serves as the master gate: + +- When `false`: `--external-issue` flags are rejected, `tbd sync --external` + is a no-op, and `tbd sync` (no flags) silently skips external phases. The + `external_issue_url` field can still exist on beads (from collaborators), + but no validation or sync occurs locally. +- When `true` (default): All features are available — URL validation at link + time, bidirectional status/label sync at `tbd sync` time, and `doctor` + checks for `gh` availability. + **Schema:** ```typescript @@ -5752,8 +5773,9 @@ external_issue_url: z.string().url().optional(), ``` The field stores the full GitHub issue URL (e.g., -`https://github.com/owner/repo/issues/123`). The URL is parsed to extract -`{owner, repo, number}` for API operations via `gh` CLI. +`https://github.com/owner/repo/issues/123`). Only full URLs are accepted — +no shorthand like `#123`. The URL is parsed to extract `{owner, repo, number}` +for API operations via `gh` CLI. **Inheritance:** `external_issue_url` uses the same generic inheritable field system as `spec_path`. When creating a child with `--parent`, the child inherits the URL From c86f58eb628e7e6b49377ea527e8910a181e5a19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 21:47:57 +0000 Subject: [PATCH 09/36] docs: expand spec doc update checklist to cover all docs comprehensively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover design doc CLI sections (§4.4, §4.7), tbd-docs.md CLI reference, tbd-prime.md workflow context, README commands section, shortcuts (plan-implementation-with-beads, implement-beads, agent-handoff, setup-github-cli), CLI --help text, and error message guidelines. https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- .../plan-2026-02-10-external-issue-linking.md | 105 ++++++++++++++---- 1 file changed, 83 insertions(+), 22 deletions(-) diff --git a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md index 8893473a..abc83a4f 100644 --- a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +++ b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md @@ -579,28 +579,89 @@ is optional and can be deferred. - [ ] Write tests for label diff computation - [ ] Create golden tryscript tests for bidirectional label sync -### Design Doc Updates - -- [x] Update `tbd-design.md` §2.6.3 (IssueSchema) to add `external_issue_url` -- [x] Update `tbd-design.md` merge rules (§5.5) to add `external_issue_url: 'lww'` -- [x] Update `tbd-design.md` §8.7 to reflect the implemented design -- [x] Add status mapping table to design doc -- [x] Add label sync design to design doc -- [x] Document the scoped sync architecture (`--external` as third sync scope) -- [x] Document the staging model (local operations don't touch external systems) -- [x] Document the inheritance and propagation rules alongside `spec_path` -- [ ] Add `use_gh_cli` to design doc ConfigSchema (§2.7.4 settings) -- [ ] Document `use_gh_cli` gating behavior in design doc §8.7 - -### User-Facing Doc Updates - -- [ ] Update README GitHub authentication section to mention external issue - features depend on `use_gh_cli: true` -- [ ] Update `setup-github-cli` shortcut to mention external issue linking - as a feature that requires `gh` CLI -- [ ] Ensure all `--external-issue` `--help` text includes format example - and mentions `use_gh_cli` requirement -- [ ] Ensure error messages include the expected URL format example +### Documentation Updates + +All documentation must be updated to reflect the new `--external-issue` flag, +`--external` sync scope, and `use_gh_cli` gating. This section lists every +document that needs changes. + +#### Design Doc (`packages/tbd/docs/tbd-design.md`) + +- [x] §2.6.3 IssueSchema: add `external_issue_url` field +- [x] §5.5 merge rules: add `external_issue_url: 'lww'` +- [x] §8.7: rewrite with implemented design (status/label mapping, sync arch) +- [x] §8.7: document `use_gh_cli` prerequisite +- [x] §2.7.4 ConfigSchema: add `use_gh_cli` to documented settings +- [ ] §4.4 Create command: add `--external-issue ` to options list and + examples. Note: `--spec` also needs adding (currently undocumented in §4.4) +- [ ] §4.4 Update command: add `--external-issue ` to options list and + examples +- [ ] §4.4 Show command: mention `external_issue_url` in output description +- [ ] §4.4 List command: add `--external-issue` filter option +- [ ] §4.7 Sync command: add `--external` scope flag, `--issues`, `--docs` + scope flags, and document the 4-phase sync ordering + +#### CLI Reference (`packages/tbd/docs/tbd-docs.md`) + +- [ ] `create` section (line ~193): add `--external-issue ` option with + description and example. Include URL format example in help text. +- [ ] `update` section (line ~294): add `--external-issue ` option +- [ ] `list` section (line ~236): add `--external-issue [url]` filter option + with example +- [ ] `show` section (line ~282): mention `external_issue_url` in output fields +- [ ] `sync` section (line ~449): add `--external`, `--issues`, `--docs` scope + flags. Document that default (no flags) syncs all scopes. + +#### Workflow Context (`packages/tbd/docs/tbd-prime.md`) + +- [ ] "Creating & Updating" section: add `--external-issue` to create example +- [ ] "Sync & Collaboration" section: mention `--external` scope flag +- [ ] Consider adding a brief external issue linking note to "Common Workflows" + +#### Top-Level README (`README.md`) + +- [x] GitHub authentication section: note that `use_gh_cli: false` disables + external issue features +- [ ] Commands section: add `--external-issue` to create/update examples +- [ ] Commands section: add `--external` to sync examples + +#### Shortcuts + +These shortcuts reference beads workflows and should mention that beads may +have linked external issues: + +- [ ] `plan-implementation-with-beads.md`: In the "Create a top-level epic" + step, show `--external-issue` as an optional flag alongside `--spec` + (epics are the natural place to link to GitHub issues) +- [ ] `implement-beads.md`: Note that beads may be linked to external GitHub + issues — agents should check `tbd show` output for `external_issue_url` + and be aware that `tbd sync` pushes status changes externally +- [ ] `agent-handoff.md`: Add "External issues" to the "What to Include" + checklist (whether beads have linked GitHub issues, sync status) +- [ ] `setup-github-cli.md`: Mention external issue linking as a feature that + requires `gh` CLI. List it alongside PR creation and code review. +- [ ] `code-review-and-commit.md`: No changes needed (doesn't deal with beads) +- [ ] `create-or-update-pr-simple.md`: No changes needed (doesn't deal with beads) + +#### CLI `--help` Text (in source code) + +- [ ] `create` command: `--external-issue` help must include format example: + `--external-issue Link to GitHub issue (e.g., https://github.com/owner/repo/issues/123)` +- [ ] `update` command: same format +- [ ] `list` command: `--external-issue [url]` filter help +- [ ] `sync` command: `--external` scope help text +- [ ] All `--external-issue` help should note: "Requires use_gh_cli: true" + +#### Error Messages (in source code) + +Every error path must include the expected URL format example so agents +are never confused: + +- [ ] Invalid URL format → include `https://github.com/owner/repo/issues/123` +- [ ] PR URL → "This is a pull request URL, not an issue URL" +- [ ] Non-GitHub URL → "Only GitHub issue URLs are supported" +- [ ] 404 → "Issue not found or not accessible" +- [ ] `use_gh_cli: false` → "Set use_gh_cli: true or run tbd setup --auto" ## Testing Strategy From c63fbf3480afc258f779c5dee8bfc696a9157354 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 22:18:26 +0000 Subject: [PATCH 10/36] docs: add detailed implementation plan with file:line code references Replace high-level task lists with exact source file locations, line numbers, and code snippets for every change. Organized into 4 phases (1a-1i, 2, 3a-3b, 4) with specific insertion points, interface changes, method signatures, and test file plans. Updated References section with comprehensive source file table and new files table. https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- .../plan-2026-02-10-external-issue-linking.md | 569 +++++++++++++++--- 1 file changed, 479 insertions(+), 90 deletions(-) diff --git a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md index abc83a4f..d2181b2a 100644 --- a/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +++ b/docs/project/specs/active/plan-2026-02-10-external-issue-linking.md @@ -473,111 +473,439 @@ This gating behavior must be clearly documented in: ## Implementation Plan +Each phase lists the exact source files, line numbers, and nature of changes. +Line numbers are approximate (based on current state) and may shift as earlier +phases are implemented. + +--- + ### Phase 1: Schema, URL Parsing, Inheritable Fields, and Core Linking Add the field, extract inheritable field logic, parse GitHub URLs, validate issues, and wire up the basic create/update/show/list functionality. No status or label sync yet. -- [ ] Add `external_issue_url` field to `IssueSchema` in `schemas.ts` -- [ ] Add `external_issue_url: 'lww'` to merge rules in `git.ts` -- [ ] Create `inheritable-fields.ts` module: - - [ ] Define `INHERITABLE_FIELDS` registry (`spec_path`, `external_issue_url`) - - [ ] Implement `inheritFromParent()` - inherit on create with `--parent` - - [ ] Implement `propagateToChildren()` - propagate on parent field update - - [ ] Write unit tests for inheritable field logic -- [ ] Refactor `create.ts` to use `inheritFromParent()` instead of inline - `spec_path` logic (existing behavior preserved, now generic) -- [ ] Refactor `update.ts` to use `propagateToChildren()` instead of inline - `spec_path` logic (existing behavior preserved, now generic) -- [ ] Create `github-issues.ts` module with: - - [ ] `parseGitHubIssueUrl()` - regex-based URL parsing - - [ ] `isGitHubIssueUrl()` - URL type detection - - [ ] `validateGitHubIssue()` - verify issue exists via `gh api` - - [ ] `formatGitHubIssueRef()` - format as `owner/repo#number` for display -- [ ] Add `--external-issue ` flag to `create` command with: - - [ ] URL validation (must be a valid GitHub issue URL) - - [ ] Issue accessibility check via `gh api` - - [ ] Parent inheritance (via generic `inheritFromParent()`) -- [ ] Add `--external-issue ` flag to `update` command with: - - [ ] URL validation and accessibility check - - [ ] Propagation to children (via generic `propagateToChildren()`) - - [ ] Clear with `--external-issue ""` -- [ ] Update `show` command to display `external_issue_url` with color highlighting -- [ ] Add `--external-issue` filter to `list` command -- [ ] Write unit tests for `github-issues.ts` (URL parsing, format detection) -- [ ] Write unit tests for schema changes -- [ ] Verify existing `spec_path` tryscript tests still pass after refactor -- [ ] Create golden tryscript tests (see Testing Strategy for detailed scenarios) +#### 1a. Schema and Merge Rules + +**`packages/tbd/src/lib/schemas.ts`** (line 149, after `spec_path`): +- [ ] Add field to `IssueSchema`: + ```typescript + external_issue_url: z.string().url().nullable().optional(), + ``` +- [ ] This auto-propagates to the `Issue` type via `types.ts:28` + (`type Issue = z.infer`) + +**`packages/tbd/src/file/git.ts`** (line 300, after `spec_path: 'lww'`): +- [ ] Add merge rule: + ```typescript + external_issue_url: 'lww', + ``` + +**`packages/tbd/src/lib/types.ts`** (lines 81-91, 96-109): +- [ ] Add `external_issue_url?: string | null` to `CreateIssueOptions` +- [ ] Add `external_issue_url?: string | null` to `UpdateIssueOptions` + +**Tests:** +- [ ] Add `external_issue_url` validation cases to `schemas.test.ts` +- [ ] Add LWW merge test for `external_issue_url` in `git.test.ts` + +#### 1b. GitHub Issue URL Parser + +**`packages/tbd/src/file/github-issues.ts`** (NEW FILE): + +Place alongside `github-fetch.ts` (existing GitHub URL utilities). + +```typescript +// Regex: https://github.com/{owner}/{repo}/issues/{number} +const GITHUB_ISSUE_RE = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)$/; + +// Also need a PR detection regex for better error messages +const GITHUB_PR_RE = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)$/; + +interface GitHubIssueRef { owner: string; repo: string; number: number; url: string; } + +export function parseGitHubIssueUrl(url: string): GitHubIssueRef | null; +export function isGitHubIssueUrl(url: string): boolean; +export function isGitHubPrUrl(url: string): boolean; +export function formatGitHubIssueRef(ref: GitHubIssueRef): string; // → "owner/repo#123" + +// gh api operations (all use execFile('gh', [...]) like github-fetch.ts:16) +export async function validateGitHubIssue(ref: GitHubIssueRef): Promise; +export async function getGitHubIssueState(ref: GitHubIssueRef): Promise<{ + state: string; state_reason: string | null; labels: string[]; +}>; +export async function closeGitHubIssue( + ref: GitHubIssueRef, reason: 'completed' | 'not_planned' +): Promise; +export async function reopenGitHubIssue(ref: GitHubIssueRef): Promise; +export async function addGitHubLabel(ref: GitHubIssueRef, label: string): Promise; +export async function removeGitHubLabel(ref: GitHubIssueRef, label: string): Promise; +``` + +**Pattern reference:** `github-fetch.ts:12-16` uses `execFile` + `promisify` +for `gh api` calls. Follow same pattern. + +**`packages/tbd/src/file/github-issues.test.ts`** (NEW FILE): +- [ ] URL parsing: valid URLs, trailing slash, query params, PR URLs, blob + URLs, non-GitHub, malformed, no issue number, non-numeric +- [ ] Format: `parseGitHubIssueUrl` → correct owner/repo/number +- [ ] Detection: `isGitHubIssueUrl()`, `isGitHubPrUrl()` + +#### 1c. Generic Inheritable Field System + +**`packages/tbd/src/lib/inheritable-fields.ts`** (NEW FILE): + +```typescript +import type { Issue } from './types.js'; + +interface InheritableFieldConfig { + field: keyof Issue; +} + +export const INHERITABLE_FIELDS: InheritableFieldConfig[] = [ + { field: 'spec_path' }, + { field: 'external_issue_url' }, +]; + +export function inheritFromParent( + child: Partial, + parent: Issue, + explicitlySet: Set, +): void; + +export async function propagateToChildren( + parent: Issue, + oldValues: Partial>, + children: Issue[], + writeIssueFn: (issue: Issue) => Promise, +): Promise; // returns count of updated children +``` + +**`packages/tbd/src/lib/inheritable-fields.test.ts`** (NEW FILE): +- [ ] `inheritFromParent()` copies registered fields from parent when not + explicitly set +- [ ] `inheritFromParent()` does NOT overwrite explicitly set fields +- [ ] `propagateToChildren()` updates children with null or old-matching values +- [ ] `propagateToChildren()` skips children with different values +- [ ] Both `spec_path` and `external_issue_url` are exercised + +#### 1d. Refactor `create.ts` to Use Inheritable Fields + +**`packages/tbd/src/cli/commands/create.ts`**: + +Current inline `spec_path` inheritance (lines 113-119): +```typescript +// Inherit spec_path from parent if not explicitly provided +if (!specPath && parentId) { + const parentIssue = await readIssue(dataSyncDir, parentId); + if (parentIssue.spec_path) { + specPath = parentIssue.spec_path; + } +} +``` + +Changes needed: +- [ ] **Line 29-41** (`CreateOptions` interface): Add `externalIssue?: string` +- [ ] **Lines 67-76** (spec validation block): Add parallel validation block + for `--external-issue`: + - Read config to check `use_gh_cli` (`readConfig` already imported, line 26) + - If `use_gh_cli` is false, throw `ValidationError` with clear message + - Parse URL via `parseGitHubIssueUrl()` + - Validate issue exists via `validateGitHubIssue()` +- [ ] **Lines 113-119** (spec_path inheritance): Replace with call to + `inheritFromParent()` from `inheritable-fields.ts`. Pass a + `Set` tracking which fields the user explicitly provided + (`spec_path` if `--spec` was given, `external_issue_url` if + `--external-issue` was given). +- [ ] **Line 138** (`spec_path: specPath`): Add `external_issue_url` to the + issue data object +- [ ] **Line 201** (Commander `.option('--spec ...')`): Add: + ```typescript + .option('--external-issue ', + 'Link to GitHub issue (e.g., https://github.com/owner/repo/issues/123). Requires use_gh_cli: true') + ``` + +#### 1e. Refactor `update.ts` to Use Inheritable Fields + +**`packages/tbd/src/cli/commands/update.ts`**: + +Current inline logic: +- `spec_path` re-parenting inheritance (lines 94-104) +- `spec_path` propagation to children (lines 151-164) + +Changes needed: +- [ ] **Line 30-41** (`UpdateOptions` interface): Add `externalIssue?: string` +- [ ] **Lines 76-77** (`oldSpecPath` capture): Generalize to capture old + values for all inheritable fields: + ```typescript + const oldInheritableValues: Partial> = {}; + for (const config of INHERITABLE_FIELDS) { + oldInheritableValues[config.field] = issue[config.field]; + } + ``` +- [ ] **Line 90** (`spec_path` update): Add `external_issue_url` update + alongside: + ```typescript + if (updates.external_issue_url !== undefined) + issue.external_issue_url = updates.external_issue_url; + ``` +- [ ] **Lines 94-104** (re-parenting inheritance): Replace inline + `spec_path`-specific logic with `inheritFromParent()` call. Track + `explicitlySet` from CLI flags. +- [ ] **Lines 151-164** (propagation to children): Replace inline + `spec_path`-specific logic with `propagateToChildren()` call. Pass + `oldInheritableValues` and write function. +- [ ] **Lines 371-383** (spec CLI option handling in `buildUpdates`): + Add parallel block for `--external-issue`: + - If non-empty: validate URL format, check `use_gh_cli`, validate via + `gh api`, set `updates.external_issue_url` + - If empty string: set `updates.external_issue_url = null` (clear) +- [ ] **Line 437** (Commander `.option('--spec ...')`): Add: + ```typescript + .option('--external-issue ', + 'Set or clear external issue (empty clears). Requires use_gh_cli: true') + ``` + +#### 1f. Update `show.ts` Display + +**`packages/tbd/src/cli/commands/show.ts`** (lines 69-70): + +Current `spec_path` highlighting: +```typescript +} else if (line.startsWith('spec_path:')) { + console.log(`${colors.dim('spec_path:')} ${colors.id(line.slice(11))}`); +``` + +- [ ] Add after line 70: + ```typescript + } else if (line.startsWith('external_issue_url:')) { + console.log(`${colors.dim('external_issue_url:')} ${colors.id(line.slice(20))}`); + ``` + +#### 1g. Add `--external-issue` Filter to `list.ts` + +**`packages/tbd/src/cli/commands/list.ts`**: + +- [ ] **Line 33-50** (`ListOptions` interface): Add `externalIssue?: string` +- [ ] **Lines 192-260** (`filterIssues` method): Add filter block after + the `spec` filter (lines 247-251): + ```typescript + // External issue filter + if (options.externalIssue) { + if (!issue.external_issue_url) return false; + // If a specific URL is given, match it; otherwise just filter for + // any linked issue + if (options.externalIssue !== 'true' && + issue.external_issue_url !== options.externalIssue) { + return false; + } + } + ``` +- [ ] **Line 96** (`displayIssues` mapping): Add + `external_issue_url: i.external_issue_url ?? undefined` after `spec_path` +- [ ] **Lines 289-316** (Commander options): Add after `--spec`: + ```typescript + .option('--external-issue [url]', + 'Filter by external issue (URL optional, shows all linked if no URL given)') + ``` + +#### 1h. Golden Tests + +**`packages/tbd/tests/cli-external-issue-linking.tryscript.md`** (NEW FILE): +- [ ] Basic CRUD with `--external-issue` +- [ ] URL validation error scenarios (PR URL, non-GitHub, malformed, etc.) +- [ ] Show displays URL, list filters by URL + +**`packages/tbd/tests/cli-inheritable-fields.tryscript.md`** (NEW FILE): +- [ ] 5 scenarios from Testing Strategy section (parent-to-child, propagation, + re-parenting, clearing, mixed inheritance) +- [ ] Both `spec_path` and `external_issue_url` exercised in each scenario + +#### 1i. Verification + +- [ ] All existing `spec_path` tryscript tests pass (the refactor to + `inheritable-fields.ts` must be behavior-preserving) +- [ ] `pnpm test` passes +- [ ] `pnpm build` passes + +--- ### Phase 2: `gh` CLI Health Check and Setup Validation Ensure `gh` CLI availability is verified in `doctor` and that the setup flow properly validates GitHub access. -- [ ] Add `gh` CLI availability check to `doctor` command: - - [ ] Check if `gh` is in PATH - - [ ] Check if `gh auth status` succeeds - - [ ] Report as integration check (not blocking, but informational) -- [ ] Add `--fix` support: if `gh` missing and `use_gh_cli` is true, suggest - running `tbd setup --auto` -- [ ] Write tests for the new doctor check +**`packages/tbd/src/cli/commands/doctor.ts`**: + +- [ ] **Lines 136-142** (integration checks): Add a third integration check: + ```typescript + // Integration 3: GitHub CLI (gh) + integrationChecks.push(await this.checkGhCli()); + ``` +- [ ] Add new method `checkGhCli()` (after `checkCodexAgents()`, line ~602): + ```typescript + private async checkGhCli(): Promise { + // If use_gh_cli is false, report as skipped + if (this.config?.settings?.use_gh_cli === false) { + return { + name: 'GitHub CLI (gh)', + status: 'ok', + message: 'disabled (use_gh_cli: false)', + }; + } + // Check if gh is available + try { + await execFileAsync('gh', ['--version']); + } catch { + return { + name: 'GitHub CLI (gh)', + status: 'warn', + message: 'not found in PATH', + suggestion: 'Run: tbd setup --auto, or set use_gh_cli: false', + }; + } + // Check auth + try { + await execFileAsync('gh', ['auth', 'status']); + return { name: 'GitHub CLI (gh)', status: 'ok' }; + } catch { + return { + name: 'GitHub CLI (gh)', + status: 'warn', + message: 'not authenticated', + suggestion: 'Run: gh auth login, or set GH_TOKEN env var', + }; + } + } + ``` +- [ ] **Line 10** (imports): Add `execFile` from `node:child_process` and + `promisify` from `node:util` (or reuse from git.ts) + +**Tests:** +- [ ] Add `checkGhCli` test cases to doctor tests: gh missing, gh + unauthenticated, gh available, use_gh_cli=false +- [ ] Verify existing doctor tests still pass + +--- ### Phase 3: External Sync Scope and Status Sync Add `--external` scope to `tbd sync` and implement bidirectional status sync with the correct two-phase ordering (pull before git commit, push after). -- [ ] Add `--external` flag to `tbd sync` command: - - [ ] Default behavior: `tbd sync` (no flags) syncs all scopes - - [ ] `tbd sync --external` syncs only external issues - - [ ] `tbd sync --issues` and `tbd sync --docs` continue to work as before - (no external sync unless `--external` also given or no flags at all) -- [ ] Implement two-phase external sync in `sync.ts`: - - [ ] **External-pull phase** (before issue git sync): - - [ ] Find all beads with non-null `external_issue_url` - - [ ] For each: fetch GitHub state, apply reverse status mapping, pull labels - - [ ] Write updated beads to local storage - - [ ] **External-push phase** (after issue git sync succeeds): - - [ ] For each linked bead: compare local state to fetched GitHub state - - [ ] Push status changes and label diffs to GitHub - - [ ] Continue on per-issue failures, report summary at end - - [ ] Integrate phases into existing sync ordering: - external-pull → docs → issues (git) → external-push -- [ ] Add status mapping table to `github-issues.ts`: - - [ ] `tbd closed` → GitHub `closed` (`completed`) - - [ ] `tbd deferred` → GitHub `closed` (`not_planned`) - - [ ] `tbd open` / `in_progress` → GitHub `open` (reopen if closed) - - [ ] `tbd blocked` → no change -- [ ] Add GitHub API functions using `gh api`: - - [ ] `getGitHubIssueState()` - fetch current state, state_reason, labels - - [ ] `closeGitHubIssue()` - close with reason - - [ ] `reopenGitHubIssue()` - reopen -- [ ] Implement GitHub → tbd status mapping (reverse direction) -- [ ] Add sync summary output (e.g., "Synced 3 external issues: 2 updated, 1 unchanged") -- [ ] Error handling: per-issue failures logged, non-zero exit code, other - issues still sync -- [ ] Write tests for status sync (mock `gh` CLI calls) -- [ ] Write tests for sync scope selection logic -- [ ] Create golden tryscript tests for sync behavior +#### 3a. Sync Scope Changes + +**`packages/tbd/src/cli/commands/sync.ts`**: + +- [ ] **Lines 58-65** (`SyncOptions` interface): Add `external?: boolean`: + ```typescript + interface SyncOptions { + push?: boolean; + pull?: boolean; + local?: boolean; + issues?: boolean; + docs?: boolean; + external?: boolean; // NEW + } + ``` +- [ ] **Lines 89-103** (scope selection logic): Extend to handle `--external`: + ```typescript + const hasSelectiveFlag = Boolean(options.issues) || Boolean(options.docs) + || Boolean(options.external); + // ... + const syncExternal = Boolean(options.external) + || (!hasSelectiveFlag && !hasExclusiveIssueFlag); + ``` + Also: `--push`/`--pull` should be rejected with `--external` (like `--docs`). +- [ ] **Lines 105-116** (sync steps): Reorder to 4 phases: + ``` + // Phase 1: External-pull (if syncExternal && use_gh_cli) + // Phase 2: Docs sync (if syncDocs) + // Phase 3: Issues git sync (if syncIssues) + // Phase 4: External-push (if syncExternal && use_gh_cli) + ``` + The `use_gh_cli` gate check: read config, check + `config.settings.use_gh_cli`. If false: + - `--external` explicitly → warn "External sync skipped: use_gh_cli is false" + - default (no flags) → silently skip phases 1/4 +- [ ] **Lines 1100-1113** (Commander definition): Add option: + ```typescript + .option('--external', 'Sync only external issues (not issues or docs)') + ``` + +#### 3b. External Sync Implementation + +**`packages/tbd/src/cli/commands/sync.ts`** (new methods): + +- [ ] Add `syncExternalPull()` method: + - Load all beads via `listIssues(dataSyncDir)` + - Filter to beads with non-null `external_issue_url` + - For each: parse URL, call `getGitHubIssueState()`, apply reverse mapping + - Write updated beads via `writeIssue()` + - Return count of updated beads +- [ ] Add `syncExternalPush()` method: + - For each linked bead: compare local state to fetched state (from pull) + - Push status via `closeGitHubIssue()` / `reopenGitHubIssue()` + - Return count of pushed changes +- [ ] Add summary output: "Synced N external issues: X pulled, Y pushed, + Z unchanged" + +**`packages/tbd/src/file/github-issues.ts`** (status mapping tables): + +- [ ] Add status mapping constants: + ```typescript + // tbd → GitHub mapping + const TBD_TO_GITHUB_STATUS: Record = { + open: { state: 'open' }, + in_progress: { state: 'open' }, + blocked: null, // no change + deferred: { state: 'closed', state_reason: 'not_planned' }, + closed: { state: 'closed', state_reason: 'completed' }, + }; + + // GitHub → tbd mapping + function githubToTbdStatus( + state: string, stateReason: string | null, currentTbdStatus: string + ): string | null; + ``` + +**Tests:** +- [ ] Status mapping unit tests in `github-issues.test.ts` +- [ ] Sync scope selection unit tests in `sync.test.ts` +- [ ] Mock `gh api` calls to test sync flow end-to-end +- [ ] Golden tryscript tests for sync behavior + +--- ### Phase 4: Label Sync (bidirectional, optional) Add bidirectional label sync as part of the external sync scope. This phase is optional and can be deferred. -- [ ] Add label sync functions to `github-issues.ts`: - - [ ] `addGitHubLabel()` - add label on GitHub (with auto-creation) - - [ ] `removeGitHubLabel()` - remove label on GitHub -- [ ] Extend the external sync loop to also sync labels: - - [ ] Compute label diff between bead and GitHub issue - - [ ] Push local label additions/removals to GitHub - - [ ] Pull GitHub label additions/removals to local bead - - [ ] Union semantics: if both sides added different labels, both get the union -- [ ] Handle label auto-creation on GitHub (two-step POST, ignore 422) -- [ ] Write tests for label sync (mock `gh` CLI calls) -- [ ] Write tests for label diff computation -- [ ] Create golden tryscript tests for bidirectional label sync +**`packages/tbd/src/file/github-issues.ts`**: + +- [ ] Add `addGitHubLabel()`: + - Step 1: `POST /repos/{owner}/{repo}/labels` (create if needed, ignore 422) + - Step 2: `POST /repos/{owner}/{repo}/issues/{number}/labels` +- [ ] Add `removeGitHubLabel()`: + - `DELETE /repos/{owner}/{repo}/issues/{number}/labels/{label}` +- [ ] Add `computeLabelDiff()` helper: + ```typescript + function computeLabelDiff( + localLabels: string[], remoteLabels: string[] + ): { toAdd: string[]; toRemove: string[] }; + ``` + +**`packages/tbd/src/cli/commands/sync.ts`**: + +- [ ] Extend `syncExternalPull()` to also pull label changes +- [ ] Extend `syncExternalPush()` to also push label diffs +- [ ] Union semantics: if both sides added different labels, both get union + +**Tests:** +- [ ] Label diff computation tests in `github-issues.test.ts` +- [ ] Label sync mock tests +- [ ] Golden tryscript tests for bidirectional label sync ### Documentation Updates @@ -842,14 +1170,75 @@ via LWW). ## References -- `packages/tbd/src/lib/schemas.ts` — Current bead schema (line 118-151) -- `packages/tbd/src/cli/commands/create.ts` — `spec_path` inheritance pattern (lines 113-119) -- `packages/tbd/src/cli/commands/update.ts` — `spec_path` propagation pattern (lines 151-164) -- `packages/tbd/src/file/git.ts` — Merge strategy rules (lines 277-308) -- `packages/tbd/src/file/github-fetch.ts` — Existing GitHub URL parsing patterns -- `packages/tbd/src/cli/commands/doctor.ts` — Health check infrastructure -- `packages/tbd/docs/tbd-design.md` §8.7 — External Issue Tracker Linking design (lines 5717-5779) -- `packages/tbd/docs/tbd-design.md` §7.2 — Future GitHub Bridge architecture (lines 4958-4976) -- `docs/project/specs/done/plan-2026-01-26-spec-linking.md` — Reference spec (similar feature) +### Source Files (with key line numbers) + +| File | Key Lines | What's There | +| --- | --- | --- | +| `packages/tbd/src/lib/schemas.ts` | 118-151 | `IssueSchema` definition | +| | 149-150 | `spec_path` field (template for `external_issue_url`) | +| | ~280 | `use_gh_cli: z.boolean().default(true)` in ConfigSchema | +| `packages/tbd/src/lib/types.ts` | 28 | `Issue` type (inferred from schema) | +| | 81-91 | `CreateIssueOptions` (needs `external_issue_url`) | +| | 96-109 | `UpdateIssueOptions` (needs `external_issue_url`) | +| `packages/tbd/src/cli/commands/create.ts` | 29-41 | `CreateOptions` interface | +| | 67-76 | `--spec` validation block (template for `--external-issue`) | +| | 95 | `readConfig(tbdRoot)` — config already loaded here | +| | 113-119 | `spec_path` inheritance from parent (to refactor) | +| | 138 | `spec_path: specPath` in issue data (add `external_issue_url`) | +| | 201 | `.option('--spec ...')` Commander flag | +| `packages/tbd/src/cli/commands/update.ts` | 30-41 | `UpdateOptions` interface | +| | 76-77 | `oldSpecPath` capture (generalize to all inheritable) | +| | 90 | `spec_path` update (add `external_issue_url`) | +| | 94-104 | Re-parent inheritance (to refactor) | +| | 151-164 | Propagation to children (to refactor) | +| | 371-383 | `--spec` CLI option handling in `buildUpdates` | +| | 437 | `.option('--spec ...')` Commander flag | +| `packages/tbd/src/cli/commands/show.ts` | 69-70 | `spec_path` color highlighting (add `external_issue_url`) | +| `packages/tbd/src/cli/commands/list.ts` | 33-50 | `ListOptions` interface | +| | 96 | `spec_path` in displayIssues (add `external_issue_url`) | +| | 247-251 | `--spec` filter block (template for `--external-issue`) | +| | 301-304 | `.option('--spec ...')` Commander flag | +| `packages/tbd/src/cli/commands/sync.ts` | 58-65 | `SyncOptions` interface (add `external`) | +| | 89-103 | Scope selection logic (extend for `--external`) | +| | 105 | Step 1: docs sync | +| | 116 | Step 2: issues sync | +| | 1100-1113 | Commander definition + options | +| `packages/tbd/src/cli/commands/close.ts` | 56-61 | Idempotent close (no external side effects — by design) | +| `packages/tbd/src/cli/commands/doctor.ts` | 88-133 | 15 health checks | +| | 136-142 | 2 integration checks (add `gh` CLI check) | +| | 562-601 | Integration check methods (add `checkGhCli()`) | +| `packages/tbd/src/file/git.ts` | 277-308 | `FIELD_STRATEGIES` merge rules | +| | 300 | `spec_path: 'lww'` (add `external_issue_url`) | +| `packages/tbd/src/file/github-fetch.ts` | 12-16 | `execFile` + `promisify` pattern for `gh` CLI | +| | 28, 36 | GitHub URL regex patterns (reference for issue regex) | +| | 63 | `isGitHubUrl()` helper | + +### New Files to Create + +| File | Purpose | +| --- | --- | +| `packages/tbd/src/file/github-issues.ts` | GitHub issue URL parsing, validation, and API operations | +| `packages/tbd/src/lib/inheritable-fields.ts` | Generic parent→child field inheritance/propagation | +| `packages/tbd/src/file/github-issues.test.ts` | URL parsing and status mapping tests | +| `packages/tbd/src/lib/inheritable-fields.test.ts` | Inheritance logic tests | +| `packages/tbd/tests/cli-external-issue-linking.tryscript.md` | Golden tests for external issue linking | +| `packages/tbd/tests/cli-inheritable-fields.tryscript.md` | Golden tests for inheritance system | + +### Documentation Files to Update + +| File | Sections | +| --- | --- | +| `packages/tbd/docs/tbd-design.md` | §2.6.3, §2.7.4, §4.4, §4.7, §5.5, §7.2, §8.7 | +| `packages/tbd/docs/tbd-docs.md` | create, update, list, show, sync sections | +| `packages/tbd/docs/tbd-prime.md` | Creating, Sync, Common Workflows | +| `README.md` | GitHub auth, Commands sections | +| `packages/tbd/docs/shortcuts/standard/plan-implementation-with-beads.md` | Epic creation | +| `packages/tbd/docs/shortcuts/standard/implement-beads.md` | Bead awareness | +| `packages/tbd/docs/shortcuts/standard/agent-handoff.md` | Handoff checklist | +| `packages/tbd/docs/shortcuts/standard/setup-github-cli.md` | Feature list | + +### External References + +- `docs/project/specs/done/plan-2026-01-26-spec-linking.md` — Reference spec for similar `spec_path` feature - [GitHub Issues API: state and state_reason](https://docs.github.com/en/rest/issues) - [GitHub REST API: Labels](https://docs.github.com/en/rest/issues/labels) From 2668ce18b1016090923d282b07417c317b051a15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 22:24:27 +0000 Subject: [PATCH 11/36] tbd: save outbox with external issue linking beads Epic tbd-68cw with 12 implementation beads covering schema changes, URL parser, inheritable fields, create/update refactor, show/list, golden tests, doctor check, sync scope, sync implementation, label sync, and documentation updates. All with blocker dependencies. https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- .../issues/is-01kh4t9cdp8esjk70khqvn2rmf.md | 27 +++++++++++++++++++ .../issues/is-01kh4t9x02rkd6pxhsbr69ts96.md | 27 +++++++++++++++++++ .../issues/is-01kh4ta023fe6awh57kp8z5afb.md | 21 +++++++++++++++ .../issues/is-01kh4ta2yejbm48vhwp5k9vph3.md | 19 +++++++++++++ .../issues/is-01kh4tab2habe6r6cspptrkavp.md | 17 ++++++++++++ .../issues/is-01kh4tady1vnmfb8tvnhv93xmz.md | 17 ++++++++++++ .../issues/is-01kh4tagqa9as4s7s231t9c3h3.md | 17 ++++++++++++ .../issues/is-01kh4tarbsf7k6jwr4pt5n79ks.md | 19 +++++++++++++ .../issues/is-01kh4tb83jp2d4fdt3dhy89t3d.md | 15 +++++++++++ .../issues/is-01kh4tbgjhyw1qfphmqt77tx05.md | 17 ++++++++++++ .../issues/is-01kh4tbkc8gtnkrkkd2y6fg0qt.md | 17 ++++++++++++ .../issues/is-01kh4tc1as6pksaz2gns64q9k3.md | 15 +++++++++++ .../issues/is-01kh4tc4n2eb3cwq6ym5tm041n.md | 15 +++++++++++ .tbd/workspaces/outbox/mappings/ids.yml | 13 +++++++++ 14 files changed, 256 insertions(+) create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4t9cdp8esjk70khqvn2rmf.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4t9x02rkd6pxhsbr69ts96.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4ta023fe6awh57kp8z5afb.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4ta2yejbm48vhwp5k9vph3.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4tab2habe6r6cspptrkavp.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4tady1vnmfb8tvnhv93xmz.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4tagqa9as4s7s231t9c3h3.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4tarbsf7k6jwr4pt5n79ks.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4tb83jp2d4fdt3dhy89t3d.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4tbgjhyw1qfphmqt77tx05.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4tbkc8gtnkrkkd2y6fg0qt.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4tc1as6pksaz2gns64q9k3.md create mode 100644 .tbd/workspaces/outbox/issues/is-01kh4tc4n2eb3cwq6ym5tm041n.md create mode 100644 .tbd/workspaces/outbox/mappings/ids.yml diff --git a/.tbd/workspaces/outbox/issues/is-01kh4t9cdp8esjk70khqvn2rmf.md b/.tbd/workspaces/outbox/issues/is-01kh4t9cdp8esjk70khqvn2rmf.md new file mode 100644 index 00000000..3e5970f3 --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4t9cdp8esjk70khqvn2rmf.md @@ -0,0 +1,27 @@ +--- +child_order_hints: + - is-01kh4t9x02rkd6pxhsbr69ts96 + - is-01kh4ta023fe6awh57kp8z5afb + - is-01kh4ta2yejbm48vhwp5k9vph3 + - is-01kh4tab2habe6r6cspptrkavp + - is-01kh4tady1vnmfb8tvnhv93xmz + - is-01kh4tagqa9as4s7s231t9c3h3 + - is-01kh4tarbsf7k6jwr4pt5n79ks + - is-01kh4tb83jp2d4fdt3dhy89t3d + - is-01kh4tbgjhyw1qfphmqt77tx05 + - is-01kh4tbkc8gtnkrkkd2y6fg0qt + - is-01kh4tc1as6pksaz2gns64q9k3 + - is-01kh4tc4n2eb3cwq6ym5tm041n +created_at: 2026-02-10T22:20:56.118Z +dependencies: [] +id: is-01kh4t9cdp8esjk70khqvn2rmf +kind: epic +labels: [] +priority: 1 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Epic: External issue linking" +type: is +updated_at: 2026-02-10T22:22:26.465Z +version: 13 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4t9x02rkd6pxhsbr69ts96.md b/.tbd/workspaces/outbox/issues/is-01kh4t9x02rkd6pxhsbr69ts96.md new file mode 100644 index 00000000..4559d688 --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4t9x02rkd6pxhsbr69ts96.md @@ -0,0 +1,27 @@ +--- +created_at: 2026-02-10T22:21:13.090Z +dependencies: + - target: is-01kh4ta023fe6awh57kp8z5afb + type: blocks + - target: is-01kh4ta2yejbm48vhwp5k9vph3 + type: blocks + - target: is-01kh4tab2habe6r6cspptrkavp + type: blocks + - target: is-01kh4tady1vnmfb8tvnhv93xmz + type: blocks + - target: is-01kh4tagqa9as4s7s231t9c3h3 + type: blocks + - target: is-01kh4tbgjhyw1qfphmqt77tx05 + type: blocks +id: is-01kh4t9x02rkd6pxhsbr69ts96 +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 1 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 1a: Schema and merge rules — add external_issue_url field to IssueSchema (schemas.ts:149), merge rule in git.ts:300, type updates in types.ts" +type: is +updated_at: 2026-02-10T22:23:21.791Z +version: 7 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4ta023fe6awh57kp8z5afb.md b/.tbd/workspaces/outbox/issues/is-01kh4ta023fe6awh57kp8z5afb.md new file mode 100644 index 00000000..91361e96 --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4ta023fe6awh57kp8z5afb.md @@ -0,0 +1,21 @@ +--- +created_at: 2026-02-10T22:21:16.227Z +dependencies: + - target: is-01kh4tab2habe6r6cspptrkavp + type: blocks + - target: is-01kh4tady1vnmfb8tvnhv93xmz + type: blocks + - target: is-01kh4tbkc8gtnkrkkd2y6fg0qt + type: blocks +id: is-01kh4ta023fe6awh57kp8z5afb +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 1 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 1b: GitHub issue URL parser — new github-issues.ts alongside github-fetch.ts with parseGitHubIssueUrl, isGitHubIssueUrl, isGitHubPrUrl, validateGitHubIssue, gh api operations" +type: is +updated_at: 2026-02-10T22:23:22.628Z +version: 4 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4ta2yejbm48vhwp5k9vph3.md b/.tbd/workspaces/outbox/issues/is-01kh4ta2yejbm48vhwp5k9vph3.md new file mode 100644 index 00000000..0da643e0 --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4ta2yejbm48vhwp5k9vph3.md @@ -0,0 +1,19 @@ +--- +created_at: 2026-02-10T22:21:19.181Z +dependencies: + - target: is-01kh4tab2habe6r6cspptrkavp + type: blocks + - target: is-01kh4tady1vnmfb8tvnhv93xmz + type: blocks +id: is-01kh4ta2yejbm48vhwp5k9vph3 +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 1 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 1c: Generic inheritable field system — new inheritable-fields.ts with INHERITABLE_FIELDS registry, inheritFromParent(), propagateToChildren()" +type: is +updated_at: 2026-02-10T22:22:59.872Z +version: 3 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4tab2habe6r6cspptrkavp.md b/.tbd/workspaces/outbox/issues/is-01kh4tab2habe6r6cspptrkavp.md new file mode 100644 index 00000000..a6109956 --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4tab2habe6r6cspptrkavp.md @@ -0,0 +1,17 @@ +--- +created_at: 2026-02-10T22:21:27.504Z +dependencies: + - target: is-01kh4tarbsf7k6jwr4pt5n79ks + type: blocks +id: is-01kh4tab2habe6r6cspptrkavp +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 1 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 1d: Refactor create.ts to use inheritable fields — replace inline spec_path logic (lines 113-119) with inheritFromParent(), add --external-issue flag with use_gh_cli gating" +type: is +updated_at: 2026-02-10T22:23:08.216Z +version: 2 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4tady1vnmfb8tvnhv93xmz.md b/.tbd/workspaces/outbox/issues/is-01kh4tady1vnmfb8tvnhv93xmz.md new file mode 100644 index 00000000..27281abc --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4tady1vnmfb8tvnhv93xmz.md @@ -0,0 +1,17 @@ +--- +created_at: 2026-02-10T22:21:30.432Z +dependencies: + - target: is-01kh4tarbsf7k6jwr4pt5n79ks + type: blocks +id: is-01kh4tady1vnmfb8tvnhv93xmz +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 1 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 1e: Refactor update.ts to use inheritable fields — replace inline spec_path logic (lines 94-104, 151-164) with inheritFromParent/propagateToChildren, add --external-issue flag" +type: is +updated_at: 2026-02-10T22:23:08.637Z +version: 2 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4tagqa9as4s7s231t9c3h3.md b/.tbd/workspaces/outbox/issues/is-01kh4tagqa9as4s7s231t9c3h3.md new file mode 100644 index 00000000..edb6e1f9 --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4tagqa9as4s7s231t9c3h3.md @@ -0,0 +1,17 @@ +--- +created_at: 2026-02-10T22:21:33.289Z +dependencies: + - target: is-01kh4tarbsf7k6jwr4pt5n79ks + type: blocks +id: is-01kh4tagqa9as4s7s231t9c3h3 +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 1 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 1f-g: Update show.ts display (add external_issue_url highlighting after line 70) and list.ts filter (add --external-issue filter after spec filter lines 247-251)" +type: is +updated_at: 2026-02-10T22:23:09.058Z +version: 2 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4tarbsf7k6jwr4pt5n79ks.md b/.tbd/workspaces/outbox/issues/is-01kh4tarbsf7k6jwr4pt5n79ks.md new file mode 100644 index 00000000..b53e0ecb --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4tarbsf7k6jwr4pt5n79ks.md @@ -0,0 +1,19 @@ +--- +created_at: 2026-02-10T22:21:41.113Z +dependencies: + - target: is-01kh4tb83jp2d4fdt3dhy89t3d + type: blocks + - target: is-01kh4tc4n2eb3cwq6ym5tm041n + type: blocks +id: is-01kh4tarbsf7k6jwr4pt5n79ks +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 1 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 1h-i: Golden tests and verification — cli-external-issue-linking.tryscript.md, cli-inheritable-fields.tryscript.md (5 inheritance scenarios), verify existing spec_path tests pass" +type: is +updated_at: 2026-02-10T22:23:23.458Z +version: 3 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4tb83jp2d4fdt3dhy89t3d.md b/.tbd/workspaces/outbox/issues/is-01kh4tb83jp2d4fdt3dhy89t3d.md new file mode 100644 index 00000000..8d6a233c --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4tb83jp2d4fdt3dhy89t3d.md @@ -0,0 +1,15 @@ +--- +created_at: 2026-02-10T22:21:57.233Z +dependencies: [] +id: is-01kh4tb83jp2d4fdt3dhy89t3d +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 2 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 2: gh CLI health check in doctor — add checkGhCli() integration check (doctor.ts:136-142), respect use_gh_cli gate, check gh --version and gh auth status" +type: is +updated_at: 2026-02-10T22:21:57.233Z +version: 1 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4tbgjhyw1qfphmqt77tx05.md b/.tbd/workspaces/outbox/issues/is-01kh4tbgjhyw1qfphmqt77tx05.md new file mode 100644 index 00000000..1c389dde --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4tbgjhyw1qfphmqt77tx05.md @@ -0,0 +1,17 @@ +--- +created_at: 2026-02-10T22:22:05.905Z +dependencies: + - target: is-01kh4tbkc8gtnkrkkd2y6fg0qt + type: blocks +id: is-01kh4tbgjhyw1qfphmqt77tx05 +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 2 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 3a: Sync scope changes — add --external flag to sync.ts SyncOptions (line 58-65), extend scope selection logic (lines 89-103), reorder to 4-phase sync with use_gh_cli gating" +type: is +updated_at: 2026-02-10T22:23:22.214Z +version: 2 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4tbkc8gtnkrkkd2y6fg0qt.md b/.tbd/workspaces/outbox/issues/is-01kh4tbkc8gtnkrkkd2y6fg0qt.md new file mode 100644 index 00000000..494f62ea --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4tbkc8gtnkrkkd2y6fg0qt.md @@ -0,0 +1,17 @@ +--- +created_at: 2026-02-10T22:22:08.775Z +dependencies: + - target: is-01kh4tc1as6pksaz2gns64q9k3 + type: blocks +id: is-01kh4tbkc8gtnkrkkd2y6fg0qt +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 2 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 3b: External sync implementation — syncExternalPull/Push methods in sync.ts, status mapping tables in github-issues.ts (TBD_TO_GITHUB_STATUS, githubToTbdStatus), summary output" +type: is +updated_at: 2026-02-10T22:23:23.035Z +version: 2 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4tc1as6pksaz2gns64q9k3.md b/.tbd/workspaces/outbox/issues/is-01kh4tc1as6pksaz2gns64q9k3.md new file mode 100644 index 00000000..3ea917f0 --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4tc1as6pksaz2gns64q9k3.md @@ -0,0 +1,15 @@ +--- +created_at: 2026-02-10T22:22:23.064Z +dependencies: [] +id: is-01kh4tc1as6pksaz2gns64q9k3 +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 3 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: "Phase 4: Bidirectional label sync — addGitHubLabel (two-step POST), removeGitHubLabel, computeLabelDiff in github-issues.ts; extend syncExternalPull/Push with label sync; union semantics" +type: is +updated_at: 2026-02-10T22:22:23.064Z +version: 1 +--- diff --git a/.tbd/workspaces/outbox/issues/is-01kh4tc4n2eb3cwq6ym5tm041n.md b/.tbd/workspaces/outbox/issues/is-01kh4tc4n2eb3cwq6ym5tm041n.md new file mode 100644 index 00000000..7dc74e0d --- /dev/null +++ b/.tbd/workspaces/outbox/issues/is-01kh4tc4n2eb3cwq6ym5tm041n.md @@ -0,0 +1,15 @@ +--- +created_at: 2026-02-10T22:22:26.465Z +dependencies: [] +id: is-01kh4tc4n2eb3cwq6ym5tm041n +kind: task +labels: [] +parent_id: is-01kh4t9cdp8esjk70khqvn2rmf +priority: 2 +spec_path: docs/project/specs/active/plan-2026-02-10-external-issue-linking.md +status: open +title: Documentation updates — design doc (§4.4, §4.7), tbd-docs.md (create/update/list/show/sync), tbd-prime.md, README commands, shortcuts (plan-with-beads, implement-beads, agent-handoff, setup-github-cli), CLI --help text, error messages +type: is +updated_at: 2026-02-10T22:22:26.465Z +version: 1 +--- diff --git a/.tbd/workspaces/outbox/mappings/ids.yml b/.tbd/workspaces/outbox/mappings/ids.yml new file mode 100644 index 00000000..4a89044c --- /dev/null +++ b/.tbd/workspaces/outbox/mappings/ids.yml @@ -0,0 +1,13 @@ +0gg1: 01kh4tab2habe6r6cspptrkavp +1dhp: 01kh4tc1as6pksaz2gns64q9k3 +68cw: 01kh4t9cdp8esjk70khqvn2rmf +84ol: 01kh4tady1vnmfb8tvnhv93xmz +8exl: 01kh4tarbsf7k6jwr4pt5n79ks +gv9a: 01kh4ta2yejbm48vhwp5k9vph3 +j2ln: 01kh4tagqa9as4s7s231t9c3h3 +no3o: 01kh4tb83jp2d4fdt3dhy89t3d +srzb: 01kh4t9x02rkd6pxhsbr69ts96 +y8hq: 01kh4tbgjhyw1qfphmqt77tx05 +yf3f: 01kh4tbkc8gtnkrkkd2y6fg0qt +z6u1: 01kh4ta023fe6awh57kp8z5afb +zh1p: 01kh4tc4n2eb3cwq6ym5tm041n From 0f9396a13fcb5edb7f4d1dcd874461e08d367b38 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 22:31:33 +0000 Subject: [PATCH 12/36] feat: add external_issue_url field to schema, merge rules, and types (Phase 1a) Add the external_issue_url field to IssueSchema (nullable, optional, URL-validated), add LWW merge rule in git.ts, and add to CreateIssueOptions/UpdateIssueOptions types. Part of external issue linking feature (tbd-srzb). https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- packages/tbd/src/file/git.ts | 1 + packages/tbd/src/lib/schemas.ts | 3 +++ packages/tbd/src/lib/types.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/packages/tbd/src/file/git.ts b/packages/tbd/src/file/git.ts index 867f9ca2..ee7bdabc 100644 --- a/packages/tbd/src/file/git.ts +++ b/packages/tbd/src/file/git.ts @@ -298,6 +298,7 @@ const FIELD_STRATEGIES: Record = { due_date: 'lww', deferred_until: 'lww', spec_path: 'lww', + external_issue_url: 'lww', // Union - combine arrays, deduplicate labels: 'union', diff --git a/packages/tbd/src/lib/schemas.ts b/packages/tbd/src/lib/schemas.ts index d793a5a0..833b85c5 100644 --- a/packages/tbd/src/lib/schemas.ts +++ b/packages/tbd/src/lib/schemas.ts @@ -148,6 +148,9 @@ export const IssueSchema = BaseEntity.extend({ // Spec linking - path to related spec/doc (relative to repo root) spec_path: z.string().nullable().optional(), + + // External issue linking - URL to linked external issue (e.g., GitHub Issues) + external_issue_url: z.string().url().nullable().optional(), }); // ============================================================================= diff --git a/packages/tbd/src/lib/types.ts b/packages/tbd/src/lib/types.ts index cacda337..18b771a7 100644 --- a/packages/tbd/src/lib/types.ts +++ b/packages/tbd/src/lib/types.ts @@ -88,6 +88,7 @@ export interface CreateIssueOptions { parent_id?: string; due_date?: string; deferred_until?: string; + external_issue_url?: string | null; } /** @@ -106,6 +107,7 @@ export interface UpdateIssueOptions { parent_id?: string | null; due_date?: string | null; deferred_until?: string | null; + external_issue_url?: string | null; } /** From 5ccbe6c7ea3d231609af3d1c3acf60c7aa3ba02a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 22:37:42 +0000 Subject: [PATCH 13/36] feat: add GitHub issue URL parser and generic inheritable field system (Phase 1b+1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add github-issues.ts with URL parsing, status mapping, and label diff helpers. Add inheritable-fields.ts with registry-based parent→child field inheritance and propagation for both spec_path and external_issue_url. 64 new tests covering URL parsing, status mapping, label diffs, and inheritance/propagation logic. Part of external issue linking feature (tbd-z6u1, tbd-gv9a). https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- packages/tbd/src/file/github-issues.ts | 270 +++++++++++++ packages/tbd/src/lib/inheritable-fields.ts | 139 +++++++ packages/tbd/tests/github-issues.test.ts | 281 ++++++++++++++ packages/tbd/tests/inheritable-fields.test.ts | 365 ++++++++++++++++++ 4 files changed, 1055 insertions(+) create mode 100644 packages/tbd/src/file/github-issues.ts create mode 100644 packages/tbd/src/lib/inheritable-fields.ts create mode 100644 packages/tbd/tests/github-issues.test.ts create mode 100644 packages/tbd/tests/inheritable-fields.test.ts diff --git a/packages/tbd/src/file/github-issues.ts b/packages/tbd/src/file/github-issues.ts new file mode 100644 index 00000000..af68636d --- /dev/null +++ b/packages/tbd/src/file/github-issues.ts @@ -0,0 +1,270 @@ +/** + * GitHub Issues URL parsing, validation, and API operations. + * + * Provides functions to parse GitHub issue URLs, validate them against the + * GitHub API via `gh` CLI, and perform status/label operations. + * + * All GitHub API operations use `gh api` via child process, leveraging the + * existing `gh` CLI that `ensure-gh-cli.sh` installs. + * + * See: plan-2026-02-10-external-issue-linking.md §1b + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +// ============================================================================= +// URL Parsing +// ============================================================================= + +/** + * Matches GitHub issue URLs: https://github.com/{owner}/{repo}/issues/{number} + */ +const GITHUB_ISSUE_RE = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)$/; + +/** + * Matches GitHub PR URLs: https://github.com/{owner}/{repo}/pull/{number} + * Used for better error messages when users provide PR URLs. + */ +const GITHUB_PR_RE = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)$/; + +/** + * A parsed GitHub issue reference. + */ +export interface GitHubIssueRef { + owner: string; + repo: string; + number: number; + url: string; +} + +/** + * Parse a GitHub issue URL into its components. + * + * @returns The parsed reference, or null if the URL doesn't match + */ +export function parseGitHubIssueUrl(url: string): GitHubIssueRef | null { + const match = GITHUB_ISSUE_RE.exec(url); + if (!match) return null; + return { + owner: match[1]!, + repo: match[2]!, + number: parseInt(match[3]!, 10), + url, + }; +} + +/** + * Check if a URL is a GitHub issue URL. + */ +export function isGitHubIssueUrl(url: string): boolean { + return GITHUB_ISSUE_RE.test(url); +} + +/** + * Check if a URL is a GitHub PR URL. + */ +export function isGitHubPrUrl(url: string): boolean { + return GITHUB_PR_RE.test(url); +} + +/** + * Format a GitHubIssueRef as a short string: "owner/repo#number" + */ +export function formatGitHubIssueRef(ref: GitHubIssueRef): string { + return `${ref.owner}/${ref.repo}#${ref.number}`; +} + +// ============================================================================= +// GitHub API Operations +// ============================================================================= + +/** + * Validate that a GitHub issue exists and is accessible. + * + * @returns true if the issue exists, false otherwise + */ +export async function validateGitHubIssue(ref: GitHubIssueRef): Promise { + try { + await execFileAsync('gh', [ + 'api', + `/repos/${ref.owner}/${ref.repo}/issues/${ref.number}`, + '--jq', + '.number', + ]); + return true; + } catch { + return false; + } +} + +/** + * Get the current state of a GitHub issue. + */ +export async function getGitHubIssueState( + ref: GitHubIssueRef, +): Promise<{ state: string; state_reason: string | null; labels: string[] }> { + const { stdout } = await execFileAsync('gh', [ + 'api', + `/repos/${ref.owner}/${ref.repo}/issues/${ref.number}`, + '--jq', + '{state: .state, state_reason: .state_reason, labels: [.labels[].name]}', + ]); + const data = JSON.parse(stdout) as { + state: string; + state_reason: string | null; + labels: string[]; + }; + return data; +} + +/** + * Close a GitHub issue with a specific reason. + */ +export async function closeGitHubIssue( + ref: GitHubIssueRef, + reason: 'completed' | 'not_planned', +): Promise { + await execFileAsync('gh', [ + 'api', + '--method', + 'PATCH', + `/repos/${ref.owner}/${ref.repo}/issues/${ref.number}`, + '-f', + 'state=closed', + '-f', + `state_reason=${reason}`, + ]); +} + +/** + * Reopen a GitHub issue. + */ +export async function reopenGitHubIssue(ref: GitHubIssueRef): Promise { + await execFileAsync('gh', [ + 'api', + '--method', + 'PATCH', + `/repos/${ref.owner}/${ref.repo}/issues/${ref.number}`, + '-f', + 'state=open', + ]); +} + +/** + * Add a label to a GitHub issue. Creates the label on the repo if needed. + */ +export async function addGitHubLabel(ref: GitHubIssueRef, label: string): Promise { + // Step 1: Ensure the label exists on the repo (ignore 422 = already exists) + try { + await execFileAsync('gh', [ + 'api', + '--method', + 'POST', + `/repos/${ref.owner}/${ref.repo}/labels`, + '-f', + `name=${label}`, + '-f', + 'color=ededed', + ]); + } catch { + // 422 = label already exists, which is fine + } + + // Step 2: Add the label to the issue + await execFileAsync('gh', [ + 'api', + '--method', + 'POST', + `/repos/${ref.owner}/${ref.repo}/issues/${ref.number}/labels`, + '--input', + '-', + ]); +} + +/** + * Remove a label from a GitHub issue. + */ +export async function removeGitHubLabel(ref: GitHubIssueRef, label: string): Promise { + try { + await execFileAsync('gh', [ + 'api', + '--method', + 'DELETE', + `/repos/${ref.owner}/${ref.repo}/issues/${ref.number}/labels/${encodeURIComponent(label)}`, + ]); + } catch { + // Label not on issue — ignore + } +} + +// ============================================================================= +// Status Mapping +// ============================================================================= + +/** + * tbd → GitHub status mapping. + * null means "no change" (e.g., `blocked` has no GitHub equivalent). + */ +export const TBD_TO_GITHUB_STATUS: Record< + string, + { state: 'open' | 'closed'; state_reason?: 'completed' | 'not_planned' } | null +> = { + open: { state: 'open' }, + in_progress: { state: 'open' }, + blocked: null, + deferred: { state: 'closed', state_reason: 'not_planned' }, + closed: { state: 'closed', state_reason: 'completed' }, +}; + +/** + * Map a GitHub issue state to a tbd status. + * + * @param state - GitHub state ('open' or 'closed') + * @param stateReason - GitHub state_reason (null, 'completed', 'not_planned', 'duplicate', 'reopened') + * @param currentTbdStatus - The bead's current tbd status + * @returns The new tbd status, or null if no change is needed + */ +export function githubToTbdStatus( + state: string, + stateReason: string | null, + currentTbdStatus: string, +): string | null { + if (state === 'open') { + // Only reopen if bead is closed or deferred + if (currentTbdStatus === 'closed' || currentTbdStatus === 'deferred') { + return 'open'; + } + return null; // in_progress, blocked stay as-is + } + + if (state === 'closed') { + if (stateReason === 'not_planned') { + if (currentTbdStatus !== 'deferred') return 'deferred'; + return null; + } + // completed, duplicate, or null reason → closed + if (currentTbdStatus !== 'closed') return 'closed'; + return null; + } + + return null; +} + +/** + * Compute the label diff between local and remote label sets. + */ +export function computeLabelDiff( + localLabels: string[], + remoteLabels: string[], +): { toAdd: string[]; toRemove: string[] } { + const localSet = new Set(localLabels); + const remoteSet = new Set(remoteLabels); + + const toAdd = localLabels.filter((l) => !remoteSet.has(l)); + const toRemove = remoteLabels.filter((l) => !localSet.has(l)); + + return { toAdd, toRemove }; +} diff --git a/packages/tbd/src/lib/inheritable-fields.ts b/packages/tbd/src/lib/inheritable-fields.ts new file mode 100644 index 00000000..5321b84e --- /dev/null +++ b/packages/tbd/src/lib/inheritable-fields.ts @@ -0,0 +1,139 @@ +/** + * Generic parent→child field inheritance and propagation. + * + * All inheritable fields follow the same three rules: + * + * 1. On create with --parent: if the field is not explicitly set, inherit + * from parent. + * 2. On re-parenting: if the field is not explicitly set and the child has + * no value, inherit from new parent. + * 3. On parent update: if the field changes on the parent, propagate to + * children whose field is null/undefined or matches the old value. + * + * Adding a new inheritable field means adding one entry to INHERITABLE_FIELDS. + * No other code changes are needed for inheritance behavior. + * + * See: plan-2026-02-10-external-issue-linking.md §1c + */ + +import type { Issue } from './types.js'; + +// ============================================================================= +// Registry +// ============================================================================= + +/** + * Configuration for a field that inherits from parent to child beads. + */ +interface InheritableFieldConfig { + /** The field name on the Issue type */ + field: keyof Issue; +} + +/** + * Registry of all inheritable fields. + * To add a new inheritable field, add one entry here. + */ +export const INHERITABLE_FIELDS: InheritableFieldConfig[] = [ + { field: 'spec_path' }, + { field: 'external_issue_url' }, +]; + +// ============================================================================= +// Inheritance +// ============================================================================= + +/** + * Inherit fields from a parent issue to a child issue being created. + * + * For each inheritable field: if the child has no value and the field + * was not explicitly set by the user, copy from parent. + * + * @param child - The child issue data (mutated in place) + * @param parent - The parent issue to inherit from + * @param explicitlySet - Set of field names that the user explicitly provided + */ +export function inheritFromParent( + child: Partial, + parent: Issue, + explicitlySet: Set, +): void { + for (const config of INHERITABLE_FIELDS) { + const { field } = config; + // Only inherit if: user didn't explicitly set it AND child has no value + if (!explicitlySet.has(field) && !child[field]) { + const parentValue = parent[field]; + if (parentValue != null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (child as any)[field] = parentValue; + } + } + } +} + +/** + * Propagate field changes from a parent to its children. + * + * For each inheritable field that changed on the parent: + * update children whose value is null/undefined or matches the old value. + * + * @param parent - The parent issue (with updated values) + * @param oldValues - The parent's old values for inheritable fields + * @param children - All direct children of the parent + * @param writeIssueFn - Function to persist a modified child + * @param timestamp - The timestamp for updated_at + * @returns The number of children that were updated + */ +export async function propagateToChildren( + parent: Issue, + oldValues: Partial>, + children: Issue[], + writeIssueFn: (issue: Issue) => Promise, + timestamp: string, +): Promise { + let updatedCount = 0; + + for (const child of children) { + let childModified = false; + + for (const config of INHERITABLE_FIELDS) { + const { field } = config; + const newValue = parent[field]; + const oldValue = oldValues[field]; + + // Only propagate if the parent's field actually changed + if (newValue === oldValue) continue; + // Only propagate if there's a new value to set + if (newValue == null) continue; + + const childValue = child[field]; + // Propagate if child has no value or had the old inherited value + if (!childValue || childValue === oldValue) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (child as any)[field] = newValue; + childModified = true; + } + } + + if (childModified) { + child.version += 1; + child.updated_at = timestamp; + await writeIssueFn(child); + updatedCount++; + } + } + + return updatedCount; +} + +/** + * Capture the current values of all inheritable fields from an issue. + * Used before applying updates, so propagation can compare old vs new. + */ +export function captureInheritableValues(issue: Issue): Partial> { + const values: Partial> = {}; + for (const config of INHERITABLE_FIELDS) { + values[config.field] = issue[config.field]; + } + return values; +} diff --git a/packages/tbd/tests/github-issues.test.ts b/packages/tbd/tests/github-issues.test.ts new file mode 100644 index 00000000..6bce6aa6 --- /dev/null +++ b/packages/tbd/tests/github-issues.test.ts @@ -0,0 +1,281 @@ +/** + * Tests for github-issues.ts - GitHub issue URL parsing, validation, and status mapping. + * + * URL parsing tests run without network access. + * API operation tests mock child_process. + */ + +import { describe, it, expect } from 'vitest'; + +import { + parseGitHubIssueUrl, + isGitHubIssueUrl, + isGitHubPrUrl, + formatGitHubIssueRef, + githubToTbdStatus, + computeLabelDiff, + TBD_TO_GITHUB_STATUS, +} from '../src/file/github-issues.js'; + +// ============================================================================= +// parseGitHubIssueUrl +// ============================================================================= + +describe('parseGitHubIssueUrl', () => { + it('parses valid HTTPS issue URL', () => { + const result = parseGitHubIssueUrl('https://github.com/owner/repo/issues/123'); + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + number: 123, + url: 'https://github.com/owner/repo/issues/123', + }); + }); + + it('parses valid HTTP issue URL', () => { + const result = parseGitHubIssueUrl('http://github.com/owner/repo/issues/456'); + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + number: 456, + url: 'http://github.com/owner/repo/issues/456', + }); + }); + + it('extracts correct owner/repo/number from complex names', () => { + const result = parseGitHubIssueUrl('https://github.com/my-org/my-repo.js/issues/9999'); + expect(result).toEqual({ + owner: 'my-org', + repo: 'my-repo.js', + number: 9999, + url: 'https://github.com/my-org/my-repo.js/issues/9999', + }); + }); + + it('rejects URL with trailing slash', () => { + expect(parseGitHubIssueUrl('https://github.com/owner/repo/issues/123/')).toBeNull(); + }); + + it('rejects URL with query params', () => { + expect(parseGitHubIssueUrl('https://github.com/owner/repo/issues/123?foo=bar')).toBeNull(); + }); + + it('rejects GitHub PR URL', () => { + expect(parseGitHubIssueUrl('https://github.com/owner/repo/pull/123')).toBeNull(); + }); + + it('rejects GitHub repo URL (no issue number)', () => { + expect(parseGitHubIssueUrl('https://github.com/owner/repo')).toBeNull(); + }); + + it('rejects GitHub blob URL', () => { + expect(parseGitHubIssueUrl('https://github.com/owner/repo/blob/main/file.ts')).toBeNull(); + }); + + it('rejects non-GitHub URL', () => { + expect(parseGitHubIssueUrl('https://jira.example.com/PROJ-123')).toBeNull(); + }); + + it('rejects malformed input', () => { + expect(parseGitHubIssueUrl('not-a-url')).toBeNull(); + }); + + it('rejects URL with no issue number', () => { + expect(parseGitHubIssueUrl('https://github.com/owner/repo/issues/')).toBeNull(); + }); + + it('rejects URL with non-numeric issue number', () => { + expect(parseGitHubIssueUrl('https://github.com/owner/repo/issues/abc')).toBeNull(); + }); + + it('rejects issues path with extra segments', () => { + expect(parseGitHubIssueUrl('https://github.com/owner/repo/issues/123/comments')).toBeNull(); + }); +}); + +// ============================================================================= +// isGitHubIssueUrl +// ============================================================================= + +describe('isGitHubIssueUrl', () => { + it('returns true for valid issue URL', () => { + expect(isGitHubIssueUrl('https://github.com/owner/repo/issues/123')).toBe(true); + }); + + it('returns false for PR URL', () => { + expect(isGitHubIssueUrl('https://github.com/owner/repo/pull/123')).toBe(false); + }); + + it('returns false for repo URL', () => { + expect(isGitHubIssueUrl('https://github.com/owner/repo')).toBe(false); + }); +}); + +// ============================================================================= +// isGitHubPrUrl +// ============================================================================= + +describe('isGitHubPrUrl', () => { + it('returns true for PR URL', () => { + expect(isGitHubPrUrl('https://github.com/owner/repo/pull/123')).toBe(true); + }); + + it('returns false for issue URL', () => { + expect(isGitHubPrUrl('https://github.com/owner/repo/issues/123')).toBe(false); + }); + + it('returns false for non-GitHub URL', () => { + expect(isGitHubPrUrl('https://example.com/pull/123')).toBe(false); + }); +}); + +// ============================================================================= +// formatGitHubIssueRef +// ============================================================================= + +describe('formatGitHubIssueRef', () => { + it('formats as owner/repo#number', () => { + expect( + formatGitHubIssueRef({ + owner: 'jlevy', + repo: 'tbd', + number: 83, + url: 'https://github.com/jlevy/tbd/issues/83', + }), + ).toBe('jlevy/tbd#83'); + }); +}); + +// ============================================================================= +// TBD_TO_GITHUB_STATUS mapping +// ============================================================================= + +describe('TBD_TO_GITHUB_STATUS', () => { + it('maps open to GitHub open', () => { + expect(TBD_TO_GITHUB_STATUS.open).toEqual({ state: 'open' }); + }); + + it('maps in_progress to GitHub open', () => { + expect(TBD_TO_GITHUB_STATUS.in_progress).toEqual({ state: 'open' }); + }); + + it('maps blocked to null (no change)', () => { + expect(TBD_TO_GITHUB_STATUS.blocked).toBeNull(); + }); + + it('maps deferred to GitHub closed/not_planned', () => { + expect(TBD_TO_GITHUB_STATUS.deferred).toEqual({ + state: 'closed', + state_reason: 'not_planned', + }); + }); + + it('maps closed to GitHub closed/completed', () => { + expect(TBD_TO_GITHUB_STATUS.closed).toEqual({ + state: 'closed', + state_reason: 'completed', + }); + }); +}); + +// ============================================================================= +// githubToTbdStatus +// ============================================================================= + +describe('githubToTbdStatus', () => { + describe('GitHub open → tbd', () => { + it('reopens closed bead', () => { + expect(githubToTbdStatus('open', null, 'closed')).toBe('open'); + }); + + it('reopens deferred bead', () => { + expect(githubToTbdStatus('open', null, 'deferred')).toBe('open'); + }); + + it('does not change open bead', () => { + expect(githubToTbdStatus('open', null, 'open')).toBeNull(); + }); + + it('does not change in_progress bead', () => { + expect(githubToTbdStatus('open', null, 'in_progress')).toBeNull(); + }); + + it('does not change blocked bead', () => { + expect(githubToTbdStatus('open', null, 'blocked')).toBeNull(); + }); + + it('handles reopened reason same as null', () => { + expect(githubToTbdStatus('open', 'reopened', 'closed')).toBe('open'); + }); + }); + + describe('GitHub closed → tbd', () => { + it('closes open bead on completed', () => { + expect(githubToTbdStatus('closed', 'completed', 'open')).toBe('closed'); + }); + + it('closes in_progress bead on completed', () => { + expect(githubToTbdStatus('closed', 'completed', 'in_progress')).toBe('closed'); + }); + + it('closes blocked bead on completed', () => { + expect(githubToTbdStatus('closed', 'completed', 'blocked')).toBe('closed'); + }); + + it('does not change already closed bead', () => { + expect(githubToTbdStatus('closed', 'completed', 'closed')).toBeNull(); + }); + + it('defers open bead on not_planned', () => { + expect(githubToTbdStatus('closed', 'not_planned', 'open')).toBe('deferred'); + }); + + it('does not change already deferred bead', () => { + expect(githubToTbdStatus('closed', 'not_planned', 'deferred')).toBeNull(); + }); + + it('closes bead on duplicate reason', () => { + expect(githubToTbdStatus('closed', 'duplicate', 'open')).toBe('closed'); + }); + + it('closes bead when state_reason is null', () => { + expect(githubToTbdStatus('closed', null, 'open')).toBe('closed'); + }); + }); +}); + +// ============================================================================= +// computeLabelDiff +// ============================================================================= + +describe('computeLabelDiff', () => { + it('detects labels to add and remove', () => { + const result = computeLabelDiff(['bug', 'p1', 'new-label'], ['bug', 'p1', 'old-label']); + expect(result.toAdd).toEqual(['new-label']); + expect(result.toRemove).toEqual(['old-label']); + }); + + it('returns empty diffs when labels are identical', () => { + const result = computeLabelDiff(['bug', 'p1'], ['bug', 'p1']); + expect(result.toAdd).toEqual([]); + expect(result.toRemove).toEqual([]); + }); + + it('handles empty local labels', () => { + const result = computeLabelDiff([], ['bug', 'p1']); + expect(result.toAdd).toEqual([]); + expect(result.toRemove).toEqual(['bug', 'p1']); + }); + + it('handles empty remote labels', () => { + const result = computeLabelDiff(['bug', 'p1'], []); + expect(result.toAdd).toEqual(['bug', 'p1']); + expect(result.toRemove).toEqual([]); + }); + + it('handles both empty', () => { + const result = computeLabelDiff([], []); + expect(result.toAdd).toEqual([]); + expect(result.toRemove).toEqual([]); + }); +}); diff --git a/packages/tbd/tests/inheritable-fields.test.ts b/packages/tbd/tests/inheritable-fields.test.ts new file mode 100644 index 00000000..fcc4cc02 --- /dev/null +++ b/packages/tbd/tests/inheritable-fields.test.ts @@ -0,0 +1,365 @@ +/** + * Tests for inheritable-fields.ts - Generic parent→child field inheritance. + * + * Covers: + * - inheritFromParent() copies registered fields when not explicitly set + * - inheritFromParent() does NOT overwrite explicitly set fields + * - propagateToChildren() updates children with null or old-matching values + * - propagateToChildren() skips children with explicitly different values + * - captureInheritableValues() captures all registered fields + * - Both spec_path and external_issue_url are exercised + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { Issue } from '../src/lib/types.js'; +import { + inheritFromParent, + propagateToChildren, + captureInheritableValues, + INHERITABLE_FIELDS, +} from '../src/lib/inheritable-fields.js'; + +const makeIssue = (overrides: Partial = {}): Issue => ({ + type: 'is', + id: 'is-00000000000000000000000001', + version: 1, + kind: 'task', + title: 'Test issue', + status: 'open', + priority: 2, + labels: [], + dependencies: [], + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + ...overrides, +}); + +// ============================================================================= +// INHERITABLE_FIELDS registry +// ============================================================================= + +describe('INHERITABLE_FIELDS', () => { + it('includes spec_path', () => { + expect(INHERITABLE_FIELDS.some((f) => f.field === 'spec_path')).toBe(true); + }); + + it('includes external_issue_url', () => { + expect(INHERITABLE_FIELDS.some((f) => f.field === 'external_issue_url')).toBe(true); + }); +}); + +// ============================================================================= +// inheritFromParent +// ============================================================================= + +describe('inheritFromParent', () => { + it('copies spec_path from parent when not explicitly set', () => { + const parent = makeIssue({ spec_path: 'docs/spec-a.md' }); + const child: Partial = {}; + + inheritFromParent(child, parent, new Set()); + + expect(child.spec_path).toBe('docs/spec-a.md'); + }); + + it('copies external_issue_url from parent when not explicitly set', () => { + const parent = makeIssue({ + external_issue_url: 'https://github.com/owner/repo/issues/1', + }); + const child: Partial = {}; + + inheritFromParent(child, parent, new Set()); + + expect(child.external_issue_url).toBe('https://github.com/owner/repo/issues/1'); + }); + + it('copies both fields from parent when neither is explicitly set', () => { + const parent = makeIssue({ + spec_path: 'docs/spec-a.md', + external_issue_url: 'https://github.com/owner/repo/issues/1', + }); + const child: Partial = {}; + + inheritFromParent(child, parent, new Set()); + + expect(child.spec_path).toBe('docs/spec-a.md'); + expect(child.external_issue_url).toBe('https://github.com/owner/repo/issues/1'); + }); + + it('does NOT overwrite explicitly set spec_path', () => { + const parent = makeIssue({ spec_path: 'docs/parent-spec.md' }); + const child: Partial = { spec_path: 'docs/child-spec.md' }; + + inheritFromParent(child, parent, new Set(['spec_path'])); + + expect(child.spec_path).toBe('docs/child-spec.md'); + }); + + it('does NOT overwrite explicitly set external_issue_url', () => { + const parent = makeIssue({ + external_issue_url: 'https://github.com/owner/repo/issues/1', + }); + const child: Partial = { + external_issue_url: 'https://github.com/owner/repo/issues/2', + }; + + inheritFromParent(child, parent, new Set(['external_issue_url'])); + + expect(child.external_issue_url).toBe('https://github.com/owner/repo/issues/2'); + }); + + it('inherits one field while respecting explicit set on another', () => { + const parent = makeIssue({ + spec_path: 'docs/parent-spec.md', + external_issue_url: 'https://github.com/owner/repo/issues/1', + }); + const child: Partial = { spec_path: 'docs/child-spec.md' }; + + inheritFromParent(child, parent, new Set(['spec_path'])); + + expect(child.spec_path).toBe('docs/child-spec.md'); + expect(child.external_issue_url).toBe('https://github.com/owner/repo/issues/1'); + }); + + it('does not inherit when parent has no value', () => { + const parent = makeIssue({}); + const child: Partial = {}; + + inheritFromParent(child, parent, new Set()); + + expect(child.spec_path).toBeUndefined(); + expect(child.external_issue_url).toBeUndefined(); + }); +}); + +// ============================================================================= +// propagateToChildren +// ============================================================================= + +describe('propagateToChildren', () => { + it('propagates spec_path change to children with null value', async () => { + const parent = makeIssue({ + id: 'is-00000000000000000000000001', + spec_path: 'docs/new-spec.md', + }); + const child = makeIssue({ + id: 'is-00000000000000000000000002', + parent_id: parent.id, + spec_path: undefined, + }); + + const writeFn = vi.fn().mockResolvedValue(undefined); + const count = await propagateToChildren( + parent, + { spec_path: undefined }, + [child], + writeFn, + '2025-06-01T00:00:00Z', + ); + + expect(count).toBe(1); + expect(child.spec_path).toBe('docs/new-spec.md'); + expect(writeFn).toHaveBeenCalledOnce(); + }); + + it('propagates spec_path change to children with old value', async () => { + const parent = makeIssue({ spec_path: 'docs/new-spec.md' }); + const child = makeIssue({ spec_path: 'docs/old-spec.md' }); + + const writeFn = vi.fn().mockResolvedValue(undefined); + const count = await propagateToChildren( + parent, + { spec_path: 'docs/old-spec.md' }, + [child], + writeFn, + '2025-06-01T00:00:00Z', + ); + + expect(count).toBe(1); + expect(child.spec_path).toBe('docs/new-spec.md'); + }); + + it('skips children with different value from old', async () => { + const parent = makeIssue({ spec_path: 'docs/new-spec.md' }); + const child = makeIssue({ spec_path: 'docs/different-spec.md' }); + + const writeFn = vi.fn().mockResolvedValue(undefined); + const count = await propagateToChildren( + parent, + { spec_path: 'docs/old-spec.md' }, + [child], + writeFn, + '2025-06-01T00:00:00Z', + ); + + expect(count).toBe(0); + expect(child.spec_path).toBe('docs/different-spec.md'); + expect(writeFn).not.toHaveBeenCalled(); + }); + + it('propagates external_issue_url to children', async () => { + const parent = makeIssue({ + external_issue_url: 'https://github.com/owner/repo/issues/2', + }); + const child = makeIssue({ external_issue_url: undefined }); + + const writeFn = vi.fn().mockResolvedValue(undefined); + const count = await propagateToChildren( + parent, + { external_issue_url: undefined }, + [child], + writeFn, + '2025-06-01T00:00:00Z', + ); + + expect(count).toBe(1); + expect(child.external_issue_url).toBe('https://github.com/owner/repo/issues/2'); + }); + + it('propagates multiple fields in one pass', async () => { + const parent = makeIssue({ + spec_path: 'docs/new-spec.md', + external_issue_url: 'https://github.com/owner/repo/issues/2', + }); + const child = makeIssue({ + spec_path: undefined, + external_issue_url: undefined, + }); + + const writeFn = vi.fn().mockResolvedValue(undefined); + const count = await propagateToChildren( + parent, + { spec_path: undefined, external_issue_url: undefined }, + [child], + writeFn, + '2025-06-01T00:00:00Z', + ); + + expect(count).toBe(1); + expect(child.spec_path).toBe('docs/new-spec.md'); + expect(child.external_issue_url).toBe('https://github.com/owner/repo/issues/2'); + // Should only write once even though two fields changed + expect(writeFn).toHaveBeenCalledOnce(); + }); + + it('increments version and sets updated_at on modified children', async () => { + const parent = makeIssue({ spec_path: 'docs/new-spec.md' }); + const child = makeIssue({ version: 3, spec_path: undefined }); + + const writeFn = vi.fn().mockResolvedValue(undefined); + await propagateToChildren( + parent, + { spec_path: undefined }, + [child], + writeFn, + '2025-06-01T12:00:00Z', + ); + + expect(child.version).toBe(4); + expect(child.updated_at).toBe('2025-06-01T12:00:00Z'); + }); + + it('does not modify children when field did not change', async () => { + const parent = makeIssue({ spec_path: 'docs/same-spec.md' }); + const child = makeIssue({ spec_path: 'docs/same-spec.md' }); + + const writeFn = vi.fn().mockResolvedValue(undefined); + const count = await propagateToChildren( + parent, + { spec_path: 'docs/same-spec.md' }, + [child], + writeFn, + '2025-06-01T00:00:00Z', + ); + + expect(count).toBe(0); + expect(writeFn).not.toHaveBeenCalled(); + }); + + it('handles empty children array', async () => { + const parent = makeIssue({ spec_path: 'docs/new-spec.md' }); + + const writeFn = vi.fn().mockResolvedValue(undefined); + const count = await propagateToChildren( + parent, + { spec_path: undefined }, + [], + writeFn, + '2025-06-01T00:00:00Z', + ); + + expect(count).toBe(0); + expect(writeFn).not.toHaveBeenCalled(); + }); + + it('handles mixed children: some eligible, some not', async () => { + const parent = makeIssue({ + spec_path: 'docs/new-spec.md', + external_issue_url: 'https://github.com/owner/repo/issues/2', + }); + const child1 = makeIssue({ + id: 'is-00000000000000000000000010', + spec_path: 'docs/old-spec.md', + external_issue_url: undefined, + }); + const child2 = makeIssue({ + id: 'is-00000000000000000000000011', + spec_path: 'docs/different-spec.md', + external_issue_url: 'https://github.com/owner/repo/issues/99', + }); + const child3 = makeIssue({ + id: 'is-00000000000000000000000012', + spec_path: undefined, + external_issue_url: undefined, + }); + + const writeFn = vi.fn().mockResolvedValue(undefined); + const count = await propagateToChildren( + parent, + { spec_path: 'docs/old-spec.md', external_issue_url: undefined }, + [child1, child2, child3], + writeFn, + '2025-06-01T00:00:00Z', + ); + + // child1: spec_path matches old, external_issue_url is null → both updated + expect(child1.spec_path).toBe('docs/new-spec.md'); + expect(child1.external_issue_url).toBe('https://github.com/owner/repo/issues/2'); + + // child2: spec_path is different, external_issue_url is different → neither updated + expect(child2.spec_path).toBe('docs/different-spec.md'); + expect(child2.external_issue_url).toBe('https://github.com/owner/repo/issues/99'); + + // child3: both null → both updated + expect(child3.spec_path).toBe('docs/new-spec.md'); + expect(child3.external_issue_url).toBe('https://github.com/owner/repo/issues/2'); + + expect(count).toBe(2); // child1 and child3 updated + expect(writeFn).toHaveBeenCalledTimes(2); + }); +}); + +// ============================================================================= +// captureInheritableValues +// ============================================================================= + +describe('captureInheritableValues', () => { + it('captures both spec_path and external_issue_url', () => { + const issue = makeIssue({ + spec_path: 'docs/spec.md', + external_issue_url: 'https://github.com/owner/repo/issues/1', + }); + const values = captureInheritableValues(issue); + + expect(values.spec_path).toBe('docs/spec.md'); + expect(values.external_issue_url).toBe('https://github.com/owner/repo/issues/1'); + }); + + it('captures undefined when fields are not set', () => { + const issue = makeIssue({}); + const values = captureInheritableValues(issue); + + expect(values.spec_path).toBeUndefined(); + expect(values.external_issue_url).toBeUndefined(); + }); +}); From ce419eb9169eabd43879e47bc56da8f35130e93c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 22:41:45 +0000 Subject: [PATCH 14/36] feat: add --external-issue flag to create/update and refactor to use inheritable fields (Phase 1d+1e) Refactor create.ts and update.ts to use the generic inheritable-fields module for both spec_path and external_issue_url inheritance/propagation. Add --external-issue flag with validation (URL parsing, use_gh_cli gate, GitHub API existence check, PR URL rejection). The refactored inheritance is behavior-preserving: all existing spec_path inheritance tests pass unchanged. Part of external issue linking feature (tbd-0gg1, tbd-84ol). https://claude.ai/code/session_01DEmeJXdLaZCM6iZw56JNkK --- packages/tbd/src/cli/commands/create.ts | 59 ++++++++++++-- packages/tbd/src/cli/commands/update.ts | 100 +++++++++++++++++++----- 2 files changed, 132 insertions(+), 27 deletions(-) diff --git a/packages/tbd/src/cli/commands/create.ts b/packages/tbd/src/cli/commands/create.ts index d1c3fdf3..74e3091b 100644 --- a/packages/tbd/src/cli/commands/create.ts +++ b/packages/tbd/src/cli/commands/create.ts @@ -25,6 +25,12 @@ import { resolveDataSyncDir } from '../../lib/paths.js'; import { now } from '../../utils/time-utils.js'; import { readConfig } from '../../file/config.js'; import { resolveAndValidatePath, getPathErrorMessage } from '../../lib/project-paths.js'; +import { inheritFromParent } from '../../lib/inheritable-fields.js'; +import { + parseGitHubIssueUrl, + isGitHubPrUrl, + validateGitHubIssue, +} from '../../file/github-issues.js'; interface CreateOptions { fromFile?: string; @@ -38,6 +44,7 @@ interface CreateOptions { parent?: string; label?: string[]; spec?: string; + externalIssue?: string; } class CreateHandler extends BaseCommand { @@ -75,6 +82,35 @@ class CreateHandler extends BaseCommand { } } + // Validate and resolve external issue URL if provided + let externalIssueUrl: string | undefined; + if (options.externalIssue) { + const config = await readConfig(tbdRoot); + if (config.settings.use_gh_cli === false) { + throw new ValidationError( + 'External issue linking requires GitHub CLI. Set use_gh_cli: true in config or run `tbd setup --auto`.', + ); + } + if (isGitHubPrUrl(options.externalIssue)) { + throw new ValidationError( + 'This is a pull request URL, not an issue URL. Expected: https://github.com/owner/repo/issues/123', + ); + } + const ref = parseGitHubIssueUrl(options.externalIssue); + if (!ref) { + throw new ValidationError( + 'Invalid URL. Expected a full GitHub issue URL like https://github.com/owner/repo/issues/123', + ); + } + const exists = await validateGitHubIssue(ref); + if (!exists) { + throw new ValidationError( + 'Issue not found or not accessible. Check the URL and your GitHub authentication (`gh auth status`).', + ); + } + externalIssueUrl = options.externalIssue; + } + if ( this.checkDryRun('Would create issue', { title, kind, priority, spec: specPath, ...options }) ) { @@ -110,14 +146,7 @@ class CreateHandler extends BaseCommand { } } - // Inherit spec_path from parent if not explicitly provided - if (!specPath && parentId) { - const parentIssue = await readIssue(dataSyncDir, parentId); - if (parentIssue.spec_path) { - specPath = parentIssue.spec_path; - } - } - + // Build issue data issue = { type: 'is', id, @@ -136,8 +165,18 @@ class CreateHandler extends BaseCommand { deferred_until: options.defer ?? undefined, parent_id: parentId, spec_path: specPath, + external_issue_url: externalIssueUrl, }; + // Inherit inheritable fields from parent if not explicitly provided + if (parentId) { + const parentIssue = await readIssue(dataSyncDir, parentId); + const explicitlySet = new Set(); + if (options.spec) explicitlySet.add('spec_path'); + if (options.externalIssue) explicitlySet.add('external_issue_url'); + inheritFromParent(issue, parentIssue, explicitlySet); + } + // Write both the issue and the mapping await writeIssue(dataSyncDir, issue); await saveIdMapping(dataSyncDir, mapping); @@ -199,6 +238,10 @@ export const createCommand = new Command('create') .option('--defer ', 'Defer until date (ISO8601)') .option('--parent ', 'Parent issue ID') .option('--spec ', 'Link to spec document (relative path)') + .option( + '--external-issue ', + 'Link to GitHub issue (e.g., https://github.com/owner/repo/issues/123). Requires use_gh_cli: true', + ) .option('-l, --label