Skip to content

Issue inventory and synchronization integrity

forge.SyncIssues requires a complete bounded inventory before changing issues. Its existing signature and identity rule remain: titles match exactly after trimming surrounding whitespace. Matching is case-sensitive. This is declarative title matching, not semantic duplicate detection or upstream contribution approval.

A planned mutation batch may contain at most 1,000 issues. Its trimmed titles must be unique. The complete inventory may contain up to 2,000 entries, and sync checks all of them, including entries beyond the mutation batch limit. If a selected title matches multiple existing entries, the whole batch fails before any mutation. Duplicate existing titles unrelated to the planned batch do not block that batch.

GitHub listing requests at most 20 pages of 100 items. Pull requests count toward pagination even though they are omitted from the returned issues. A short page establishes completion. A full twentieth page does not prove completion and returns an error with no partial issue result, even when the inventory might contain exactly 2,000 items. Oversized pages, null arrays, invalid issue numbers and empty titles are rejected rather than treated as absent issues.

Callers can inspect *forge.IssueListIncompleteError and *forge.IssueTitleConflictError with errors.As. The latter identifies the trimmed title and whether the conflict is in planned or existing issues. These errors must stop mutation; retrying creation without a complete inventory can duplicate issues. Transport errors retain their existing wrapped context.

praetorctl issue reconcile reconciles the repositories --repos names. Without the flag it reads forge.reconcile_repos from the operator settings that --fleet-config, --workstation-config and --manifest select (effective policy), and without that the repository in the working directory, identified by config.ResolveRepositoryIdentity. With none of them it refuses, as it refuses an explicit empty --repos=. A bare entry such as kit is qualified with --owner, else the working directory's repository.owner or origin remote owner, else forge.default_owner; with no owner the entry is refused by name. No owner or repository list is built in (TestIssueReconcile_Positive_ScopeChain and TestIssueReconcile_Negative_NoInventedOwnerOrScope in cmd/standardsctl/forge_owner_test.go).

praetorctl issue reconcile accepts at most 256 comma-separated repository entries (config.MaxReconcileRepos, the same cap as forge.reconcile_repos); larger selections fail before authentication or network reads instead of being truncated. It loads every selected repository before reconciling or applying transitions. Any listing failure returns an error naming that repository, including in dry-run mode. It cannot report a successful fleet reconciliation from partially fetched input. Complete inventories retain normal dry-run and apply behavior.

Planning sync: parents, epics and milestones

praetorctl issue reconcile also keeps the planning state on the forge in step with the work that landed (issue #837). Without --apply every run is a dry run: it reads the issues and milestones of each selected repository, prints every write it would make and makes none. The first run on a repository is no exception, so existing drift is always listed before anything repairs it:

# list the writes and the drift, write nothing (the default; --dry-run says the same)
praetorctl issue reconcile --repos=acme/example
# make the listed writes
praetorctl issue reconcile --repos=acme/example --apply

--dry-run=false still applies, as it did before --apply existed; --apply together with an explicit --dry-run is refused. Applying needs a token with issues: write; a dry run needs read access to issues and milestones.

What the sync writes

The engine is the same forge.ReconcileEngine that unblocks dependencies (PlanPlanning and ApplyPlanning in internal/forge/reconciler.go). It makes three kinds of write, and never unticks a box or reopens an issue or a milestone:

Write When
Tick A parent's task-list box names a child issue that is closed. Only that box changes; every other byte of the body is kept.
Close parent An open parent carrying the epic or tracking label names at least one child, and every child is closed: each task item, and each GitHub sub-issue its sub_issues_summary counts.
Close milestone An open milestone holds closed issues and no open one.

A parent is an issue that carries the epic or tracking label, names a child in a task list, or has sub-issues. A child is a task item whose text opens with the reference: - [ ] #12, - [ ] owner/repo#12 or - [ ] https://github.com/owner/repo/issues/12. A reference later in the item, or inside a fenced code block, makes no child (forge.ParseTaskItems). A child in a repository outside --repos has no known state, so its parent is never closed.

Before each write the sync reads the issue or milestone again, under its own 30-second timeout. A box someone ticked meanwhile, a parent that lost its label or gained an open child, and a milestone that gained an open issue are skipped with the reason, not written.

The write log and the findings

The run summary lists every write with its outcome, then every finding:

=== Planning Sync (applied): 4 writes, cap 3 | 1 findings ===
  [APPLIED] acme/example#1 tick the boxes of closed children acme/example#2, acme/example#3
  [APPLIED] acme/example#5 close parent "Tracking": every child is closed (1 task-list, 0 sub-issue)
  [SKIPPED] acme/example milestone #1 close "Done": 1 open issues since the listing; left open
  [DEFERRED] acme/example milestone #2 close "Beta": 0 open, 4 closed issues (write cap 3 reached)
  [DRIFT] acme/example#1 ticked-open-child: box ticked for acme/example#4, which is open; left ticked

A dry run prints [PLAN] where an applied run prints [APPLIED]; a write the forge refused is [FAILED] and fails the command. [DRIFT] lines are drift the sync leaves as it is:

Finding Meaning
ticked-open-child A ticked box names an open child.
closed-parent-open-child A closed parent has an open child or open sub-issue.
untracked-child A child lives in a repository outside --repos.
parent-without-children An epic or tracking issue names no child and has no sub-issues.
complete-parent-without-label Every child of an open parent is closed, but it carries neither label, so it stays open.
empty-milestone An open milestone holds no issue at all; it may be waiting for its first one.
unreadable-task-list The body exceeds 2,000 lines or names more than 100 children; the parent is not reconciled.

Bounds

--max-planning-writes caps the writes of one run: 50 by default, at most 500. Writes beyond the cap are printed as [DEFERRED] and the next run makes them; a parent's close is never made before its tick. Reads are bounded by the listings themselves: 2,000 issues and 5,000 milestones per repository, an incomplete listing failing the run before anything is planned. A milestone that this run's parent closes empties is closed by the next run.

Task lists, not sub-issues

The sync writes task lists and reads both task lists and sub-issue progress. A task-list line is plain Markdown in the parent's body: it renders wherever the body is shown, the sync ticks it with the one body edit it already makes, and GitHub ticks the box itself when the child closes (About tasklists), so the sync is the backstop for a box GitHub did not tick. Writing sub-issues would need one request per child against the issue's database id rather than its number (REST API endpoints for sub-issues) and works on GitHub alone, so it is left to a later slice. Sub-issue progress is read from the sub_issues_summary every listed issue carries, at no extra request.

needs epic --publish writes those lines: once every child exists, the parent's body names each one as - [ ] #N, ticked when the child is already closed (migration candidates and epics).

The scheduled run in this repository

.github/workflows/planning-sync.yml runs issue reconcile --apply on this repository when an issue closes and daily at 04:17 UTC, with issues: write, and writes the output into the run summary. A manual dispatch is a dry run unless its apply input is set. Like the other scheduled workflows it runs in the canonical repository only (operational sync). Adopter repositories do not get this workflow from praetorctl adopt yet; adoption emits it in a later slice.

The behaviour is covered by internal/forge/planning_sync_test.go and cmd/standardsctl/issue_planning_test.go.

Limits

Duplicate planned titles are rejected. If an intended existing title is ambiguous, resolve that ambiguity before syncing it. Large or incomplete inventories require a separately reviewed lookup strategy; increasing a local bound or treating a partial result as empty does not establish completeness.

Partial inventories and partial success summaries are not evidence of a complete reconciliation; rerun affected checks after obtaining complete inputs.

Preflight failures cause no mutations. Later provider failures can still leave an explicitly reported partial batch, and simultaneous independent sync processes are not serialized. There is no distributed idempotency mechanism and no MCP mutation tool for sync.