Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/specsync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ func runPull(args []string) {
if res.Tasks != "" {
printPreview("tasks.md", res.Tasks)
}
if res.MarkerPresent {
fmt.Printf("\nissue %s already carries the marker %s (no edit needed)\n", *issue, res.Marker)
} else {
fmt.Printf("\nwould add marker to issue %s body: %s\n", *issue, res.Marker)
}
return
}
fmt.Printf("specsync: pulled issue %s -> %s\n", *issue, dest)
Expand Down
47 changes: 44 additions & 3 deletions github.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os/exec"
"strings"
"sync"
)

// GitHubProvider projects changes onto GitHub Issues using the `gh` CLI. It
Expand All @@ -15,6 +16,11 @@ type GitHubProvider struct {
repo string // optional "owner/name"; empty = auto-detect from git remote
// run executes gh and returns trimmed stdout. Overridable in tests.
run func(ctx context.Context, args ...string) (string, error)

// nameOnce memoizes the canonical cache key so Name() resolves the concrete
// repo (auto-detect included) exactly once instead of on every call.
nameOnce sync.Once
name string
}

// NewGitHubProvider returns a provider that drives the real `gh` binary,
Expand Down Expand Up @@ -48,11 +54,30 @@ func runGH(ctx context.Context, args ...string) (string, error) {
return strings.TrimSpace(string(out)), nil
}

// Name is the ref-cache key. It resolves the concrete target repo — from -repo
// or, failing that, the git-remote auto-detect gh itself would use — and keys
// canonically as "github:owner/repo". Two providers aimed at the same repo thus
// share one key, whichever way the repo was supplied, so a ref cached by `pull`
// is found by a later auto-detected `sync`. Resolution is memoized because it
// shells out; when the repo can't be resolved it degrades to the bare "github".
func (p *GitHubProvider) Name() string {
if p.repo != "" {
return "github:" + p.repo
p.nameOnce.Do(func() {
p.name = p.resolveKey(context.Background())
})
return p.name
}

func (p *GitHubProvider) resolveKey(ctx context.Context) string {
repo := p.repo
if repo == "" {
if out, err := p.run(ctx, "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"); err == nil {
repo = strings.TrimSpace(out)
}
}
return "github"
if repo == "" {
return "github"
}
return "github:" + repo
}

// repoFlag returns ["--repo", "owner/name"] when a repo override is set,
Expand Down Expand Up @@ -107,6 +132,22 @@ func (p *GitHubProvider) renderBody(item WorkItem) string {
return marker(item.Slug) + "\n\n" + item.Body
}

// EnsureMarker upserts the identity marker into issue id's body so the link
// survives loss of the local ref cache: a later sync rediscovers the issue via
// Find. Idempotent — a body already carrying the marker is left untouched and no
// gh write happens. It satisfies the IssueMarkerWriter capability used by pull.
func (p *GitHubProvider) EnsureMarker(ctx context.Context, id, slug, body string) (bool, error) {
if strings.Contains(body, marker(slug)) {
return false, nil
}
args := append([]string{"issue", "edit", id}, p.repoFlag()...)
args = append(args, "--body", marker(slug)+"\n\n"+body)
if _, err := p.run(ctx, args...); err != nil {
return false, err
}
return true, nil
}

func (p *GitHubProvider) Push(ctx context.Context, item WorkItem, existing *Ref) (Ref, error) {
labels := desiredLabels(item)
if err := p.ensureLabels(ctx, labels); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion openspec/changes/epic-and-subissue-projection/.status
Original file line number Diff line number Diff line change
@@ -1 +1 @@
triaged
researched
51 changes: 41 additions & 10 deletions openspec/changes/epic-and-subissue-projection/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,47 @@ gets a branch, a worktree, and — once implementation starts — a focused spec
specsync should understand this hierarchy so an epic stays a coordination shell
while each sub-issue maps one-to-one to an OpenSpec change.

## What
GitHub now models this natively: **sub-issues** (parent↔child) went GA in 2025,
support cross-repo/cross-org via the GraphQL `subIssueUrl`, and are read/written
through `gh api graphql` (`subIssues`, `parent`, `subIssuesSummary`;
`addSubIssue`/`removeSubIssue`/`reprioritizeSubIssue`). So the hierarchy should be
projected onto real sub-issues — not a markdown checklist that only renders.

- Recognize an epic by a convention (a label/marker such as `type:epic`), not by
having a spec of its own — an epic is a list/coordination issue.
- Map each sub-issue to one OpenSpec change (one change ⇄ one sub-issue).
- Keep the epic's body in sync with the set of sub-issues (checklist or links),
while each sub-issue's body is driven by its change's proposal + tasks.
## What Changes

## Scope
- Recognize an **epic** by convention (a `type:epic` label/marker), not by having
a spec of its own — an epic is a coordination issue.
- Add a typed **`parent`** edge to a change's `links.md` (the committed relationship
layer; never `refs.json`, which stays identity-only). A child change with a
`## Parent` entry is projected as a **sub-issue** of that parent via the GitHub
sub-issues API, cross-repo-safe through `subIssueUrl`.
- **Reconcile the parent edge both ways** against a last-synced **baseline** (a
gitignored snapshot in `.specsync/`, the merge base). Because an edge is binary
(present/absent), the 3-way reconcile has no ambiguous conflict: an edge added in
`links.md` is pushed to GitHub; a parent attached on GitHub by a human is
**pulled into `links.md`**; an edge removed on either side is removed from the
other. `links.md` and the tracker converge — neither goes stale, and a human's
UI edit is honored rather than discarded.
- **Roll up** the epic body from its live `subIssuesSummary` (count + completion),
while each sub-issue's body stays driven by its change's proposal + tasks.

- Epic detection convention + a `parent` link on a change.
- Sub-issue projection: child change ⇄ sub-issue.
- Epic body roll-up of its sub-issues.
### Out of scope / explicitly deferred
- `blocked-by` / `blocks` dependencies (→ `issue-dependency-sync`)
- Deriving relationships from OpenSpec `references:` (→ `openspec-references-coordination`)
- Reordering sub-issues (`reprioritizeSubIssue`) — add when a real need appears
- Non-GitHub providers — sub-issue projection is a GitHub capability; others gain
an equivalent when they exist (`pluggable-providers`)

## Capabilities

### New Capabilities
- `sub-issue-projection` — project a child change's `## Parent` edge onto a GitHub
sub-issue (cross-repo via `subIssueUrl`), delta-reconciled against `links.md`,
with epic-body roll-up from `subIssuesSummary`.

## Impact
- New code: typed-edge parsing in `links.md` (a `## Parent` section beside the
existing `## Related`), and a sub-issue reconcile that shells `gh api graphql`.
- Reuses the asserted-graph line (`work-graph`) and the per-layer reconcile model
(`two-way-reconcile`); stdlib-only; shells out. Builds on the typed-`links.md`
groundwork in `link-by-issue-reference`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# sub-issue-projection

## ADDED Requirements

### Requirement: Recognize an epic without a spec of its own
specsync SHALL treat a GitHub issue carrying the `type:epic` label as an epic — a
coordination shell — and SHALL NOT require or create an OpenSpec change for it.

#### Scenario: An epic is coordination-only
- **WHEN** an issue labeled `type:epic` is encountered as a `## Parent`
- **THEN** specsync does not expect a local change for it
- **AND** it does not author a proposal or tasks for the epic

### Requirement: Project a change's parent edge onto a GitHub sub-issue
specsync SHALL read a `## Parent` entry from a change's `links.md` and make that
change's issue a sub-issue of the named parent, using the GitHub sub-issues API
(`addSubIssue`), passing the child's issue URL (`subIssueUrl`) so a parent in a
different repository works.

#### Scenario: Child projected under its parent
- **WHEN** a change's `links.md` has `## Parent` → `owner/repo#10` and the change is synced
- **THEN** the change's issue is attached as a sub-issue of `owner/repo#10`
- **AND** a parent in a different repo is attached via its issue URL

### Requirement: Reconcile the parent edge both ways against a baseline
specsync SHALL reconcile the parent edge bidirectionally against a last-synced
baseline stored in the gitignored `.specsync/` cache. Because the edge is binary,
each case resolves without ambiguity: an edge in `links.md` but not the baseline is
pushed to GitHub; a parent on GitHub but not the baseline is pulled into
`links.md`; an edge in the baseline but missing from `links.md` is removed on
GitHub; an edge in the baseline but missing on GitHub is removed from `links.md`.
After reconcile the baseline is updated to the converged set.

#### Scenario: Local removal detaches the sub-issue
- **WHEN** the `## Parent` entry is deleted from `links.md` (it was in the baseline) and the change is synced
- **THEN** specsync detaches the sub-issue on GitHub
- **AND** the baseline no longer records it

#### Scenario: A parent attached on GitHub is pulled into links.md
- **WHEN** a person attaches this issue under a parent on GitHub (not in the baseline) and the change is synced
- **THEN** specsync writes the parent into `links.md` as a `## Parent` entry
- **AND** the edge is not discarded

#### Scenario: Removal on GitHub clears the local entry
- **WHEN** a parent that was in the baseline is detached on GitHub and the change is synced
- **THEN** specsync removes the `## Parent` entry from `links.md`
- **AND** does not re-push the edge

### Requirement: Roll up the epic body from its sub-issues
specsync SHALL keep an epic issue's body roll-up in sync with its live
`subIssuesSummary` (total and completed), without overwriting a child sub-issue's
own body.

#### Scenario: Epic reflects child completion
- **WHEN** an epic has three sub-issues, one closed
- **THEN** its roll-up reports the total and completed counts from `subIssuesSummary`
- **AND** each child's body remains driven by its own change
22 changes: 18 additions & 4 deletions openspec/changes/epic-and-subissue-projection/tasks.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
# Tasks: Epic & sub-issue projection

- [ ] Define the epic convention (type:epic marker/label) and a parent link field
- [ ] Project a child change to a sub-issue under its epic
- [ ] Roll up sub-issue references into the epic body
- [ ] Do not require or create a spec for the epic itself
## Typed links (`links.md`, core)
- [ ] Parse a `## Parent` section in `links.md` (one entry: `#N` / `owner/repo#N` / URL), beside the existing `## Related`
- [ ] Keep `## Related` behavior unchanged; `refs.json` stays identity-only

## Sub-issue projection (GitHub, `gh api graphql`)
- [ ] Read the issue's current parent/sub-issues (`parent`, `subIssues`, `subIssuesSummary`)
- [ ] Attach a child via `addSubIssue` using `subIssueUrl` (cross-repo/cross-org safe); resolve node ids as needed
- [ ] Maintain a gitignored `.specsync/` baseline of the last-synced parent edge (the merge base)
- [ ] Reconcile both ways against the baseline: push local add, pull GitHub add into `links.md`, remove on the opposite side for a removal recorded in the baseline; `removeSubIssue` for a local removal; update the baseline to the converged set

## Epic handling
- [ ] Detect `type:epic`; do not require or create a change/spec for the epic
- [ ] Roll up the epic body from `subIssuesSummary` (total/completed); never overwrite a child's body

## Boundaries & tests
- [ ] Stdlib-only; shell out to `gh api graphql`; `boundary_test.go` green
- [ ] Fake-runner tests: attach, detach-on-removal, cross-repo via URL, unmanaged-edge gap, epic roll-up
- [ ] Update the specsync skill with the `## Parent` syntax and the epic convention
1 change: 1 addition & 0 deletions openspec/changes/issue-dependency-sync/.status
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
researched
53 changes: 53 additions & 0 deletions openspec/changes/issue-dependency-sync/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Sync issue dependencies (blocked-by / blocks)

## Why

Work that spans a backend and a frontend almost always has a *direction*: the
frontend change can't ship until the backend change lands. Today specsync can
record that two specs are "related" (`## Related`), but "related" is symmetric and
loses the dependency direction — the exact thing a planner and an agent need to
sequence the work.

GitHub now models this as a first-class relationship: **issue dependencies**
(`blockedBy` / `blocking`) went GA in 2025, with real mutations (`addBlockedBy`,
`removeBlockedBy`) and a summary field (`issueDependenciesSummary`), read/written
via `gh api graphql` (there is no native `gh issue` flag yet). So a directed
local edge should project onto a real dependency, not be flattened into "related".

## What Changes

- Add directed typed edges to `links.md`: **`## Blocked by`** and **`## Blocks`**
(the inverse). Entries take the usual forms (`#N` / `owner/repo#N` / URL), so a
backend issue in another repo can block a frontend change.
- On sync, project each `## Blocked by` entry onto a GitHub **issue dependency**
via `addBlockedBy`, cross-repo by node id. `## Blocks` is the same edge seen from
the other end (write it as the blocker's `blockedBy`).
- **Reconcile dependencies both ways** against a last-synced **baseline** (a
gitignored snapshot in `.specsync/`, the merge base). Each edge is binary, so the
3-way reconcile is unambiguous: an edge added in `links.md` is pushed
(`addBlockedBy`); a dependency added on GitHub by a human is **pulled into
`links.md`**; an edge removed on either side is removed from the other
(`removeBlockedBy` for the GitHub side). Dependencies converge in both
directions — none go stale, and UI-added dependencies are honored, not discarded.

### Out of scope / explicitly deferred
- Parent/sub-issue hierarchy (→ `epic-and-subissue-projection`)
- `duplicateOf` and other relationship types — add when a real need appears
- Cycle detection across dependencies — GitHub rejects cycles itself; specsync
surfaces the error rather than pre-validating
- Non-GitHub providers — dependency sync is a GitHub capability; others gain an
equivalent when they exist (`pluggable-providers`)

## Capabilities

### New Capabilities
- `issue-dependency-sync` — project directed `## Blocked by` / `## Blocks` edges
from `links.md` onto GitHub issue dependencies, delta-reconciled, cross-repo.

## Impact
- New code: parse `## Blocked by` / `## Blocks` in `links.md`; a dependency
reconcile that shells `gh api graphql` (`issueDependenciesSummary`, `blockedBy`;
`addBlockedBy` / `removeBlockedBy`).
- Same asserted-graph and per-layer-reconcile model as the sibling changes;
stdlib-only; shells out. Composes with `epic-and-subissue-projection` (both are
typed `links.md` edges projected onto native GitHub relationships).
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# issue-dependency-sync

## ADDED Requirements

### Requirement: Declare directed dependency edges in links.md
specsync SHALL parse `## Blocked by` and `## Blocks` sections in a change's
`links.md`, each listing issue references (`#N` / `owner/repo#N` / URL). These are
directed edges, distinct from the symmetric `## Related` section.

#### Scenario: A frontend change blocked by a backend issue
- **WHEN** a change's `links.md` has `## Blocked by` → `ExopenGitHub/FusionHub#3489`
- **THEN** specsync treats that backend issue as blocking this change's issue
- **AND** the dependency direction is preserved (not flattened to "related")

### Requirement: Project dependencies onto GitHub issue dependencies
On sync specsync SHALL make the issue's dependencies match `links.md`: a
`## Blocked by` entry is projected via `addBlockedBy`, and a `## Blocks` entry is
projected as the named issue being blocked by this one. References in another repo
are linked by node id.

#### Scenario: Blocked-by projected to GitHub
- **WHEN** a change with a `## Blocked by` entry is synced
- **THEN** the change's issue gains a GitHub dependency on the named blocker
- **AND** a blocker in a different repo is linked across repos

### Requirement: Reconcile dependencies both ways against a baseline
specsync SHALL reconcile the dependency edge set bidirectionally against a
last-synced baseline stored in the gitignored `.specsync/` cache. Each edge is
binary, so every case resolves without ambiguity: an edge in `links.md` but not the
baseline is pushed (`addBlockedBy`); a dependency on GitHub but not the baseline is
pulled into `links.md`; an edge in the baseline but missing from `links.md` is
removed on GitHub (`removeBlockedBy`); an edge in the baseline but missing on GitHub
is removed from `links.md`. After reconcile the baseline becomes the converged set.

#### Scenario: Local removal clears the dependency on GitHub
- **WHEN** a `## Blocked by` entry that was in the baseline is deleted from `links.md` and the change is synced
- **THEN** specsync removes the dependency on GitHub
- **AND** the baseline no longer records it

#### Scenario: A dependency added on GitHub is pulled into links.md
- **WHEN** a person adds a blocked-by dependency on GitHub (not in the baseline) and the change is synced
- **THEN** specsync writes it into `links.md` as a `## Blocked by` entry
- **AND** the dependency is not discarded

#### Scenario: Removal on GitHub clears the local entry
- **WHEN** a dependency that was in the baseline is removed on GitHub and the change is synced
- **THEN** specsync removes the `## Blocked by` entry from `links.md`
- **AND** does not re-push it

### Requirement: Surface dependency conflicts rather than pre-validating
specsync SHALL rely on GitHub to reject invalid dependencies (e.g. a cycle) and
SHALL surface that error, rather than maintaining its own cycle check.

#### Scenario: GitHub rejects a cycle
- **WHEN** projecting a dependency would create a cycle and GitHub returns an error
- **THEN** specsync reports the error
- **AND** does not silently drop the edge
18 changes: 18 additions & 0 deletions openspec/changes/issue-dependency-sync/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Tasks: sync issue dependencies

## Typed links (`links.md`, core)
- [ ] Parse `## Blocked by` and `## Blocks` sections (entries: `#N` / `owner/repo#N` / URL)
- [ ] Treat them as directed edges, distinct from the symmetric `## Related`

## Dependency projection (GitHub, `gh api graphql`)
- [ ] Read current dependencies (`issueDependenciesSummary`, `blockedBy`, `blocking`)
- [ ] Resolve node ids for cross-repo references; `addBlockedBy` for `## Blocked by`
- [ ] `## Blocks` projects as the named issue's `blockedBy` (the inverse edge)
- [ ] Maintain a gitignored `.specsync/` baseline of the last-synced dependency set (the merge base)
- [ ] Reconcile both ways against the baseline: push local adds, pull GitHub adds into `links.md`, `removeBlockedBy` for local removals, remove from `links.md` for GitHub removals; update the baseline to the converged set
- [ ] Surface GitHub's error on an invalid/cyclic dependency rather than pre-validating

## Boundaries & tests
- [ ] Stdlib-only; shell out to `gh api graphql`; `boundary_test.go` green
- [ ] Fake-runner tests: add blocked-by, inverse `## Blocks`, cross-repo, remove-on-removal, unmanaged-edge gap, cycle-error surfaced
- [ ] Update the specsync skill with the `## Blocked by` / `## Blocks` syntax
1 change: 1 addition & 0 deletions openspec/changes/openspec-references-coordination/.status
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
researched
Loading
Loading