diff --git a/AGENTS.md b/AGENTS.md index c01aa435..be8b2dd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,8 @@ Before changing code, verify an active OpenSpec change explicitly covers the req - If missing scope: create or extend a change first (`openspec` workflow) - Follow strict TDD order: spec delta -> failing tests -> implementation -> passing tests -> quality gates - Record failing/passing evidence in `openspec/changes//TDD_EVIDENCE.md` +- For GitHub issue setup, parent linking, or blocker lookup, consult `.specfact/backlog/github_hierarchy_cache.md` first. This cache is ephemeral local state and MUST NOT be committed. +- Rerun `python scripts/sync_github_hierarchy_cache.py` whenever the cache is missing or stale, and recreate it as part of OpenSpec and GitHub issue work. ### OpenSpec archive rule (hard requirement) diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index 4d2f11e3..adc7628f 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -53,6 +53,7 @@ | Module | Order | Change folder | GitHub # | Blocked by | |--------|-------|---------------|----------|------------| | sync | 01 | sync-01-unified-kernel | [#157](https://github.com/nold-ai/specfact-cli-modules/issues/157) | Parent Feature: [#147](https://github.com/nold-ai/specfact-cli-modules/issues/147); preview/apply safety baseline from `specfact-cli#177` | +| project-runtime | 01 | project-runtime-01-safe-artifact-write-policy | [#177](https://github.com/nold-ai/specfact-cli-modules/issues/177) | Parent Feature: [#161](https://github.com/nold-ai/specfact-cli-modules/issues/161); paired core change [specfact-cli#490](https://github.com/nold-ai/specfact-cli/issues/490); related bug [specfact-cli#487](https://github.com/nold-ai/specfact-cli/issues/487) | ### Cross-layer runtime follow-ups @@ -74,6 +75,7 @@ These changes are the modules-side runtime companions to split core governance a |--------|-------|---------------|----------|------------| | governance | 01 | governance-01-evidence-output | [#169](https://github.com/nold-ai/specfact-cli-modules/issues/169) | Parent Feature: [#163](https://github.com/nold-ai/specfact-cli-modules/issues/163); core counterpart `specfact-cli#247`; validation runtime `#171` | | governance | 02 | governance-02-exception-management | [#167](https://github.com/nold-ai/specfact-cli-modules/issues/167) | Parent Feature: [#163](https://github.com/nold-ai/specfact-cli-modules/issues/163); core counterpart `specfact-cli#248`; policy runtime `#158` | +| governance | 03 | governance-03-github-hierarchy-cache | [#178](https://github.com/nold-ai/specfact-cli-modules/issues/178) | Parent Feature: [#163](https://github.com/nold-ai/specfact-cli-modules/issues/163); paired core `governance-02-github-hierarchy-cache` [specfact-cli#491](https://github.com/nold-ai/specfact-cli/issues/491) | | validation | 02 | validation-02-full-chain-engine | [#171](https://github.com/nold-ai/specfact-cli-modules/issues/171) | Parent Feature: [#163](https://github.com/nold-ai/specfact-cli-modules/issues/163); core counterpart `specfact-cli#241`; runtime inputs from `#164` and `#165`; policy semantics from `#158` | ### Documentation restructure diff --git a/openspec/changes/governance-03-github-hierarchy-cache/.openspec.yaml b/openspec/changes/governance-03-github-hierarchy-cache/.openspec.yaml new file mode 100644 index 00000000..98d7681c --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-09 diff --git a/openspec/changes/governance-03-github-hierarchy-cache/CHANGE_VALIDATION.md b/openspec/changes/governance-03-github-hierarchy-cache/CHANGE_VALIDATION.md new file mode 100644 index 00000000..2e9070c0 --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/CHANGE_VALIDATION.md @@ -0,0 +1,12 @@ +# CHANGE VALIDATION + +- Change: `governance-03-github-hierarchy-cache` +- Date: 2026-04-09 +- Command: `openspec validate governance-03-github-hierarchy-cache --strict` +- Result: PASS + +## Notes + +- The new capability `github-hierarchy-cache` validates as a net-new spec delta. +- The modified capability `backlog-sync` remains aligned with the existing spec folder name. +- The change is apply-ready from an OpenSpec artifact perspective. diff --git a/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md b/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md new file mode 100644 index 00000000..bb274626 --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md @@ -0,0 +1,49 @@ +# TDD Evidence + +## Failing-before implementation + +- Timestamp: `2026-04-09T21:03:37+02:00` +- Command: `python3 -m pytest tests/unit/scripts/test_sync_github_hierarchy_cache.py -q` +- Result: FAIL +- Summary: All three tests failed with `FileNotFoundError` because `scripts/sync_github_hierarchy_cache.py` did not exist yet. + +## Failing-before path relocation refinement + +- Timestamp: `2026-04-09T21:17:04+02:00` +- Command: `python3 -m pytest tests/unit/scripts/test_sync_github_hierarchy_cache.py -q` +- Result: FAIL +- Summary: The new default-path test failed because the script still targeted `openspec/GITHUB_HIERARCHY_CACHE.md` instead of ignored `.specfact/backlog/` storage. + +## Passing-after implementation + +- Timestamp: `2026-04-09T21:17:35+02:00` +- Command: `python3 -m pytest tests/unit/scripts/test_sync_github_hierarchy_cache.py -q` +- Result: PASS +- Summary: All five script tests passed after moving the cache into ignored `.specfact/backlog/` storage and keeping the no-op fingerprint path intact. + +## Additional verification + +- `python3 -m py_compile scripts/sync_github_hierarchy_cache.py` → PASS +- `python3 scripts/sync_github_hierarchy_cache.py --force` → generated `.specfact/backlog/github_hierarchy_cache.md` +- Second `python3 scripts/sync_github_hierarchy_cache.py` run → `GitHub hierarchy cache unchanged (13 issues).` + +## Final scoped quality gates + +Full gate order (per `AGENTS.md` / `CLAUDE.md`). Run from repo root before merge; record PASS/FAIL after each step: + +1. `hatch run format` → PASS +2. `hatch run type-check` → PASS +3. `hatch run lint` → PASS +4. `hatch run yaml-lint` → PASS +5. `hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump` → PASS +6. `hatch run contract-test` → PASS +7. `hatch run smart-test` → PASS +8. `hatch run test` → PASS +9. `hatch run specfact code review run --json --out .specfact/code-review.json` → PASS (`overall_verdict` PASS, `ci_exit_code` 0) + +**Scoped exception:** None for this change; the list above is the required sequence. If CI or policy later narrows scope for a hotfix, update this block with an explicit rationale, approver, and approval id/date instead of omitting gates. + +### `.specfact/code-review.json` (this change) + +- Last refresh: `2026-04-09T21:05:38Z` (UTC), command: `hatch run specfact code review run --json --out .specfact/code-review.json --scope changed` +- Outcome: PASS. Any low-severity DRY hints on icontract precondition helpers are documented under **Code review note** in `proposal.md` (accepted; do not merge predicates in ways that break icontract binding). diff --git a/openspec/changes/governance-03-github-hierarchy-cache/design.md b/openspec/changes/governance-03-github-hierarchy-cache/design.md new file mode 100644 index 00000000..eaac6a00 --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/design.md @@ -0,0 +1,66 @@ +# Context + +`specfact-cli-modules` now carries its own GitHub planning hierarchy, but parent Feature/Epic resolution is still manual and repeated. The goal is to make hierarchy lookup local and deterministic in the modules repo the same way it will be in core: a generated markdown inventory under ignored `.specfact/backlog/` becomes the first lookup surface, and the sync script only performs a full refresh when the Epic/Feature hierarchy changed. + +This is a governance/runtime support change rather than a bundle feature. The output should stay self-contained in this repo and should not depend on the core repo’s cache file. + +## Goals / Non-Goals + +**Goals:** + +- Generate a deterministic markdown cache of Epic and Feature issues for this repository. +- Include enough metadata for issue-parenting work without another GitHub lookup: issue number, title, short summary, labels, parent/child relationships, and issue URLs. +- Make the sync fast on no-op runs by using a small fingerprint/state check before regenerating markdown. +- Update repo guidance so contributors use the cache first and rerun sync only when needed. + +**Non-Goals:** + +- Replacing GitHub as the source of truth for modules-side hierarchy. +- Caching all issue types or full issue bodies. +- Sharing one cache file across both repos. +- Adding runtime coupling from bundle packages to GitHub sync logic. + +## Decisions + +### Reuse the same script contract as core, but keep files repo-local and ephemeral + +The modules repo will implement the same cache contract as the core repo: sync script, state file, and deterministic markdown output. The generated files live under `.specfact/backlog/` so they remain local, ignored, and easy to regenerate. + +Alternative considered: + +- Import the core script from `specfact-cli`: rejected because governance tooling should work from this repo without special cross-repo bootstrapping. + +### Use `gh api graphql` for hierarchy metadata + +The script will use `gh api graphql` to retrieve issue type, labels, relationships, and summary fields in a compact way. This keeps the implementation aligned with the core repo and avoids bespoke HTML or REST stitching. + +Alternative considered: + +- `gh issue view/list` fan-out calls: too many calls and weaker relationship support. + +### Split fingerprint detection from markdown rendering + +The script will compute a fingerprint from Epic/Feature identity plus relevant change signals, compare it with a local state file, and skip markdown regeneration when nothing changed. When the fingerprint differs, it will fetch full data and rewrite the cache deterministically. + +Alternative considered: + +- Always rewrite the cache: simpler, but slower and noisier for routine use. + +## Risks / Trade-offs + +- [Core/modules drift] → Keep file names, output structure, and tests closely aligned across both repos. +- [GitHub metadata gaps] → Normalize missing parents, children, and summaries instead of failing on absent optional fields. +- [Users forget to refresh] → Make rerun conditions explicit in `AGENTS.md` and keep the no-op path cheap. + +## Migration Plan + +1. Add the sync script, state handling, markdown renderer, and tests in this repo. +2. Generate the initial modules-side cache file under ignored `.specfact/backlog/`. +3. Update `AGENTS.md` with cache-first GitHub parenting guidance. +4. Run verification and keep the paired core change aligned before implementation closes. + +Rollback removes the script, cache, state file, and governance references without affecting bundle runtime code. + +## Open Questions + +- Whether a future follow-up should surface the cache in published docs, or keep it strictly as a maintainer artifact. diff --git a/openspec/changes/governance-03-github-hierarchy-cache/proposal.md b/openspec/changes/governance-03-github-hierarchy-cache/proposal.md new file mode 100644 index 00000000..82b928fa --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/proposal.md @@ -0,0 +1,38 @@ +# Governance: GitHub hierarchy cache (specfact-cli-modules) + +## Why + +The modules repository now has its own Epic and Feature hierarchy, but contributors still have to query GitHub directly to rediscover parent Features and Epics before syncing OpenSpec changes. That creates unnecessary API traffic and makes cross-repo governance slower and less deterministic than it should be. + +## What Changes + +- Add a deterministic repo-local hierarchy cache generator for `specfact-cli-modules` Epic and Feature issues. +- Persist a repo-local markdown hierarchy cache at `.specfact/backlog/github_hierarchy_cache.md` (ignored; not committed) with issue number, title, brief summary, labels, and hierarchy relationships, plus a companion fingerprint/state file `.specfact/backlog/github_hierarchy_cache_state.json` so the sync can exit quickly when Epic and Feature metadata has not changed. +- Update governance instructions in `AGENTS.md` for modules-side GitHub issue setup to consult the cache first and rerun sync only when needed. +- Keep the modules-side cache behavior aligned with the paired core change so both repos expose the same planning lookup pattern. + +## Capabilities + +### New Capabilities + +- `github-hierarchy-cache`: Deterministic synchronization of GitHub Epic and Feature hierarchy metadata into a repo-local OpenSpec markdown cache for low-cost parent and planning lookups. + +### Modified Capabilities + +- `backlog-sync`: Modules-side backlog and change-sync workflows must be able to resolve current Epic and Feature planning metadata from the repo-local cache before performing manual GitHub lookups. + +## Impact + +- Affected code: new script and tests under `scripts/` and `tests/`, plus governance guidance in `AGENTS.md`. +- Affected workflow: OpenSpec change creation and modules-side GitHub issue parenting become cache-first instead of lookup-first. +- Cross-repo impact: this change must stay aligned with `specfact-cli` so both repos use the same hierarchy-cache operating model. + +## Source Tracking + +- GitHub Issue: [#178](https://github.com/nold-ai/specfact-cli-modules/issues/178) +- Parent Feature: [#163](https://github.com/nold-ai/specfact-cli-modules/issues/163) +- Paired core (specfact-cli): `governance-02-github-hierarchy-cache` — tracked in `specfact-cli` `openspec/CHANGE_ORDER.md` with [specfact-cli#491](https://github.com/nold-ai/specfact-cli/issues/491) (distinct from the older `governance-02-exception-management` / `#248` row in the same file). + +## Code review note (SpecFact dogfood) + +Icontract `@require` preconditions on `fetch_hierarchy_issues`, `render_cache_markdown`, and `sync_cache` intentionally use small, similarly shaped predicates (each checks one string field). The code-review module may emit low-severity DRY / duplicate-shape hints for those helpers; that is accepted here because collapsing them would break icontract’s per-parameter argument binding (e.g. `**kwargs` predicates are not supported the same way). diff --git a/openspec/changes/governance-03-github-hierarchy-cache/specs/backlog-sync/spec.md b/openspec/changes/governance-03-github-hierarchy-cache/specs/backlog-sync/spec.md new file mode 100644 index 00000000..cd330199 --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/specs/backlog-sync/spec.md @@ -0,0 +1,49 @@ +## MODIFIED Requirements + +### Requirement: Restore backlog sync command functionality +The system SHALL provide `specfact backlog sync` command for bidirectional backlog synchronization, and related governance workflows SHALL be able to resolve current Epic and Feature planning metadata from the repo-local hierarchy cache before performing manual GitHub lookups. + +#### Scenario: Sync from OpenSpec to backlog + +- **WHEN** the user runs `specfact backlog sync --adapter github --project-id ` +- **THEN** OpenSpec changes are exported to GitHub issues +- **AND** state mapping preserves status semantics + +#### Scenario: Bidirectional sync with cross-adapter +- **WHEN** the user runs sync with cross-adapter configuration +- **THEN** state is mapped between adapters using canonical status +- **AND** lossless round-trip preserves content + +#### Scenario: Sync with bundle integration +- **WHEN** sync is run within an OpenSpec bundle context +- **THEN** synced items update bundle state and source tracking + +#### Scenario: Ceremony alias works +- **WHEN** the user runs `specfact backlog ceremony sync` +- **THEN** the command forwards to `specfact backlog sync` + +#### Scenario: Cache-first hierarchy lookup for parent issue assignment +- **GIVEN** a contributor needs a parent Feature or Epic while preparing GitHub sync metadata +- **WHEN** the local hierarchy cache is present and current +- **THEN** the contributor can resolve the parent relationship from the cache without an additional GitHub lookup +- **AND** the sync script is rerun only when the cache is stale or missing + +### Requirement: Backlog sync checks for existing external issue mappings before creation +The backlog sync system SHALL check for existing issue mappings from external tools (including spec-kit extensions) before creating new backlog issues, to prevent duplicates. + +#### Scenario: Backlog sync with spec-kit extension mappings available + +- **GIVEN** a project with both SpecFact backlog sync and spec-kit backlog extensions active +- **AND** `SpecKitBacklogSync.detect_issue_mappings()` has returned mappings for some tasks +- **WHEN** `specfact backlog sync` runs for the project +- **THEN** for each task, the sync checks imported issue mappings first +- **AND** skips creation for tasks with existing mappings +- **AND** creates new issues only for unmapped tasks +- **AND** the sync summary reports both skipped (already-mapped) and newly-created issues + +#### Scenario: Backlog sync without spec-kit extensions + +- **GIVEN** a project without spec-kit or without backlog extensions +- **WHEN** `specfact backlog sync` runs +- **THEN** the sync creates issues for all tasks as before (no behavior change) +- **AND** no spec-kit extension detection is attempted diff --git a/openspec/changes/governance-03-github-hierarchy-cache/specs/github-hierarchy-cache/spec.md b/openspec/changes/governance-03-github-hierarchy-cache/specs/github-hierarchy-cache/spec.md new file mode 100644 index 00000000..ada87055 --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/specs/github-hierarchy-cache/spec.md @@ -0,0 +1,29 @@ +# ADDED Requirements + +## Requirement: Repository hierarchy cache sync + +The repository SHALL provide a deterministic sync mechanism that retrieves GitHub Epic and Feature issues for the current repository and writes a local hierarchy cache under ignored `.specfact/backlog/`. + +### Scenario: Generate hierarchy cache from GitHub metadata + +- **WHEN** the user runs the hierarchy cache sync script for the repository +- **THEN** the script retrieves GitHub issues whose Type is `Epic` or `Feature` +- **AND** writes a markdown cache under ignored `.specfact/backlog/` with each issue's number, title, URL, short summary, labels, and hierarchy relationships +- **AND** the output ordering is deterministic across repeated runs with unchanged source data + +### Scenario: Fast exit on unchanged hierarchy state + +- **WHEN** the script detects that the current Epic and Feature hierarchy fingerprint matches the last synced fingerprint +- **THEN** it exits successfully without regenerating the markdown cache +- **AND** it reports that no hierarchy update was required + +## Requirement: Modules governance must use cache-first hierarchy lookup + +Repository governance instructions SHALL direct contributors and agents to consult the local hierarchy cache before performing manual GitHub lookups for Epic or Feature parenting. + +### Scenario: Cache-first governance guidance + +- **WHEN** a contributor reads `AGENTS.md` for GitHub issue setup guidance +- **THEN** the instructions tell them to consult the local hierarchy cache first +- **AND** the instructions define when the sync script must be rerun to refresh stale hierarchy metadata +- **AND** the instructions state that the cache is local ephemeral state and must not be committed diff --git a/openspec/changes/governance-03-github-hierarchy-cache/tasks.md b/openspec/changes/governance-03-github-hierarchy-cache/tasks.md new file mode 100644 index 00000000..56c8bedf --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/tasks.md @@ -0,0 +1,20 @@ +## 1. Change setup and governance sync + +- [x] 1.1 Create and sync the GitHub issue for `governance-03-github-hierarchy-cache`, attach it to the correct parent Feature, and update `openspec/CHANGE_ORDER.md` plus proposal source tracking. +- [x] 1.2 Validate the change artifacts and capture the validation report in `openspec/changes/governance-03-github-hierarchy-cache/CHANGE_VALIDATION.md`. + +## 2. Spec-first test setup + +- [x] 2.1 Add or update tests for hierarchy fingerprinting, deterministic markdown rendering, and fast no-change exit behavior. +- [x] 2.2 Run the targeted test command, confirm it fails before implementation, and record the failing run in `openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md`. + +## 3. Implementation + +- [x] 3.1 Implement the repository-local GitHub hierarchy cache sync script and state file handling under `scripts/`. +- [x] 3.2 Generate the initial `.specfact/backlog/github_hierarchy_cache.md` output and ensure reruns remain deterministic without committing it. +- [x] 3.3 Update `AGENTS.md` so GitHub issue setup and parent lookup use the cache-first workflow. + +## 4. Verification + +- [x] 4.1 Re-run the targeted tests and record the passing run in `openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md`. +- [x] 4.2 Run the required repo quality gates for the touched scope, including code review JSON refresh if stale. diff --git a/openspec/config.yaml b/openspec/config.yaml index cfbf0599..b617b307 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -54,6 +54,13 @@ rules: - Align bundle and registry changes with semver, `core_compatibility`, signing, and AGENTS.md release policy. - State impact on `registry/index.json` and any `packages//module-package.yaml` when versions or artifacts change. - For user-facing doc or command changes, note affected `docs/` paths and modules.specfact.io permalinks. + - >- + For public GitHub issue setup in this repo, resolve Parent Feature or Epic from + `.specfact/backlog/github_hierarchy_cache.md` first (regenerate via `python scripts/sync_github_hierarchy_cache.py` + when missing or stale). The cache is ephemeral local state and MUST NOT be committed. + **Pending until core:** `specfact backlog add` / `specfact backlog sync` do not yet read this cache automatically; + until a paired core change wires cache-first lookup into those commands, treat this rule as **contributor and + agent workflow** (docs + local script), not as enforced bundle runtime behavior. specs: - Use Given/When/Then for scenarios; tie scenarios to tests under `tests/` for bundle or registry behavior. @@ -67,6 +74,11 @@ rules: - >- Enforce SDD+TDD order: branch/worktree → spec deltas → failing tests → implementation → passing tests → TDD_EVIDENCE.md → quality gates → PR. + - >- + Before GitHub issue creation or parent linking, consult `.specfact/backlog/github_hierarchy_cache.md`; rerun + `python scripts/sync_github_hierarchy_cache.py` when the cache is missing or stale. Treat this cache as ephemeral + local state, not a committed OpenSpec artifact. **Pending until core:** backlog CLI commands do not yet consume + the cache automatically—track alignment with the paired `specfact-cli` governance hierarchy-cache change. - Include module signing / version-bump tasks when `module-package.yaml` or bundle payloads change (see AGENTS.md). - Record TDD evidence in `openspec/changes//TDD_EVIDENCE.md` for behavior changes. - |- diff --git a/scripts/sync_github_hierarchy_cache.py b/scripts/sync_github_hierarchy_cache.py new file mode 100755 index 00000000..5e4031d5 --- /dev/null +++ b/scripts/sync_github_hierarchy_cache.py @@ -0,0 +1,594 @@ +#!/usr/bin/env python3 +"""Sync GitHub Epic/Feature hierarchy into a local OpenSpec cache.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import sys +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from beartype import beartype +from icontract import ensure, require + + +DEFAULT_REPO_OWNER = "nold-ai" +_SCRIPT_DIR = Path(__file__).resolve().parent + + +@beartype +@ensure( + lambda result: result is None or bool(str(result).strip()), + "parsed repository name must be non-blank when present", +) +def parse_repo_name_from_remote_url(url: str) -> str | None: + """Return the repository name segment from a Git remote URL, if parseable.""" + stripped = url.strip() + if not stripped: + return None + if stripped.startswith("git@"): + _, _, rest = stripped.partition(":") + path = rest + elif "://" in stripped: + host_and_path = stripped.split("://", 1)[1] + if "/" not in host_and_path: + return None + path = host_and_path.split("/", 1)[1] + else: + path = stripped + path = path.rstrip("/") + if path.endswith(".git"): + path = path[:-4] + segments = [segment for segment in path.split("/") if segment] + if not segments: + return None + return segments[-1] + + +@beartype +def _default_repo_name_from_git(script_dir: Path) -> str | None: + """Resolve the GitHub repository name from ``origin`` (works in worktrees).""" + try: + completed = subprocess.run( + ["git", "-C", str(script_dir), "config", "--get", "remote.origin.url"], + check=False, + capture_output=True, + text=True, + ) + except (FileNotFoundError, OSError): + return None + if completed.returncode != 0: + return None + return parse_repo_name_from_remote_url(completed.stdout) + + +_DEFAULT_REPO_NAME_FALLBACK = Path(__file__).resolve().parents[1].name +DEFAULT_REPO_NAME = _default_repo_name_from_git(_SCRIPT_DIR) or _DEFAULT_REPO_NAME_FALLBACK +DEFAULT_OUTPUT_PATH = Path(".specfact") / "backlog" / "github_hierarchy_cache.md" +DEFAULT_STATE_PATH = Path(".specfact") / "backlog" / "github_hierarchy_cache_state.json" +SUPPORTED_ISSUE_TYPES = frozenset({"Epic", "Feature"}) +SUPPORTED_ISSUE_TYPES_ORDER: tuple[str, ...] = ("Epic", "Feature") +_SUMMARY_SKIP_LINES = {"why", "scope", "summary", "changes", "capabilities", "impact"} +_GH_GRAPHQL_TIMEOUT_SEC = 120 + + +@beartype +def _build_hierarchy_issues_query(*, include_body: bool) -> str: + """Return the shared GitHub GraphQL query, optionally including issue body text.""" + body_field = " bodyText\n" if include_body else "" + return f""" +query($owner: String!, $name: String!, $after: String) {{ + repository(owner: $owner, name: $name) {{ + issues(first: 100, after: $after, states: [OPEN, CLOSED], orderBy: {{field: CREATED_AT, direction: ASC}}) {{ + pageInfo {{ hasNextPage endCursor }} + nodes {{ + number + title + url + updatedAt +{body_field} issueType {{ name }} + labels(first: 100) {{ nodes {{ name }} }} + parent {{ number title url }} + subIssues(first: 100) {{ nodes {{ number title url issueType {{ name }} }} }} + }} + }} + }} +}} +""".strip() + + +@dataclass(frozen=True) +class IssueLink: + """Compact link to a related issue.""" + + number: int + title: str + url: str + + +@dataclass(frozen=True) +class HierarchyIssue: + """Normalized hierarchy issue used for cache rendering.""" + + number: int + title: str + url: str + issue_type: str + labels: list[str] + summary: str + updated_at: str + parent: IssueLink | None + children: list[IssueLink] + + +@dataclass(frozen=True) +class SyncResult: + """Outcome of a cache sync attempt.""" + + changed: bool + issue_count: int + fingerprint: str + output_path: Path + + +@beartype +def _extract_summary(body_text: str) -> str: + """Return a compact summary line for markdown output.""" + normalized = body_text.replace("\\n", "\n") + for line in normalized.splitlines(): + cleaned = line.strip() + if not cleaned: + continue + if cleaned.startswith("#"): + cleaned = cleaned.lstrip("#").strip() + if cleaned.lower().rstrip(":") in _SUMMARY_SKIP_LINES: + continue + if cleaned: + return cleaned[:200] + return "No summary provided." + + +@beartype +def _parse_issue_link(node: Mapping[str, Any] | None) -> IssueLink | None: + """Convert a GraphQL link node to IssueLink.""" + if not node: + return None + return IssueLink( + number=int(node["number"]), + title=str(node["title"]), + url=str(node["url"]), + ) + + +@beartype +def _mapping_value(node: Mapping[str, Any], key: str) -> Mapping[str, Any] | None: + """Return a nested mapping value when present.""" + value = node.get(key) + return value if isinstance(value, Mapping) else None + + +@beartype +def _mapping_nodes(container: Mapping[str, Any] | None) -> list[Mapping[str, Any]]: + """Return a filtered list of mapping nodes from a GraphQL connection.""" + if container is None: + return [] + + raw_nodes = container.get("nodes") + if not isinstance(raw_nodes, list): + return [] + + return [item for item in raw_nodes if isinstance(item, Mapping)] + + +@beartype +def _label_names(label_nodes: list[Mapping[str, Any]]) -> list[str]: + """Extract sorted label names from GraphQL label nodes.""" + names: list[str] = [] + for item in label_nodes: + name = item.get("name") + if name: + names.append(str(name)) + return sorted(names, key=str.lower) + + +@beartype +def _subissue_type_name(item: Mapping[str, Any]) -> str | None: + """Return sub-issue type name when present.""" + issue_type_node = _mapping_value(item, "issueType") + if issue_type_node and issue_type_node.get("name"): + return str(issue_type_node["name"]) + return None + + +@beartype +def _child_links(subissue_nodes: list[Mapping[str, Any]]) -> list[IssueLink]: + """Extract sorted child issue links from GraphQL subissue nodes (Epic/Feature only).""" + children = [ + IssueLink(number=int(item["number"]), title=str(item["title"]), url=str(item["url"])) + for item in subissue_nodes + if item.get("number") is not None and _subissue_type_name(item) in SUPPORTED_ISSUE_TYPES + ] + children.sort(key=lambda item: item.number) + return children + + +@beartype +def _parse_issue_node(node: Mapping[str, Any], *, include_body: bool) -> HierarchyIssue | None: + """Convert a GraphQL issue node to HierarchyIssue when supported.""" + issue_type_node = _mapping_value(node, "issueType") + issue_type_name = str(issue_type_node["name"]) if issue_type_node and issue_type_node.get("name") else None + if issue_type_name not in SUPPORTED_ISSUE_TYPES: + return None + + summary = _extract_summary(str(node.get("bodyText", ""))) if include_body else "" + return HierarchyIssue( + number=int(node["number"]), + title=str(node["title"]), + url=str(node["url"]), + issue_type=str(issue_type_name), + labels=_label_names(_mapping_nodes(_mapping_value(node, "labels"))), + summary=summary, + updated_at=str(node["updatedAt"]), + parent=_parse_issue_link(_mapping_value(node, "parent")), + children=_child_links(_mapping_nodes(_mapping_value(node, "subIssues"))), + ) + + +@beartype +def _run_graphql_query(query: str, *, repo_owner: str, repo_name: str, after: str | None) -> Mapping[str, Any]: + """Run a GitHub GraphQL query through `gh`.""" + command = [ + "gh", + "api", + "graphql", + "-f", + f"query={query}", + "-F", + f"owner={repo_owner}", + "-F", + f"name={repo_name}", + ] + if after is not None: + command.extend(["-F", f"after={after}"]) + + try: + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=_GH_GRAPHQL_TIMEOUT_SEC, + ) + except subprocess.TimeoutExpired as exc: + detail = f"GitHub GraphQL subprocess timed out after {_GH_GRAPHQL_TIMEOUT_SEC}s" + out = (exc.stdout or "").strip() + err = (exc.stderr or "").strip() + if out or err: + detail = f"{detail}; stdout={out!r}; stderr={err!r}" + raise RuntimeError(detail) from exc + + if completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or completed.stdout.strip() or "GitHub GraphQL query failed") + + payload = json.loads(completed.stdout) + if "errors" in payload: + raise RuntimeError(json.dumps(payload["errors"], indent=2)) + return payload + + +@beartype +def _is_not_blank(value: str) -> bool: + """Return whether a required CLI string value is non-blank.""" + return bool(value.strip()) + + +@beartype +def _all_supported_issue_types(result: list[HierarchyIssue]) -> bool: + """Return whether every issue has a supported issue type.""" + return all(issue.issue_type in SUPPORTED_ISSUE_TYPES for issue in result) + + +@beartype +def _require_repo_owner_for_fetch(*, repo_owner: str, repo_name: str, fingerprint_only: bool) -> bool: + _ = (repo_name, fingerprint_only) + return _is_not_blank(repo_owner) + + +@beartype +def _require_repo_name_for_fetch(*, repo_owner: str, repo_name: str, fingerprint_only: bool) -> bool: + _ = (repo_owner, fingerprint_only) + return _is_not_blank(repo_name) + + +@beartype +@require(_require_repo_owner_for_fetch, "repo_owner must not be blank") +@require(_require_repo_name_for_fetch, "repo_name must not be blank") +@ensure(_all_supported_issue_types, "Only Epic and Feature issues should be returned") +def fetch_hierarchy_issues(*, repo_owner: str, repo_name: str, fingerprint_only: bool) -> list[HierarchyIssue]: + """Fetch Epic and Feature issues from GitHub for the given repository.""" + query = _build_hierarchy_issues_query(include_body=not fingerprint_only) + issues: list[HierarchyIssue] = [] + after: str | None = None + + while True: + payload = _run_graphql_query(query, repo_owner=repo_owner, repo_name=repo_name, after=after) + repository = payload.get("data", {}).get("repository", {}) + issue_connection = repository.get("issues", {}) + nodes = issue_connection.get("nodes", []) + for node in nodes: + if not isinstance(node, Mapping): + continue + parsed = _parse_issue_node(node, include_body=not fingerprint_only) + if parsed is not None: + issues.append(parsed) + page_info = issue_connection.get("pageInfo", {}) + if not page_info.get("hasNextPage"): + break + after = page_info.get("endCursor") + + return issues + + +@beartype +@ensure(lambda result: len(result) == 64, "Fingerprint must be a SHA-256 hex digest") +def compute_hierarchy_fingerprint(issues: list[HierarchyIssue]) -> str: + """Compute a deterministic fingerprint for hierarchy state.""" + canonical_rows: list[dict[str, Any]] = [] + for issue in sorted(issues, key=lambda item: (item.issue_type, item.number)): + canonical_rows.append( + { + "number": issue.number, + "title": issue.title, + "issue_type": issue.issue_type, + "updated_at": issue.updated_at, + "labels": sorted(issue.labels, key=str.lower), + "parent_number": issue.parent.number if issue.parent else None, + "child_numbers": [child.number for child in sorted(issue.children, key=lambda item: item.number)], + } + ) + + canonical_json = json.dumps(canonical_rows, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest() + + +@beartype +def _group_issues_by_type(issues: list[HierarchyIssue]) -> dict[str, list[HierarchyIssue]]: + """Return issues grouped by supported type in deterministic order.""" + return { + issue_type: sorted((item for item in issues if item.issue_type == issue_type), key=lambda item: item.number) + for issue_type in SUPPORTED_ISSUE_TYPES_ORDER + } + + +@beartype +def _render_issue_block(issue: HierarchyIssue) -> list[str]: + """Render one issue block for the hierarchy cache.""" + parent_text = "none" + if issue.parent is not None: + parent_text = f"#{issue.parent.number} {issue.parent.title}" + + child_text = "none" + if issue.children: + child_text = ", ".join(f"#{child.number} {child.title}" for child in issue.children) + + label_text = ", ".join(sorted(issue.labels, key=str.lower)) if issue.labels else "none" + return [ + f"### #{issue.number} {issue.title}", + f"- URL: {issue.url}", + f"- Parent: {parent_text}", + f"- Children: {child_text}", + f"- Labels: {label_text}", + f"- Summary: {issue.summary or 'No summary provided.'}", + "", + ] + + +@beartype +def _render_issue_section(*, title: str, issues: list[HierarchyIssue]) -> list[str]: + """Render one section of grouped issues.""" + lines = [f"## {title}", ""] + if not issues: + lines.extend(["_None_", ""]) + return lines + + for issue in issues: + lines.extend(_render_issue_block(issue)) + return lines + + +@beartype +def _require_repo_full_name_for_render( + *, repo_full_name: str, issues: list[HierarchyIssue], generated_at: str, fingerprint: str +) -> bool: + _ = (issues, generated_at, fingerprint) + return _is_not_blank(repo_full_name) + + +@beartype +def _require_generated_at_for_render( + *, repo_full_name: str, issues: list[HierarchyIssue], generated_at: str, fingerprint: str +) -> bool: + _ = (repo_full_name, issues, fingerprint) + return _is_not_blank(generated_at) + + +@beartype +def _require_fingerprint_for_render( + *, repo_full_name: str, issues: list[HierarchyIssue], generated_at: str, fingerprint: str +) -> bool: + _ = (repo_full_name, issues, generated_at) + return _is_not_blank(fingerprint) + + +@beartype +@require(_require_repo_full_name_for_render, "repo_full_name must not be blank") +@require(_require_generated_at_for_render, "generated_at must not be blank") +@require(_require_fingerprint_for_render, "fingerprint must not be blank") +def render_cache_markdown( + *, + repo_full_name: str, + issues: list[HierarchyIssue], + generated_at: str, + fingerprint: str, +) -> str: + """Render deterministic markdown for the hierarchy cache.""" + grouped = _group_issues_by_type(issues) + + lines = [ + "# GitHub Hierarchy Cache", + "", + f"- Repository: `{repo_full_name}`", + f"- Generated At: `{generated_at}`", + f"- Fingerprint: `{fingerprint}`", + f"- Included Issue Types: `{', '.join(sorted(SUPPORTED_ISSUE_TYPES))}`", + "", + ( + "Use this file as the first lookup source for parent Epic or Feature relationships " + "during OpenSpec and GitHub issue setup." + ), + "", + ] + + for section_name, issue_type in (("Epics", "Epic"), ("Features", "Feature")): + lines.extend(_render_issue_section(title=section_name, issues=grouped[issue_type])) + + return "\n".join(lines).rstrip() + "\n" + + +@beartype +def _load_state(state_path: Path) -> Mapping[str, Any]: + """Load state JSON if it exists; otherwise return empty mapping.""" + if not state_path.exists(): + return {} + try: + loaded = json.loads(state_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + return loaded if isinstance(loaded, Mapping) else {} + + +@beartype +def _write_state( + *, state_path: Path, repo_full_name: str, fingerprint: str, issue_count: int, generated_at: str +) -> None: + """Persist machine-readable sync state.""" + state_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "repo": repo_full_name, + "fingerprint": fingerprint, + "issue_count": issue_count, + "generated_at": generated_at, + } + state_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +@beartype +def _require_repo_owner_for_sync( + *, repo_owner: str, repo_name: str, output_path: Path, state_path: Path, force: bool = False +) -> bool: + _ = (repo_name, output_path, state_path, force) + return _is_not_blank(repo_owner) + + +@beartype +def _require_repo_name_for_sync( + *, repo_owner: str, repo_name: str, output_path: Path, state_path: Path, force: bool = False +) -> bool: + _ = (repo_owner, output_path, state_path, force) + return _is_not_blank(repo_name) + + +@beartype +@require(_require_repo_owner_for_sync, "repo_owner must not be blank") +@require(_require_repo_name_for_sync, "repo_name must not be blank") +def sync_cache( + *, + repo_owner: str, + repo_name: str, + output_path: Path, + state_path: Path, + force: bool = False, +) -> SyncResult: + """Sync the local hierarchy cache from GitHub.""" + state = _load_state(state_path) + detailed_issues = fetch_hierarchy_issues( + repo_owner=repo_owner, + repo_name=repo_name, + fingerprint_only=False, + ) + fingerprint = compute_hierarchy_fingerprint(detailed_issues) + + if not force and state.get("fingerprint") == fingerprint and output_path.exists(): + return SyncResult( + changed=False, + issue_count=len(detailed_issues), + fingerprint=fingerprint, + output_path=output_path, + ) + + generated_at = datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + render_cache_markdown( + repo_full_name=f"{repo_owner}/{repo_name}", + issues=detailed_issues, + generated_at=generated_at, + fingerprint=fingerprint, + ), + encoding="utf-8", + ) + _write_state( + state_path=state_path, + repo_full_name=f"{repo_owner}/{repo_name}", + fingerprint=fingerprint, + issue_count=len(detailed_issues), + generated_at=generated_at, + ) + return SyncResult( + changed=True, + issue_count=len(detailed_issues), + fingerprint=fingerprint, + output_path=output_path, + ) + + +@beartype +def _build_parser() -> argparse.ArgumentParser: + """Create CLI argument parser.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-owner", default=DEFAULT_REPO_OWNER, help="GitHub repo owner") + parser.add_argument("--repo-name", default=DEFAULT_REPO_NAME, help="GitHub repo name") + parser.add_argument("--output", default=str(DEFAULT_OUTPUT_PATH), help="Markdown cache output path") + parser.add_argument("--state-file", default=str(DEFAULT_STATE_PATH), help="Fingerprint state file path") + parser.add_argument("--force", action="store_true", help="Rewrite cache even when fingerprint is unchanged") + return parser + + +@beartype +@ensure(lambda result: result >= 0, "exit code must be non-negative") +def main(argv: list[str] | None = None) -> int: + """Run the hierarchy cache sync.""" + parser = _build_parser() + args = parser.parse_args(argv) + result = sync_cache( + repo_owner=args.repo_owner, + repo_name=args.repo_name, + output_path=Path(args.output), + state_path=Path(args.state_file), + force=bool(args.force), + ) + if result.changed: + sys.stdout.write(f"Updated GitHub hierarchy cache with {result.issue_count} issues at {result.output_path}\n") + else: + sys.stdout.write(f"GitHub hierarchy cache unchanged ({result.issue_count} issues).\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/scripts/test_sync_github_hierarchy_cache.py b/tests/unit/scripts/test_sync_github_hierarchy_cache.py new file mode 100644 index 00000000..0018d1a7 --- /dev/null +++ b/tests/unit/scripts/test_sync_github_hierarchy_cache.py @@ -0,0 +1,396 @@ +"""Tests for scripts/sync_github_hierarchy_cache.py.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from functools import lru_cache +from pathlib import Path +from typing import Any, TypedDict + +import pytest + + +class IssueOptions(TypedDict, total=False): + """Optional test issue fields.""" + + labels: list[str] + summary: str + parent: tuple[int, str] + children: list[tuple[int, str]] + updated_at: str + + +@lru_cache(maxsize=1) +def _load_script_module() -> Any: + """Load scripts/sync_github_hierarchy_cache.py as a Python module (cached for stable types).""" + script_path = Path(__file__).resolve().parents[3] / "scripts" / "sync_github_hierarchy_cache.py" + spec = importlib.util.spec_from_file_location("sync_github_hierarchy_cache", script_path) + if spec is None or spec.loader is None: + raise AssertionError(f"Unable to load script module at {script_path}") + sys.modules.pop(spec.name, None) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _make_issue( + module: Any, + *, + number: int, + title: str, + issue_type: str, + options: IssueOptions | None = None, +) -> Any: + """Create a HierarchyIssue instance for tests.""" + issue_options = options or {} + children = issue_options.get("children", []) + child_links = [ + module.IssueLink(number=child_number, title=child_title, url=f"https://example.test/issues/{child_number}") + for child_number, child_title in children + ] + + parent_link = None + parent = issue_options.get("parent") + if parent is not None: + parent_number, parent_title = parent + parent_link = module.IssueLink( + number=parent_number, + title=parent_title, + url=f"https://example.test/issues/{parent_number}", + ) + + return module.HierarchyIssue( + number=number, + title=title, + url=f"https://example.test/issues/{number}", + issue_type=issue_type, + labels=issue_options.get("labels", []), + summary=issue_options.get("summary", ""), + updated_at=issue_options.get("updated_at", "2026-04-09T08:00:00Z"), + parent=parent_link, + children=child_links, + ) + + +def test_compute_hierarchy_fingerprint_is_order_independent() -> None: + """Fingerprinting should stay stable regardless of input ordering.""" + module = _load_script_module() + + epic = _make_issue( + module, + number=485, + title="[Epic] Governance", + issue_type="Epic", + options={ + "labels": ["openspec", "Epic"], + "summary": "Governance epic.", + "children": [(486, "[Feature] Alignment")], + }, + ) + feature = _make_issue( + module, + number=486, + title="[Feature] Alignment", + issue_type="Feature", + options={ + "labels": ["Feature", "openspec"], + "summary": "Alignment feature.", + "parent": (485, "[Epic] Governance"), + }, + ) + + first = module.compute_hierarchy_fingerprint([epic, feature]) + second = module.compute_hierarchy_fingerprint([feature, epic]) + + assert first == second + + +def test_extract_summary_skips_heading_only_lines() -> None: + """Summary extraction should skip markdown section headers.""" + module = _load_script_module() + extract_summary = module._extract_summary # pylint: disable=protected-access + + summary = extract_summary("## Why\n\nThis cache avoids repeated GitHub lookups.") + + assert summary == "This cache avoids repeated GitHub lookups." + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://github.com/nold-ai/specfact-cli-modules.git", "specfact-cli-modules"), + ("git@github.com:nold-ai/specfact-cli-modules.git", "specfact-cli-modules"), + ("https://github.com/org/my-repo/", "my-repo"), + ], +) +def test_parse_repo_name_from_remote_url(url: str, expected: str) -> None: + """Remote URL tail parsing should yield the GitHub repository name.""" + module = _load_script_module() + assert module.parse_repo_name_from_remote_url(url) == expected + + +def test_parse_repo_name_from_remote_url_empty_returns_none() -> None: + """Blank remote URLs should not produce a repository name.""" + module = _load_script_module() + assert module.parse_repo_name_from_remote_url("") is None + assert module.parse_repo_name_from_remote_url(" ") is None + + +def test_default_repo_name_matches_git_origin_url() -> None: + """When ``remote.origin.url`` exists, DEFAULT_REPO_NAME must match its repository segment (worktrees).""" + module = _load_script_module() + scripts_dir = Path(__file__).resolve().parents[3] / "scripts" + completed = subprocess.run( + ["git", "-C", str(scripts_dir), "config", "--get", "remote.origin.url"], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode != 0 or not completed.stdout.strip(): + pytest.skip("No git origin in this environment") + expected = module.parse_repo_name_from_remote_url(completed.stdout) + assert expected is not None + assert expected == module.DEFAULT_REPO_NAME + + +def test_default_paths_use_ephemeral_specfact_backlog_cache() -> None: + """Default cache files should live in ignored .specfact/backlog storage.""" + module = _load_script_module() + + assert str(module.DEFAULT_OUTPUT_PATH) == ".specfact/backlog/github_hierarchy_cache.md" + assert str(module.DEFAULT_STATE_PATH) == ".specfact/backlog/github_hierarchy_cache_state.json" + + +def test_child_links_include_only_epic_and_feature_subissues() -> None: + """Sub-issue GraphQL nodes should contribute children only when type is Epic or Feature.""" + module = _load_script_module() + child_links = module._child_links( # pylint: disable=protected-access + [ + {"number": 1, "title": "Task", "url": "https://example.test/1", "issueType": {"name": "Task"}}, + {"number": 2, "title": "Feat", "url": "https://example.test/2", "issueType": {"name": "Feature"}}, + {"number": 3, "title": "Ep", "url": "https://example.test/3", "issueType": {"name": "Epic"}}, + {"number": 4, "title": "Untyped", "url": "https://example.test/4"}, + ] + ) + assert [link.number for link in child_links] == [2, 3] + + +def test_render_cache_markdown_groups_epics_and_features() -> None: + """Rendered markdown should be deterministic and grouped by issue type.""" + module = _load_script_module() + + issues = [ + _make_issue( + module, + number=486, + title="[Feature] Alignment", + issue_type="Feature", + options={ + "labels": ["openspec", "Feature"], + "summary": "Alignment feature.", + "parent": (485, "[Epic] Governance"), + }, + ), + _make_issue( + module, + number=485, + title="[Epic] Governance", + issue_type="Epic", + options={ + "labels": ["Epic", "openspec"], + "summary": "Governance epic.", + "children": [(486, "[Feature] Alignment")], + }, + ), + ] + + rendered = module.render_cache_markdown( + repo_full_name="nold-ai/specfact-cli-modules", + issues=issues, + generated_at="2026-04-09T08:30:00Z", + fingerprint="abc123", + ) + + assert "# GitHub Hierarchy Cache" in rendered + assert "## Epics" in rendered + assert "## Features" in rendered + assert rendered.index("### #485") < rendered.index("### #486") + assert "- Parent: none" in rendered + assert "- Parent: #485 [Epic] Governance" in rendered + assert "- Labels: Epic, openspec" in rendered + assert "- Labels: Feature, openspec" in rendered + + +def test_sync_cache_skips_write_when_fingerprint_is_unchanged(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """sync_cache should not rewrite output when the fingerprint matches state.""" + module = _load_script_module() + + output_path = tmp_path / "GITHUB_HIERARCHY_CACHE.md" + state_path = tmp_path / ".github_hierarchy_cache_state.json" + output_path.write_text("unchanged cache\n", encoding="utf-8") + state_path.write_text('{"fingerprint":"same"}', encoding="utf-8") + + issues = [ + _make_issue( + module, + number=485, + title="[Epic] Governance", + issue_type="Epic", + options={ + "labels": ["Epic"], + "summary": "Governance epic.", + }, + ) + ] + + def _fake_fetch(*, repo_owner: str, repo_name: str, fingerprint_only: bool) -> list[Any]: + assert repo_owner == "nold-ai" + assert repo_name == "specfact-cli-modules" + assert fingerprint_only is False + return issues + + def _same_fingerprint(_: list[Any]) -> str: + return "same" + + monkeypatch.setattr(module, "fetch_hierarchy_issues", _fake_fetch) + monkeypatch.setattr(module, "compute_hierarchy_fingerprint", _same_fingerprint) + + result = module.sync_cache( + repo_owner="nold-ai", + repo_name="specfact-cli-modules", + output_path=output_path, + state_path=state_path, + ) + + assert result.changed is False + assert result.issue_count == 1 + assert output_path.read_text(encoding="utf-8") == "unchanged cache\n" + + +def test_sync_cache_force_rewrites_when_fingerprint_unchanged(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """sync_cache with force=True must rewrite output even when fingerprint matches state.""" + module = _load_script_module() + + output_path = tmp_path / "GITHUB_HIERARCHY_CACHE.md" + state_path = tmp_path / ".github_hierarchy_cache_state.json" + output_path.write_text("stale cache\n", encoding="utf-8") + state_path.write_text('{"fingerprint":"same"}', encoding="utf-8") + + issues = [ + _make_issue( + module, + number=485, + title="[Epic] Governance", + issue_type="Epic", + options={ + "labels": ["Epic"], + "summary": "Governance epic.", + }, + ) + ] + + def _fake_fetch(*, repo_owner: str, repo_name: str, fingerprint_only: bool) -> list[Any]: + assert repo_owner == "nold-ai" + assert repo_name == "specfact-cli-modules" + assert fingerprint_only is False + return issues + + monkeypatch.setattr(module, "fetch_hierarchy_issues", _fake_fetch) + monkeypatch.setattr(module, "compute_hierarchy_fingerprint", lambda _: "same") + + result = module.sync_cache( + repo_owner="nold-ai", + repo_name="specfact-cli-modules", + output_path=output_path, + state_path=state_path, + force=True, + ) + + assert result.changed is True + assert "# GitHub Hierarchy Cache" in output_path.read_text(encoding="utf-8") + + +def test_sync_cache_propagates_graphql_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """RuntimeError from GitHub GraphQL should surface to callers.""" + module = _load_script_module() + + def _boom(_query: str, *, repo_owner: str, repo_name: str, **_kwargs: Any) -> Any: + assert repo_owner == "nold-ai" + assert repo_name == "specfact-cli-modules" + raise RuntimeError("graphql failed") + + monkeypatch.setattr(module, "_run_graphql_query", _boom) + + with pytest.raises(RuntimeError, match="graphql failed"): + module.sync_cache( + repo_owner="nold-ai", + repo_name="specfact-cli-modules", + output_path=tmp_path / "out.md", + state_path=tmp_path / "state.json", + ) + + +def test_sync_cache_malformed_state_regenerates_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Invalid state JSON is treated as missing state and triggers a full sync.""" + module = _load_script_module() + + output_path = tmp_path / "GITHUB_HIERARCHY_CACHE.md" + state_path = tmp_path / ".github_hierarchy_cache_state.json" + state_path.write_text("{not-json", encoding="utf-8") + + issues = [ + _make_issue( + module, + number=485, + title="[Epic] Governance", + issue_type="Epic", + options={ + "labels": ["Epic"], + "summary": "Governance epic.", + }, + ) + ] + + fetch_calls = 0 + + def _fake_fetch(*, repo_owner: str, repo_name: str, fingerprint_only: bool) -> list[Any]: + nonlocal fetch_calls + fetch_calls += 1 + assert repo_owner == "nold-ai" + assert repo_name == "specfact-cli-modules" + assert fingerprint_only is False + return issues + + monkeypatch.setattr(module, "fetch_hierarchy_issues", _fake_fetch) + + result = module.sync_cache( + repo_owner="nold-ai", + repo_name="specfact-cli-modules", + output_path=output_path, + state_path=state_path, + ) + + assert fetch_calls == 1 + assert result.changed is True + assert "# GitHub Hierarchy Cache" in output_path.read_text(encoding="utf-8") + + +def test_default_repo_name_falls_back_when_git_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + """If ``git`` is missing, DEFAULT_REPO_NAME must use the checkout directory fallback.""" + _load_script_module.cache_clear() + sys.modules.pop("sync_github_hierarchy_cache", None) + + def _no_git(*_args: Any, **_kwargs: Any) -> Any: + raise FileNotFoundError("git not found") + + monkeypatch.setattr(subprocess, "run", _no_git) + module = _load_script_module() + script_path = Path(__file__).resolve().parents[3] / "scripts" / "sync_github_hierarchy_cache.py" + expected_fallback = script_path.resolve().parents[1].name + assert expected_fallback == module.DEFAULT_REPO_NAME + + _load_script_module.cache_clear() + sys.modules.pop("sync_github_hierarchy_cache", None)