From bd07b05308070ab59d5eac81128271170649f625 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Thu, 9 Apr 2026 22:28:39 +0200 Subject: [PATCH 1/4] Add git hierarchy scripts and change proposals --- AGENTS.md | 2 + openspec/CHANGE_ORDER.md | 2 + .../.openspec.yaml | 2 + .../CHANGE_VALIDATION.md | 12 + .../TDD_EVIDENCE.md | 28 + .../design.md | 58 ++ .../proposal.md | 31 ++ .../specs/backlog-sync/spec.md | 48 ++ .../specs/github-hierarchy-cache/spec.md | 24 + .../tasks.md | 20 + .../.openspec.yaml | 2 + .../CHANGE_VALIDATION.md | 12 + .../design.md | 78 +++ .../proposal.md | 39 ++ .../specs/backlog-add/spec.md | 9 + .../specs/backlog-sync/spec.md | 9 + .../runtime-artifact-write-safety/spec.md | 23 + .../tasks.md | 26 + openspec/config.yaml | 2 + scripts/sync_github_hierarchy_cache.py | 502 ++++++++++++++++++ .../test_sync_github_hierarchy_cache.py | 215 ++++++++ 21 files changed, 1144 insertions(+) create mode 100644 openspec/changes/governance-03-github-hierarchy-cache/.openspec.yaml create mode 100644 openspec/changes/governance-03-github-hierarchy-cache/CHANGE_VALIDATION.md create mode 100644 openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md create mode 100644 openspec/changes/governance-03-github-hierarchy-cache/design.md create mode 100644 openspec/changes/governance-03-github-hierarchy-cache/proposal.md create mode 100644 openspec/changes/governance-03-github-hierarchy-cache/specs/backlog-sync/spec.md create mode 100644 openspec/changes/governance-03-github-hierarchy-cache/specs/github-hierarchy-cache/spec.md create mode 100644 openspec/changes/governance-03-github-hierarchy-cache/tasks.md create mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/.openspec.yaml create mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/CHANGE_VALIDATION.md create mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/design.md create mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/proposal.md create mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-add/spec.md create mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-sync/spec.md create mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/runtime-artifact-write-safety/spec.md create mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/tasks.md create mode 100644 scripts/sync_github_hierarchy_cache.py create mode 100644 tests/unit/scripts/test_sync_github_hierarchy_cache.py 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..d3bc5f6a 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 change `specfact-cli/governance-02-github-hierarchy-cache` | | 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..0d377f2b --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md @@ -0,0 +1,28 @@ +# 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).` 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..7a13f80a --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/design.md @@ -0,0 +1,58 @@ +## 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..c39eb29f --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/proposal.md @@ -0,0 +1,31 @@ +## 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 central markdown inventory under `openspec/` with issue number, title, brief summary, labels, and hierarchy relationships. +- Add a lightweight fingerprint/state check so the sync exits 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 Change: `specfact-cli/governance-02-github-hierarchy-cache` 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..d546476f --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/specs/backlog-sync/spec.md @@ -0,0 +1,48 @@ +## 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/ADO work items +- **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..ef0c6556 --- /dev/null +++ b/openspec/changes/governance-03-github-hierarchy-cache/specs/github-hierarchy-cache/spec.md @@ -0,0 +1,24 @@ +## 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..12cc4407 --- /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`. +- [ ] 4.2 Run the required repo quality gates for the touched scope, including code review JSON refresh if stale. diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/.openspec.yaml b/openspec/changes/project-runtime-01-safe-artifact-write-policy/.openspec.yaml new file mode 100644 index 00000000..98d7681c --- /dev/null +++ b/openspec/changes/project-runtime-01-safe-artifact-write-policy/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-09 diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/CHANGE_VALIDATION.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/CHANGE_VALIDATION.md new file mode 100644 index 00000000..e5256c41 --- /dev/null +++ b/openspec/changes/project-runtime-01-safe-artifact-write-policy/CHANGE_VALIDATION.md @@ -0,0 +1,12 @@ +# CHANGE VALIDATION + +- **Change**: `project-runtime-01-safe-artifact-write-policy` +- **Date**: 2026-04-09 +- **Method**: `openspec validate project-runtime-01-safe-artifact-write-policy --strict` +- **Result**: PASS + +## Notes + +- Proposal, design, specs, and tasks are present and parse successfully. +- This change is the modules-side runtime adoption companion to the core policy change `profile-04-safe-project-artifact-writes`. +- GitHub tracking is synced to issue [#177](https://github.com/nold-ai/specfact-cli-modules/issues/177) under parent feature [#161](https://github.com/nold-ai/specfact-cli-modules/issues/161), with bug context linked back to `specfact-cli#487`. diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/design.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/design.md new file mode 100644 index 00000000..b12c8065 --- /dev/null +++ b/openspec/changes/project-runtime-01-safe-artifact-write-policy/design.md @@ -0,0 +1,78 @@ +## Context + +Many bundle commands in `specfact-cli-modules` write directly into user repositories using local `write_text` or bespoke write logic. Even where behavior is currently harmless, the repo lacks a consistent contract for ownership, merge strategy, preview, and recovery when bundle commands materialize artifacts. If only core init/setup adopts safer semantics, runtime package commands can still recreate the same trust failure elsewhere. + +The paired core change defines the authoritative policy language. This modules-side design focuses on adopting that policy in runtime packages without introducing a competing abstraction. + +## Goals / Non-Goals + +**Goals:** +- Reuse the core safe-write contract from `specfact-cli` in bundle runtime code. +- Standardize how bundle commands declare file ownership and write intent. +- Add adoption for first-runner package commands that materialize or mutate local project artifacts. +- Add tests ensuring bundle commands preserve unrelated user content when they touch partially owned artifacts. + +**Non-Goals:** +- Refactor every single `write_text` call in the repo regardless of target ownership. +- Move ownership policy definition into modules; core remains authoritative. +- Turn all bundle writers into interactive review workflows in this change. + +## Decisions + +### 1. Runtime packages will depend on the core safe-write helper instead of creating a duplicate modules-side helper + +Bundle code already imports `specfact_cli` surfaces where needed. This change will reuse the core helper and ownership model so both repos speak the same semantics. + +Rationale: +- One contract, one enforcement surface. +- Avoids drift between “core-safe” and “runtime-safe” behavior. + +Alternative considered: +- Create a modules-local wrapper and later reconcile. Rejected because it duplicates the core design immediately. + +### 2. Adoption scope will prioritize commands that write into user repos, not internal generated temp artifacts + +The first slice should cover commands that write persistent user-facing artifacts in target repositories. Internal temp files, caches, or package-build outputs are not the same risk class. + +Rationale: +- Keeps scope manageable while addressing the highest-risk trust boundary. + +### 3. Runtime commands must declare artifact ownership at the call site + +Each adopting command will explicitly state whether the target artifact is: +- fully owned by SpecFact +- partially owned by SpecFact-managed keys/blocks +- create-only + +Rationale: +- Bundle authors know command intent best. +- CI can verify helper usage but needs call-site ownership declarations to be meaningful. + +### 4. Modules CI should add behavior fixtures rather than a second independent static scanner + +The static “unsafe write” rule belongs in core because it defines the helper boundary. Modules-side CI will focus on adoption tests for selected commands and package flows. + +Rationale: +- Keeps enforcement non-duplicative. +- Core owns the API and static contract; modules own runtime usage proof. + +## Risks / Trade-offs + +- `[Risk]` Bundle packages may need a raised `core_compatibility` floor to consume the new helper. → Mitigation: stage versioning and compatibility updates as part of adoption tasks. +- `[Risk]` Adoption can stall if too many commands are targeted at once. → Mitigation: identify first adopters in proposal/tasks and defer remaining paths with explicit follow-up inventory. +- `[Risk]` Some runtime artifact types may not support structured merge yet. → Mitigation: use create-only or explicit-replace with backup semantics until a sanctioned merge strategy exists. + +## Migration Plan + +1. Wait for the core helper contract to land or stabilize in the paired core change. +2. Update selected runtime package commands to call the helper with ownership metadata. +3. Add tests proving preservation/backup/conflict behavior for those package flows. +4. Document adoption guidance for future bundle authors. + +Rollback strategy: +- If a specific runtime adoption proves unstable, the command should fail-safe or skip existing-file mutation instead of restoring raw overwrite behavior. + +## Open Questions + +- Which bundle commands should be first adopters in this change versus a later follow-up inventory? +- Should bundle manifests or docs carry artifact ownership metadata, or is code-level declaration sufficient for now? diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/proposal.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/proposal.md new file mode 100644 index 00000000..3aceb56a --- /dev/null +++ b/openspec/changes/project-runtime-01-safe-artifact-write-policy/proposal.md @@ -0,0 +1,39 @@ +# Change: Runtime Adoption Of Safe Artifact Write Policy + +## Why + +Core can define safer init/setup behavior, but the broader trust problem remains if bundle runtime commands in `specfact-cli-modules` still overwrite or rewrite user-project artifacts ad hoc. To make issue [specfact-cli#487](https://github.com/nold-ai/specfact-cli/issues/487) impossible by design rather than by one-off fix, runtime package commands that write local artifacts need to adopt the same safe-write contract and conflict semantics. + +## What Changes + +- **NEW**: Introduce a runtime-facing artifact write adapter/utility layer for bundle packages that classifies local writes as create-only, mergeable, append-only, or explicit-replace. +- **NEW**: Standardize backup, recovery metadata, and dry-run/preview surfaces for bundle commands that emit or mutate project artifacts. +- **NEW**: Define adoption guidance so bundle authors declare ownership boundaries for every local artifact path they write. +- **EXTEND**: Update initial adopter package commands in `specfact-project`, `specfact-spec`, and other bundle flows that currently write directly into target repos to use the safe-write utility instead of raw overwrite calls. +- **EXTEND**: Bundle docs and prompts to state the new preservation guarantees and when explicit force/replace semantics are required. + +## Capabilities + +### New Capabilities +- `runtime-artifact-write-safety`: Shared runtime safety contract for bundle commands that create or mutate project artifacts in user repositories. + +### Modified Capabilities +- `backlog-add`: local export helpers and related artifact generation must use the runtime safe-write contract when updating project files. +- `backlog-sync`: runtime sync/export flows must avoid silent local overwrites and surface preview-or-conflict behavior consistently. + +## Impact + +- Affected code: bundle runtime helpers in `packages/specfact-project/`, `packages/specfact-spec/`, and any command packages that currently call `write_text` directly against user project files. +- Affected docs: relevant bundle docs on modules.specfact.io covering setup, sync/export, and local artifact generation. +- Integration points: depends on the paired core change `specfact-cli/openspec/changes/profile-04-safe-project-artifact-writes` for the authoritative policy and terminology. +- Dependencies: may require a new modules-side feature issue if no existing feature cleanly groups cross-package local-write safety work. + +## Source Tracking + +- **GitHub Issue**: #177 +- **Issue URL**: https://github.com/nold-ai/specfact-cli-modules/issues/177 +- **Repository**: nold-ai/specfact-cli-modules +- **Last Synced Status**: open +- **Parent Feature**: #161 +- **Parent Feature URL**: https://github.com/nold-ai/specfact-cli-modules/issues/161 +- **Related Core Change**: specfact-cli#487 bug context and paired core change `profile-04-safe-project-artifact-writes` diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-add/spec.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-add/spec.md new file mode 100644 index 00000000..2ffc8551 --- /dev/null +++ b/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-add/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: Backlog add local artifact helpers SHALL preserve user-managed content +Any `specfact backlog add` helper flow that writes local project artifacts SHALL use the runtime safe-write contract and preserve unrelated user-managed content. + +#### Scenario: Existing local config is not silently replaced +- **WHEN** a backlog-add related local helper targets an existing user-project artifact +- **THEN** the helper SHALL skip, merge, or require explicit replacement according to declared ownership +- **AND** SHALL NOT silently overwrite the existing file diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-sync/spec.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-sync/spec.md new file mode 100644 index 00000000..291a12ff --- /dev/null +++ b/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-sync/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: Backlog sync local export paths SHALL avoid silent overwrite +Any `specfact backlog sync` local export or artifact materialization path SHALL avoid silent overwrites of existing user-project artifacts. + +#### Scenario: Existing export target produces conflict or safe merge +- **WHEN** backlog sync would write to a local artifact path that already exists +- **THEN** the command SHALL use the runtime safe-write contract to merge, skip, or require explicit replacement +- **AND** SHALL surface the chosen behavior in command output diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/runtime-artifact-write-safety/spec.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/runtime-artifact-write-safety/spec.md new file mode 100644 index 00000000..61a51ffb --- /dev/null +++ b/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/runtime-artifact-write-safety/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Bundle runtime commands SHALL use the core safe-write contract for user-project artifacts +Bundle commands that create or mutate persistent artifacts inside a user repository SHALL call the core safe-write contract instead of performing ad hoc overwrite logic. + +#### Scenario: Runtime command writes owned artifact through safe-write helper +- **WHEN** a bundle command materializes or updates a persistent artifact in the user's repository +- **THEN** it SHALL call the core safe-write helper with declared ownership metadata +- **AND** SHALL NOT write the artifact through a raw overwrite path + +#### Scenario: Unsupported merge falls back to explicit safe failure +- **WHEN** a bundle command targets an existing artifact whose format or ownership cannot be reconciled safely +- **THEN** the command SHALL fail with actionable guidance or require explicit replacement semantics +- **AND** SHALL leave the existing artifact unchanged by default + +### Requirement: Runtime adoption SHALL be regression-tested against existing user content +Bundle commands that adopt the safe-write contract SHALL have regression tests proving that unrelated user content survives supported mutations. + +#### Scenario: Partially owned runtime artifact preserves unrelated user content +- **WHEN** a regression fixture contains an existing user-managed artifact with additional custom content +- **AND** the bundle command updates only a SpecFact-managed section +- **THEN** the custom content SHALL remain intact +- **AND** only the managed section SHALL change diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/tasks.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/tasks.md new file mode 100644 index 00000000..6b94603c --- /dev/null +++ b/openspec/changes/project-runtime-01-safe-artifact-write-policy/tasks.md @@ -0,0 +1,26 @@ +## 1. Branch and paired-change setup + +- [ ] 1.1 Create `bugfix/project-runtime-01-safe-artifact-write-policy` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. +- [ ] 1.2 Confirm the paired core change `profile-04-safe-project-artifact-writes` is implemented or available for integration and document the minimum required core compatibility. +- [ ] 1.3 If a modules-side tracking issue is created, link it back to `specfact-cli#487` and the paired core issue/change for traceability. + +## 2. Runtime tests and failing evidence + +- [ ] 2.1 Inventory first-adopter bundle commands that write persistent user-project artifacts and select the initial runtime adoption scope. +- [ ] 2.2 Write tests from the new scenarios proving bundle commands preserve unrelated user content, fail safely on unsupported merges, and surface explicit replacement behavior when required. +- [ ] 2.3 Run the targeted runtime tests before implementation and record failing commands/timestamps in `openspec/changes/project-runtime-01-safe-artifact-write-policy/TDD_EVIDENCE.md`. + +## 3. Runtime safe-write adoption + +- [ ] 3.1 Integrate the core safe-write helper into the selected runtime package commands with explicit ownership metadata and required contract decorators on any new public APIs. +- [ ] 3.2 Update package code paths for the first adopters so they no longer perform raw overwrite behavior against user-project artifacts. +- [ ] 3.3 Adjust package metadata, compatibility declarations, and any supporting docs/prompts required by the new runtime dependency on the core safe-write contract. + +## 4. Verification, docs, and release hygiene + +- [ ] 4.1 Re-run the targeted runtime tests and any broader affected package coverage, then record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 4.2 Update affected modules docs to explain preservation guarantees and explicit replacement semantics for adopted commands. +- [ ] 4.3 Run quality gates: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run contract-test`, and the relevant `smart-test`/`test` coverage for changed packages. +- [ ] 4.4 Run module signature verification, bump package versions where required, re-sign changed manifests if needed, and verify registry consistency. +- [ ] 4.5 Ensure `.specfact/code-review.json` is fresh, remediate all findings, and record the final review command/timestamp in `TDD_EVIDENCE.md` or PR notes. +- [ ] 4.6 Open the modules PR to `dev`, cross-link the paired core PR, and note any deferred runtime adoption paths as follow-up issues if the initial scope is intentionally limited. diff --git a/openspec/config.yaml b/openspec/config.yaml index cfbf0599..c51924cb 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -54,6 +54,7 @@ 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; this cache is ephemeral local state and MUST NOT be committed. Rerun `python scripts/sync_github_hierarchy_cache.py` when the cache is missing or stale. specs: - Use Given/When/Then for scenarios; tie scenarios to tests under `tests/` for bundle or registry behavior. @@ -67,6 +68,7 @@ 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. - 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 100644 index 00000000..fe589769 --- /dev/null +++ b/scripts/sync_github_hierarchy_cache.py @@ -0,0 +1,502 @@ +#!/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" +DEFAULT_REPO_NAME = Path(__file__).resolve().parents[1].name +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"}) +_SUMMARY_SKIP_LINES = {"why", "scope", "summary", "changes", "capabilities", "impact"} + +_FINGERPRINT_QUERY = """ +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 + issueType { name } + labels(first: 100) { nodes { name } } + parent { number title url } + subIssues(first: 100) { nodes { number title url } } + } + } + } +} +""" + +_DETAIL_QUERY = """ +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 + bodyText + issueType { name } + labels(first: 100) { nodes { name } } + parent { number title url } + subIssues(first: 100) { nodes { number title url } } + } + } + } +} +""" + + +@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 _child_links(subissue_nodes: list[Mapping[str, Any]]) -> list[IssueLink]: + """Extract sorted child issue links from GraphQL subissue nodes.""" + 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 + ] + 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}"]) + + completed = subprocess.run(command, check=False, capture_output=True, text=True) + 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 _has_non_blank_value( + repo_owner: str | None = None, + repo_name: str | None = None, + repo_full_name: str | None = None, + generated_at: str | None = None, + fingerprint: str | None = None, +) -> bool: + """Return whether the provided predicate value is non-blank.""" + for candidate in (repo_owner, repo_name, repo_full_name, generated_at, fingerprint): + if candidate is not None: + return _is_not_blank(candidate) + return False + + +@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 +@require(_has_non_blank_value, "repo_owner must not be blank") +@require(_has_non_blank_value, "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 = _FINGERPRINT_QUERY if fingerprint_only else _DETAIL_QUERY + 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 + } + + +@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 +@require(_has_non_blank_value, "repo_full_name must not be blank") +@require(_has_non_blank_value, "generated_at must not be blank") +@require(_has_non_blank_value, "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 +@require(_has_non_blank_value, "repo_owner must not be blank") +@require(_has_non_blank_value, "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.""" + fingerprint_issues = fetch_hierarchy_issues( + repo_owner=repo_owner, + repo_name=repo_name, + fingerprint_only=True, + ) + fingerprint = compute_hierarchy_fingerprint(fingerprint_issues) + state = _load_state(state_path) + + if not force and state.get("fingerprint") == fingerprint and output_path.exists(): + return SyncResult( + changed=False, + issue_count=len(fingerprint_issues), + fingerprint=fingerprint, + output_path=output_path, + ) + + detailed_issues = fetch_hierarchy_issues( + repo_owner=repo_owner, + repo_name=repo_name, + fingerprint_only=False, + ) + 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..6f31fcac --- /dev/null +++ b/tests/unit/scripts/test_sync_github_hierarchy_cache.py @@ -0,0 +1,215 @@ +"""Tests for scripts/sync_github_hierarchy_cache.py.""" + +from __future__ import annotations + +import importlib.util +import sys +from functools import lru_cache +from pathlib import Path +from typing import Any, TypedDict + + +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.""" + 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." + + +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_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: Any, 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 True + 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" From 112d33257989c8d0b2d3dc45a1dffefe3493d93f Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Thu, 9 Apr 2026 22:41:34 +0200 Subject: [PATCH 2/4] Fix review findings --- openspec/CHANGE_ORDER.md | 2 +- .../TDD_EVIDENCE.md | 16 ++ .../proposal.md | 5 +- .../specs/github-hierarchy-cache/spec.md | 7 +- scripts/sync_github_hierarchy_cache.py | 213 ++++++++++++------ .../test_sync_github_hierarchy_cache.py | 155 ++++++++++++- 6 files changed, 318 insertions(+), 80 deletions(-) diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index d3bc5f6a..adc7628f 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -75,7 +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 change `specfact-cli/governance-02-github-hierarchy-cache` | +| 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/TDD_EVIDENCE.md b/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md index 0d377f2b..2b206819 100644 --- a/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md +++ b/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md @@ -26,3 +26,19 @@ - `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 (no unresolved findings) + +**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. diff --git a/openspec/changes/governance-03-github-hierarchy-cache/proposal.md b/openspec/changes/governance-03-github-hierarchy-cache/proposal.md index c39eb29f..6c529b20 100644 --- a/openspec/changes/governance-03-github-hierarchy-cache/proposal.md +++ b/openspec/changes/governance-03-github-hierarchy-cache/proposal.md @@ -5,8 +5,7 @@ The modules repository now has its own Epic and Feature hierarchy, but contribut ## What Changes - Add a deterministic repo-local hierarchy cache generator for `specfact-cli-modules` Epic and Feature issues. -- Persist a central markdown inventory under `openspec/` with issue number, title, brief summary, labels, and hierarchy relationships. -- Add a lightweight fingerprint/state check so the sync exits quickly when Epic and Feature metadata has not changed. +- 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. @@ -28,4 +27,4 @@ The modules repository now has its own Epic and Feature hierarchy, but contribut - 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 Change: `specfact-cli/governance-02-github-hierarchy-cache` +- 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). 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 index ef0c6556..b2b92cff 100644 --- 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 @@ -1,23 +1,28 @@ -## ADDED Requirements +# 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 diff --git a/scripts/sync_github_hierarchy_cache.py b/scripts/sync_github_hierarchy_cache.py index fe589769..75b81f14 100644 --- a/scripts/sync_github_hierarchy_cache.py +++ b/scripts/sync_github_hierarchy_cache.py @@ -19,52 +19,81 @@ DEFAULT_REPO_OWNER = "nold-ai" -DEFAULT_REPO_NAME = Path(__file__).resolve().parents[1].name +_SCRIPT_DIR = Path(__file__).resolve().parent + + +@beartype +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).""" + completed = subprocess.run( + ["git", "-C", str(script_dir), "config", "--get", "remote.origin.url"], + check=False, + capture_output=True, + text=True, + ) + 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 -_FINGERPRINT_QUERY = """ -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 - issueType { name } - labels(first: 100) { nodes { name } } - parent { number title url } - subIssues(first: 100) { nodes { number title url } } - } - } - } -} -""" - -_DETAIL_QUERY = """ -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 { + +@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 - bodyText - issueType { name } - labels(first: 100) { nodes { name } } - parent { number title url } - subIssues(first: 100) { nodes { number title url } } - } - } - } -} -""" +{body_field} issueType {{ name }} + labels(first: 100) {{ nodes {{ name }} }} + parent {{ number title url }} + subIssues(first: 100) {{ nodes {{ number title url }} }} + }} + }} + }} +}} +""".strip() @dataclass(frozen=True) @@ -212,7 +241,22 @@ def _run_graphql_query(query: str, *, repo_owner: str, repo_name: str, after: st if after is not None: command.extend(["-F", f"after={after}"]) - completed = subprocess.run(command, check=False, capture_output=True, text=True) + 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") @@ -228,21 +272,6 @@ def _is_not_blank(value: str) -> bool: return bool(value.strip()) -@beartype -def _has_non_blank_value( - repo_owner: str | None = None, - repo_name: str | None = None, - repo_full_name: str | None = None, - generated_at: str | None = None, - fingerprint: str | None = None, -) -> bool: - """Return whether the provided predicate value is non-blank.""" - for candidate in (repo_owner, repo_name, repo_full_name, generated_at, fingerprint): - if candidate is not None: - return _is_not_blank(candidate) - return False - - @beartype def _all_supported_issue_types(result: list[HierarchyIssue]) -> bool: """Return whether every issue has a supported issue type.""" @@ -250,12 +279,22 @@ def _all_supported_issue_types(result: list[HierarchyIssue]) -> bool: @beartype -@require(_has_non_blank_value, "repo_owner must not be blank") -@require(_has_non_blank_value, "repo_name must not be blank") +def _require_repo_owner_for_fetch(*, repo_owner: str, repo_name: str, fingerprint_only: bool) -> bool: + return _is_not_blank(repo_owner) + + +@beartype +def _require_repo_name_for_fetch(*, repo_owner: str, repo_name: str, fingerprint_only: bool) -> bool: + 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 = _FINGERPRINT_QUERY if fingerprint_only else _DETAIL_QUERY + query = _build_hierarchy_issues_query(include_body=not fingerprint_only) issues: list[HierarchyIssue] = [] after: str | None = None @@ -305,7 +344,7 @@ def _group_issues_by_type(issues: list[HierarchyIssue]) -> dict[str, list[Hierar """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 + for issue_type in SUPPORTED_ISSUE_TYPES_ORDER } @@ -346,9 +385,30 @@ def _render_issue_section(*, title: str, issues: list[HierarchyIssue]) -> list[s @beartype -@require(_has_non_blank_value, "repo_full_name must not be blank") -@require(_has_non_blank_value, "generated_at must not be blank") -@require(_has_non_blank_value, "fingerprint must not be blank") +def _require_repo_full_name_for_render( + *, repo_full_name: str, issues: list[HierarchyIssue], generated_at: str, fingerprint: str +) -> bool: + 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: + 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: + 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, @@ -408,8 +468,22 @@ def _write_state( @beartype -@require(_has_non_blank_value, "repo_owner must not be blank") -@require(_has_non_blank_value, "repo_name must not be blank") +def _require_repo_owner_for_sync( + *, repo_owner: str, repo_name: str, output_path: Path, state_path: Path, force: bool = False +) -> bool: + 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: + 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, @@ -419,27 +493,22 @@ def sync_cache( force: bool = False, ) -> SyncResult: """Sync the local hierarchy cache from GitHub.""" - fingerprint_issues = fetch_hierarchy_issues( + state = _load_state(state_path) + detailed_issues = fetch_hierarchy_issues( repo_owner=repo_owner, repo_name=repo_name, - fingerprint_only=True, + fingerprint_only=False, ) - fingerprint = compute_hierarchy_fingerprint(fingerprint_issues) - state = _load_state(state_path) + 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(fingerprint_issues), + issue_count=len(detailed_issues), fingerprint=fingerprint, output_path=output_path, ) - detailed_issues = fetch_hierarchy_issues( - repo_owner=repo_owner, - repo_name=repo_name, - fingerprint_only=False, - ) 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( diff --git a/tests/unit/scripts/test_sync_github_hierarchy_cache.py b/tests/unit/scripts/test_sync_github_hierarchy_cache.py index 6f31fcac..c6f89028 100644 --- a/tests/unit/scripts/test_sync_github_hierarchy_cache.py +++ b/tests/unit/scripts/test_sync_github_hierarchy_cache.py @@ -3,11 +3,14 @@ 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.""" @@ -21,7 +24,7 @@ class IssueOptions(TypedDict, total=False): @lru_cache(maxsize=1) def _load_script_module() -> Any: - """Load scripts/sync_github_hierarchy_cache.py as a Python module.""" + """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: @@ -115,6 +118,44 @@ def test_extract_summary_skips_heading_only_lines() -> None: 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() @@ -169,7 +210,7 @@ def test_render_cache_markdown_groups_epics_and_features() -> None: assert "- Labels: Feature, openspec" in rendered -def test_sync_cache_skips_write_when_fingerprint_is_unchanged(monkeypatch: Any, tmp_path: Path) -> None: +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() @@ -194,7 +235,7 @@ def test_sync_cache_skips_write_when_fingerprint_is_unchanged(monkeypatch: Any, 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 True + assert fingerprint_only is False return issues def _same_fingerprint(_: list[Any]) -> str: @@ -213,3 +254,111 @@ def _same_fingerprint(_: list[Any]) -> str: 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") From f174ed0ab7feff12f17da85ef49d86092bf3cd64 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Thu, 9 Apr 2026 23:11:40 +0200 Subject: [PATCH 3/4] Fix review findings --- .../TDD_EVIDENCE.md | 7 +- .../design.md | 10 ++- .../proposal.md | 8 ++ .../specs/backlog-sync/spec.md | 3 +- .../specs/github-hierarchy-cache/spec.md | 10 +-- .../tasks.md | 2 +- .../.openspec.yaml | 2 - .../CHANGE_VALIDATION.md | 12 --- .../design.md | 78 ------------------- .../proposal.md | 39 ---------- .../specs/backlog-add/spec.md | 9 --- .../specs/backlog-sync/spec.md | 9 --- .../runtime-artifact-write-safety/spec.md | 23 ------ .../tasks.md | 26 ------- openspec/config.yaml | 14 +++- scripts/sync_github_hierarchy_cache.py | 41 +++++++--- .../test_sync_github_hierarchy_cache.py | 32 ++++++++ 17 files changed, 107 insertions(+), 218 deletions(-) delete mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/.openspec.yaml delete mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/CHANGE_VALIDATION.md delete mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/design.md delete mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/proposal.md delete mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-add/spec.md delete mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-sync/spec.md delete mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/runtime-artifact-write-safety/spec.md delete mode 100644 openspec/changes/project-runtime-01-safe-artifact-write-policy/tasks.md diff --git a/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md b/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md index 2b206819..bb274626 100644 --- a/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md +++ b/openspec/changes/governance-03-github-hierarchy-cache/TDD_EVIDENCE.md @@ -39,6 +39,11 @@ Full gate order (per `AGENTS.md` / `CLAUDE.md`). Run from repo root before merge 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 (no unresolved findings) +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 index 7a13f80a..eaac6a00 100644 --- a/openspec/changes/governance-03-github-hierarchy-cache/design.md +++ b/openspec/changes/governance-03-github-hierarchy-cache/design.md @@ -1,4 +1,4 @@ -## Context +# 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. @@ -7,12 +7,14 @@ This is a governance/runtime support change rather than a bundle feature. The ou ## 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. @@ -21,21 +23,27 @@ This is a governance/runtime support change rather than a bundle feature. The ou ## 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 diff --git a/openspec/changes/governance-03-github-hierarchy-cache/proposal.md b/openspec/changes/governance-03-github-hierarchy-cache/proposal.md index 6c529b20..82b928fa 100644 --- a/openspec/changes/governance-03-github-hierarchy-cache/proposal.md +++ b/openspec/changes/governance-03-github-hierarchy-cache/proposal.md @@ -1,3 +1,5 @@ +# 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. @@ -12,9 +14,11 @@ The modules repository now has its own Epic and Feature hierarchy, but contribut ## 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 @@ -28,3 +32,7 @@ The modules repository now has its own Epic and Feature hierarchy, but contribut - 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 index d546476f..cd330199 100644 --- 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 @@ -4,8 +4,9 @@ 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/ADO work items +- **THEN** OpenSpec changes are exported to GitHub issues - **AND** state mapping preserves status semantics #### Scenario: Bidirectional sync with cross-adapter 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 index b2b92cff..ada87055 100644 --- 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 @@ -1,27 +1,27 @@ # ADDED Requirements -### Requirement: Repository hierarchy cache sync +## 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 +### 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 +### 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 +## 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 +### 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 diff --git a/openspec/changes/governance-03-github-hierarchy-cache/tasks.md b/openspec/changes/governance-03-github-hierarchy-cache/tasks.md index 12cc4407..56c8bedf 100644 --- a/openspec/changes/governance-03-github-hierarchy-cache/tasks.md +++ b/openspec/changes/governance-03-github-hierarchy-cache/tasks.md @@ -17,4 +17,4 @@ ## 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`. -- [ ] 4.2 Run the required repo quality gates for the touched scope, including code review JSON refresh if stale. +- [x] 4.2 Run the required repo quality gates for the touched scope, including code review JSON refresh if stale. diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/.openspec.yaml b/openspec/changes/project-runtime-01-safe-artifact-write-policy/.openspec.yaml deleted file mode 100644 index 98d7681c..00000000 --- a/openspec/changes/project-runtime-01-safe-artifact-write-policy/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-09 diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/CHANGE_VALIDATION.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/CHANGE_VALIDATION.md deleted file mode 100644 index e5256c41..00000000 --- a/openspec/changes/project-runtime-01-safe-artifact-write-policy/CHANGE_VALIDATION.md +++ /dev/null @@ -1,12 +0,0 @@ -# CHANGE VALIDATION - -- **Change**: `project-runtime-01-safe-artifact-write-policy` -- **Date**: 2026-04-09 -- **Method**: `openspec validate project-runtime-01-safe-artifact-write-policy --strict` -- **Result**: PASS - -## Notes - -- Proposal, design, specs, and tasks are present and parse successfully. -- This change is the modules-side runtime adoption companion to the core policy change `profile-04-safe-project-artifact-writes`. -- GitHub tracking is synced to issue [#177](https://github.com/nold-ai/specfact-cli-modules/issues/177) under parent feature [#161](https://github.com/nold-ai/specfact-cli-modules/issues/161), with bug context linked back to `specfact-cli#487`. diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/design.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/design.md deleted file mode 100644 index b12c8065..00000000 --- a/openspec/changes/project-runtime-01-safe-artifact-write-policy/design.md +++ /dev/null @@ -1,78 +0,0 @@ -## Context - -Many bundle commands in `specfact-cli-modules` write directly into user repositories using local `write_text` or bespoke write logic. Even where behavior is currently harmless, the repo lacks a consistent contract for ownership, merge strategy, preview, and recovery when bundle commands materialize artifacts. If only core init/setup adopts safer semantics, runtime package commands can still recreate the same trust failure elsewhere. - -The paired core change defines the authoritative policy language. This modules-side design focuses on adopting that policy in runtime packages without introducing a competing abstraction. - -## Goals / Non-Goals - -**Goals:** -- Reuse the core safe-write contract from `specfact-cli` in bundle runtime code. -- Standardize how bundle commands declare file ownership and write intent. -- Add adoption for first-runner package commands that materialize or mutate local project artifacts. -- Add tests ensuring bundle commands preserve unrelated user content when they touch partially owned artifacts. - -**Non-Goals:** -- Refactor every single `write_text` call in the repo regardless of target ownership. -- Move ownership policy definition into modules; core remains authoritative. -- Turn all bundle writers into interactive review workflows in this change. - -## Decisions - -### 1. Runtime packages will depend on the core safe-write helper instead of creating a duplicate modules-side helper - -Bundle code already imports `specfact_cli` surfaces where needed. This change will reuse the core helper and ownership model so both repos speak the same semantics. - -Rationale: -- One contract, one enforcement surface. -- Avoids drift between “core-safe” and “runtime-safe” behavior. - -Alternative considered: -- Create a modules-local wrapper and later reconcile. Rejected because it duplicates the core design immediately. - -### 2. Adoption scope will prioritize commands that write into user repos, not internal generated temp artifacts - -The first slice should cover commands that write persistent user-facing artifacts in target repositories. Internal temp files, caches, or package-build outputs are not the same risk class. - -Rationale: -- Keeps scope manageable while addressing the highest-risk trust boundary. - -### 3. Runtime commands must declare artifact ownership at the call site - -Each adopting command will explicitly state whether the target artifact is: -- fully owned by SpecFact -- partially owned by SpecFact-managed keys/blocks -- create-only - -Rationale: -- Bundle authors know command intent best. -- CI can verify helper usage but needs call-site ownership declarations to be meaningful. - -### 4. Modules CI should add behavior fixtures rather than a second independent static scanner - -The static “unsafe write” rule belongs in core because it defines the helper boundary. Modules-side CI will focus on adoption tests for selected commands and package flows. - -Rationale: -- Keeps enforcement non-duplicative. -- Core owns the API and static contract; modules own runtime usage proof. - -## Risks / Trade-offs - -- `[Risk]` Bundle packages may need a raised `core_compatibility` floor to consume the new helper. → Mitigation: stage versioning and compatibility updates as part of adoption tasks. -- `[Risk]` Adoption can stall if too many commands are targeted at once. → Mitigation: identify first adopters in proposal/tasks and defer remaining paths with explicit follow-up inventory. -- `[Risk]` Some runtime artifact types may not support structured merge yet. → Mitigation: use create-only or explicit-replace with backup semantics until a sanctioned merge strategy exists. - -## Migration Plan - -1. Wait for the core helper contract to land or stabilize in the paired core change. -2. Update selected runtime package commands to call the helper with ownership metadata. -3. Add tests proving preservation/backup/conflict behavior for those package flows. -4. Document adoption guidance for future bundle authors. - -Rollback strategy: -- If a specific runtime adoption proves unstable, the command should fail-safe or skip existing-file mutation instead of restoring raw overwrite behavior. - -## Open Questions - -- Which bundle commands should be first adopters in this change versus a later follow-up inventory? -- Should bundle manifests or docs carry artifact ownership metadata, or is code-level declaration sufficient for now? diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/proposal.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/proposal.md deleted file mode 100644 index 3aceb56a..00000000 --- a/openspec/changes/project-runtime-01-safe-artifact-write-policy/proposal.md +++ /dev/null @@ -1,39 +0,0 @@ -# Change: Runtime Adoption Of Safe Artifact Write Policy - -## Why - -Core can define safer init/setup behavior, but the broader trust problem remains if bundle runtime commands in `specfact-cli-modules` still overwrite or rewrite user-project artifacts ad hoc. To make issue [specfact-cli#487](https://github.com/nold-ai/specfact-cli/issues/487) impossible by design rather than by one-off fix, runtime package commands that write local artifacts need to adopt the same safe-write contract and conflict semantics. - -## What Changes - -- **NEW**: Introduce a runtime-facing artifact write adapter/utility layer for bundle packages that classifies local writes as create-only, mergeable, append-only, or explicit-replace. -- **NEW**: Standardize backup, recovery metadata, and dry-run/preview surfaces for bundle commands that emit or mutate project artifacts. -- **NEW**: Define adoption guidance so bundle authors declare ownership boundaries for every local artifact path they write. -- **EXTEND**: Update initial adopter package commands in `specfact-project`, `specfact-spec`, and other bundle flows that currently write directly into target repos to use the safe-write utility instead of raw overwrite calls. -- **EXTEND**: Bundle docs and prompts to state the new preservation guarantees and when explicit force/replace semantics are required. - -## Capabilities - -### New Capabilities -- `runtime-artifact-write-safety`: Shared runtime safety contract for bundle commands that create or mutate project artifacts in user repositories. - -### Modified Capabilities -- `backlog-add`: local export helpers and related artifact generation must use the runtime safe-write contract when updating project files. -- `backlog-sync`: runtime sync/export flows must avoid silent local overwrites and surface preview-or-conflict behavior consistently. - -## Impact - -- Affected code: bundle runtime helpers in `packages/specfact-project/`, `packages/specfact-spec/`, and any command packages that currently call `write_text` directly against user project files. -- Affected docs: relevant bundle docs on modules.specfact.io covering setup, sync/export, and local artifact generation. -- Integration points: depends on the paired core change `specfact-cli/openspec/changes/profile-04-safe-project-artifact-writes` for the authoritative policy and terminology. -- Dependencies: may require a new modules-side feature issue if no existing feature cleanly groups cross-package local-write safety work. - -## Source Tracking - -- **GitHub Issue**: #177 -- **Issue URL**: https://github.com/nold-ai/specfact-cli-modules/issues/177 -- **Repository**: nold-ai/specfact-cli-modules -- **Last Synced Status**: open -- **Parent Feature**: #161 -- **Parent Feature URL**: https://github.com/nold-ai/specfact-cli-modules/issues/161 -- **Related Core Change**: specfact-cli#487 bug context and paired core change `profile-04-safe-project-artifact-writes` diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-add/spec.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-add/spec.md deleted file mode 100644 index 2ffc8551..00000000 --- a/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-add/spec.md +++ /dev/null @@ -1,9 +0,0 @@ -## ADDED Requirements - -### Requirement: Backlog add local artifact helpers SHALL preserve user-managed content -Any `specfact backlog add` helper flow that writes local project artifacts SHALL use the runtime safe-write contract and preserve unrelated user-managed content. - -#### Scenario: Existing local config is not silently replaced -- **WHEN** a backlog-add related local helper targets an existing user-project artifact -- **THEN** the helper SHALL skip, merge, or require explicit replacement according to declared ownership -- **AND** SHALL NOT silently overwrite the existing file diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-sync/spec.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-sync/spec.md deleted file mode 100644 index 291a12ff..00000000 --- a/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/backlog-sync/spec.md +++ /dev/null @@ -1,9 +0,0 @@ -## ADDED Requirements - -### Requirement: Backlog sync local export paths SHALL avoid silent overwrite -Any `specfact backlog sync` local export or artifact materialization path SHALL avoid silent overwrites of existing user-project artifacts. - -#### Scenario: Existing export target produces conflict or safe merge -- **WHEN** backlog sync would write to a local artifact path that already exists -- **THEN** the command SHALL use the runtime safe-write contract to merge, skip, or require explicit replacement -- **AND** SHALL surface the chosen behavior in command output diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/runtime-artifact-write-safety/spec.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/runtime-artifact-write-safety/spec.md deleted file mode 100644 index 61a51ffb..00000000 --- a/openspec/changes/project-runtime-01-safe-artifact-write-policy/specs/runtime-artifact-write-safety/spec.md +++ /dev/null @@ -1,23 +0,0 @@ -## ADDED Requirements - -### Requirement: Bundle runtime commands SHALL use the core safe-write contract for user-project artifacts -Bundle commands that create or mutate persistent artifacts inside a user repository SHALL call the core safe-write contract instead of performing ad hoc overwrite logic. - -#### Scenario: Runtime command writes owned artifact through safe-write helper -- **WHEN** a bundle command materializes or updates a persistent artifact in the user's repository -- **THEN** it SHALL call the core safe-write helper with declared ownership metadata -- **AND** SHALL NOT write the artifact through a raw overwrite path - -#### Scenario: Unsupported merge falls back to explicit safe failure -- **WHEN** a bundle command targets an existing artifact whose format or ownership cannot be reconciled safely -- **THEN** the command SHALL fail with actionable guidance or require explicit replacement semantics -- **AND** SHALL leave the existing artifact unchanged by default - -### Requirement: Runtime adoption SHALL be regression-tested against existing user content -Bundle commands that adopt the safe-write contract SHALL have regression tests proving that unrelated user content survives supported mutations. - -#### Scenario: Partially owned runtime artifact preserves unrelated user content -- **WHEN** a regression fixture contains an existing user-managed artifact with additional custom content -- **AND** the bundle command updates only a SpecFact-managed section -- **THEN** the custom content SHALL remain intact -- **AND** only the managed section SHALL change diff --git a/openspec/changes/project-runtime-01-safe-artifact-write-policy/tasks.md b/openspec/changes/project-runtime-01-safe-artifact-write-policy/tasks.md deleted file mode 100644 index 6b94603c..00000000 --- a/openspec/changes/project-runtime-01-safe-artifact-write-policy/tasks.md +++ /dev/null @@ -1,26 +0,0 @@ -## 1. Branch and paired-change setup - -- [ ] 1.1 Create `bugfix/project-runtime-01-safe-artifact-write-policy` in a dedicated worktree from `origin/dev` and bootstrap the worktree environment. -- [ ] 1.2 Confirm the paired core change `profile-04-safe-project-artifact-writes` is implemented or available for integration and document the minimum required core compatibility. -- [ ] 1.3 If a modules-side tracking issue is created, link it back to `specfact-cli#487` and the paired core issue/change for traceability. - -## 2. Runtime tests and failing evidence - -- [ ] 2.1 Inventory first-adopter bundle commands that write persistent user-project artifacts and select the initial runtime adoption scope. -- [ ] 2.2 Write tests from the new scenarios proving bundle commands preserve unrelated user content, fail safely on unsupported merges, and surface explicit replacement behavior when required. -- [ ] 2.3 Run the targeted runtime tests before implementation and record failing commands/timestamps in `openspec/changes/project-runtime-01-safe-artifact-write-policy/TDD_EVIDENCE.md`. - -## 3. Runtime safe-write adoption - -- [ ] 3.1 Integrate the core safe-write helper into the selected runtime package commands with explicit ownership metadata and required contract decorators on any new public APIs. -- [ ] 3.2 Update package code paths for the first adopters so they no longer perform raw overwrite behavior against user-project artifacts. -- [ ] 3.3 Adjust package metadata, compatibility declarations, and any supporting docs/prompts required by the new runtime dependency on the core safe-write contract. - -## 4. Verification, docs, and release hygiene - -- [ ] 4.1 Re-run the targeted runtime tests and any broader affected package coverage, then record passing evidence in `TDD_EVIDENCE.md`. -- [ ] 4.2 Update affected modules docs to explain preservation guarantees and explicit replacement semantics for adopted commands. -- [ ] 4.3 Run quality gates: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run contract-test`, and the relevant `smart-test`/`test` coverage for changed packages. -- [ ] 4.4 Run module signature verification, bump package versions where required, re-sign changed manifests if needed, and verify registry consistency. -- [ ] 4.5 Ensure `.specfact/code-review.json` is fresh, remediate all findings, and record the final review command/timestamp in `TDD_EVIDENCE.md` or PR notes. -- [ ] 4.6 Open the modules PR to `dev`, cross-link the paired core PR, and note any deferred runtime adoption paths as follow-up issues if the initial scope is intentionally limited. diff --git a/openspec/config.yaml b/openspec/config.yaml index c51924cb..b617b307 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -54,7 +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; this cache is ephemeral local state and MUST NOT be committed. Rerun `python scripts/sync_github_hierarchy_cache.py` when the cache is missing or stale. + - >- + 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. @@ -68,7 +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. + - >- + 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 index 75b81f14..5e4031d5 100644 --- a/scripts/sync_github_hierarchy_cache.py +++ b/scripts/sync_github_hierarchy_cache.py @@ -23,6 +23,10 @@ @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() @@ -50,12 +54,15 @@ def parse_repo_name_from_remote_url(url: str) -> str | None: @beartype def _default_repo_name_from_git(script_dir: Path) -> str | None: """Resolve the GitHub repository name from ``origin`` (works in worktrees).""" - completed = subprocess.run( - ["git", "-C", str(script_dir), "config", "--get", "remote.origin.url"], - check=False, - capture_output=True, - text=True, - ) + 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) @@ -88,7 +95,7 @@ def _build_hierarchy_issues_query(*, include_body: bool) -> str: {body_field} issueType {{ name }} labels(first: 100) {{ nodes {{ name }} }} parent {{ number title url }} - subIssues(first: 100) {{ nodes {{ number title url }} }} + subIssues(first: 100) {{ nodes {{ number title url issueType {{ name }} }} }} }} }} }} @@ -190,13 +197,22 @@ def _label_names(label_nodes: list[Mapping[str, Any]]) -> list[str]: 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.""" + """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 + 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 @@ -280,11 +296,13 @@ def _all_supported_issue_types(result: list[HierarchyIssue]) -> bool: @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) @@ -388,6 +406,7 @@ def _render_issue_section(*, title: str, issues: list[HierarchyIssue]) -> list[s 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) @@ -395,6 +414,7 @@ def _require_repo_full_name_for_render( 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) @@ -402,6 +422,7 @@ def _require_generated_at_for_render( 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) @@ -471,6 +492,7 @@ def _write_state( 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) @@ -478,6 +500,7 @@ def _require_repo_owner_for_sync( 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) diff --git a/tests/unit/scripts/test_sync_github_hierarchy_cache.py b/tests/unit/scripts/test_sync_github_hierarchy_cache.py index c6f89028..0018d1a7 100644 --- a/tests/unit/scripts/test_sync_github_hierarchy_cache.py +++ b/tests/unit/scripts/test_sync_github_hierarchy_cache.py @@ -164,6 +164,20 @@ def test_default_paths_use_ephemeral_specfact_backlog_cache() -> None: 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() @@ -362,3 +376,21 @@ def _fake_fetch(*, repo_owner: str, repo_name: str, fingerprint_only: bool) -> l 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) From 7a512d6a66b684314ae98063564309b9bbc71ba9 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Thu, 9 Apr 2026 23:15:51 +0200 Subject: [PATCH 4/4] Make github sync script executable --- scripts/sync_github_hierarchy_cache.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/sync_github_hierarchy_cache.py diff --git a/scripts/sync_github_hierarchy_cache.py b/scripts/sync_github_hierarchy_cache.py old mode 100644 new mode 100755