CLAUDE.md@src/grove/core/tickets · git:20260712.bbc007c · 2026-07-12 · sha256 a1b2875727a72c1e
CLAUDE.md@src/grove/core/tickets git:20260712.bbc007cA
Immutable. This exact content is served forever at /api/v1/blob/a1b2875727a72c1e.
# grove.core.tickets — the branch-aware ticket-provider layer > ↑ [grove.core](../CLAUDE.md) · [root](../../../../CLAUDE.md) One `TicketProvider` interface, implemented once per tracker (Gitea, GitHub, Linear) and reused by every client (TUI / API / MCP) through the `TicketProviderRegistry`. Wire shapes (`TicketRef` / `TicketSelector` / `TicketProviderView`) live in [contracts/tickets.py](../contracts/CLAUDE.md) — they cross the client boundary; this package is the engine-side mechanism over them. Built for issue #7; the assigned-ticket *explorer* UI is the separate #52 slice. ## The provider boundary (an adapter normalizes shape, not semantics) - **A provider has two faces, split by the side-effects-at-the-edge rule.** The *pure* face (`parse_branch_refs` / `format_branch_name`) translates branch ⇄ canonical key with no I/O — this is the **engine's only entry point**, so `create()`/`attach_ticket` stay deterministic and offline-safe. The *I/O* face (`list_assigned` / `get_ticket`) hits the tracker's REST/GraphQL API and is called **only at the daemon `/tickets` edge**, never from the engine. Keep that split: a new provider method that does network must not become reachable from a lifecycle path. - **`HttpTicketProvider` carries the shared wire plumbing (the `core/mewbo.py` pattern): lazy httpx client + one typed-error `_request`.** The client is built on first I/O call, so a provider used only for pure branch parsing (the engine's path) never opens a socket. Every failure narrows to `TicketProviderError`; httpx exceptions never leak. Auth is the env-ref pattern — config holds the env-var NAME (`token_env`), the value is read at construction, `configured = bool(token)`. - **`NumberTicketProvider` factors the numeric branch grammar shared by Gitea + GitHub; Linear's keyed grammar is its own class.** This is shared *kind of grammar*, not cross-provider leakage: each concrete class still owns its API endpoints, JSON shape, and built-in keyword set (`gh` / `gitea`+`gtea`). The numeric grammar recognizes three forms, all word-boundary-anchored so a digit inside a slug (`fix-v2-bug`) is never an id: leading numeric segment (`123-…` / `repo/123-…`), built-in keyword (`gh-123`), and the configured `branch_prefix`. - **Config-bound providers, no `RepoContext` arg (YAGNI).** The issue proposed a `RepoContext` parameter on every method; we dropped it. Each provider is constructed bound to its config submodel (owner/repo/team_key/base_url), exactly like `MewboClient` is bound to `MewboConfig`, so the methods take only the data they need. One fewer speculative abstraction. ## The registry is the one shared surface - **`TicketProviderRegistry` owns the only non-provider-specific logic: branch-ref aggregation + ambiguity.** `parse_workspace_refs(branch)` runs every enabled provider's pure parse and, when the total match count across providers exceeds one, marks **every** resulting ref `ambiguous=True`. A bare number (`123-fix`) deliberately matches BOTH numeric providers when both are enabled → ambiguous; a keyed form (`ENG-123`, `gh-42`, `gtea-5`) is unambiguous. The manager and the daemon both reach providers via `WorkspaceManager.ticket_providers` (cached, built from `cfg.tickets`) — there is no second parse path. - **Branch name is the single source of truth for association.** `create()` re-parses the *final* branch (ticket-aware auto, user-named, or an adopted checkout) — so create-from-ticket and adopt-existing-branch share one code path. `attach_ticket`/`detach_ticket` are the manual override; they store a **bare** ref (provider+id), since display enrichment (title/status) is the daemon's on-demand fetch, never persisted — attach stays pure. Both gated by `ensure_can_update` (refused only on ORPHANED). - **`WorkspaceManager.find_by_ticket(provider, ticket_id)` (#195) is the issue-ops "does a workspace already exist for this ticket" lookup — a scan over `list()`, no new index.** It reuses the ref list `attach_ticket`/branch-parse already populate; no new persisted state. Tie-break when more than one live workspace tracks the same ticket: newest by `created_at` wins. "Non-killed" needs no explicit filter — `kill()` deletes the persisted record outright (there is no KILLED status), so a killed workspace can never appear in `list()`'s output to begin with. `GET /workspaces?ticket=<provider>:<id>` (daemon) is the one wire consumer, dispatched like `/branches`'s `repo` query param. ## Issue-ops research verdicts (2026-07-09, epic #192) > Distilled from a prior-art sweep (anthropics/claude-code-action, OpenHands resolver, PR-Agent, github/command, peter-evans/slash-command-dispatch, Dependabot/Mergify, Copilot coding agent, Devin, OpenAI Codex, Sweep) plus Gitea source/docs verification — persisted so no future session re-derives them. These seed the future `core/issueops/` module; this file owns them until that module's own CLAUDE.md exists. Comment I/O (post/edit/list/react) lands on the provider Protocol here (#193) — I/O face, daemon-edge-only, same as `list_assigned`. - **Industry-converged inbound defaults (copy, don't reinvent):** trigger token is the FIRST token of a comment, word-boundary matched, configurable as data never code; gate on the commenter's live repo write access with a separately-named opt-in widening knob; ignore bot actors and anything carrying our own comment signature; listen only to comment *created* events (an `edited` trigger is a silent re-fire hole); reactions (👀 receipt, 🚀/👎 outcome) are the universal cheap ack; a bad command gets a reply, never silence (PR-Agent's silent-typo failure). A bare `/` prefix collides with GitHub's native markdown slash commands — use a mention-style token. - **Sticky status comment discipline (claude-code-action `track_progress` + PR-Agent `persistent_comment` + Sweep's failure modes):** ONE bot comment per ticket, found-or-created; rebuild the whole body from live state on every edit — never diff-patch rendered markdown; persist the comment id and use marker-scan only as cold-start recovery; coalesce rapid edits (forges apply secondary rate limits to same-comment PATCH storms — Sweep shipped no defense and it showed); never let the bot's rendered scratchpad share a field with human-authored task text (Sweep had to regex-strip its own checklist out of issue bodies). - **The CI forwarder must stay a stateless dumb pipe.** Zero prior art routes a follow-up comment into a running task — every product re-runs fresh per comment. The new-vs-steer decision belongs to the daemon (it owns workspace-by-ticket state); the workflow forwards every trigger comment identically. Live steering + a session-lifetime live todo comment are Grove differentiators precisely because everyone else's executor dies with the CI job. - **Gitea Actions capability floors (verified in go-gitea source/docs, 2026-07-09):** `issue_comment` fires for PR comments only ≥1.21.6 (#29277); issue-event workflows always load from the DEFAULT branch (GitHub parity); `permissions:` blocks are parsed-but-ignored until 1.26.0 (#36173), where Restricted-mode instances 403 an undeclared `issues: write`; private cross-repo `workflow_call` needs 1.26.0 collaborative owners (#32562) — sidestepped by shipping logic as a composite action referenced via Gitea's absolute-URL `uses:` extension; `.gitea/workflows/` wins precedence over `.github/workflows/` (both scanned); fork-PR runs clamp GITEA_TOKEN to read-only; the payload is GitHub-schema-compatible but not byte-identical (`issue.pull_request` presence is the PR-vs-issue discriminator — parse defensively). - **Runner topology is the sharp constraint, not code:** a default docker act_runner job runs on an isolated bridge and cannot reach host loopback (`host.docker.internal` doesn't exist on Linux); the supported shape is a dedicated `:host`-labeled runner on the daemon host (GitHub: a same-host self-hosted runner) — which is exactly what keeps the daemon's loopback-bind invariant untouched. ## Comment I/O is a base-default capability, not a per-provider stub (#193) - **`list_comments`/`post_comment`/`edit_comment`/`react` live on `HttpTicketProvider` as concrete (non-abstract) methods whose base body raises `TicketCommentsUnsupported`.** This is the "NotImplementedError-style base default, narrowed to the tickets error family" the issue asked for: a bare `NotImplementedError` would leak an untyped exception past the provider boundary, so the base raises the typed sibling of `TicketProviderError`/`TicketProviderNotConfigured` instead (same section in `errors.py`, same "callers handle one exception family" contract). Only Gitea and GitHub override the four; **Linear inherits the raise untouched** — no per-method stub, no `NotImplemented` boilerplate duplicated three times. Adding a fourth non-implementing provider later costs zero lines. - **Ruff's ARG002 only exempts a *literal* `raise NotImplementedError(...)` body, not a custom exception.** Raising a typed subclass (even via a one-line helper) trips "unused method argument" on every base-default parameter, so each of the four base methods carries `# noqa: ARG002` — deliberate, matching the `close()` `# noqa: B027` precedent in `notifications/channel.py` for "this override intentionally does nothing with its args." - **Gitea and GitHub's comment JSON shape is byte-for-byte identical** (`id`/`body`/`user.login`/`created_at`) — tempting to share `_to_comment` on `NumberTicketProvider`. Deliberately duplicated per-provider instead, matching this package's existing `_to_ref`/`_assignee` precedent: each concrete class owns its own JSON-shape normalization even when two trackers happen to overlap, so `NumberTicketProvider` stays scoped to the *branch grammar* it was introduced for, not shape normalization too. - **`_scoped(op)` replaces the repeated `if not (self._owner and self._repo): raise ...` guard.** With only `get_ticket` needing it, duplication was fine (YAGNI); once four more comment methods needed the identical guard, factoring one private method per provider class (not shared across Gitea/GitHub — the message text still names the concrete provider) was the right call per the "three similar lines is fine, five is not" rule. - **`TicketReactionKind` is a closed `Literal` in `contracts/tickets.py`, not a bare `str`** — Gitea and GitHub accept the identical eight-value reaction vocabulary verbatim, so one shared literal catches a typo'd reaction name at the type-checker instead of a silent 4xx at the daemon edge. ## Gotchas - **Linear GraphQL must POST to the *absolute* configured endpoint.** httpx turns an empty relative path into a trailing slash (`/graphql/`), which Linear rejects; `_graphql` passes `self._base_url` as the path so the URL is exact. GraphQL also answers 200 on error — the error envelope rides `body.errors`, checked in `_graphql`, never leaked. - **`TicketRef` is Pydantic but persisted on the `WorkspaceState` dataclass.** `asdict` won't serialize a nested Pydantic model, so the store handles `ticket_refs` explicitly (`model_dump(mode="json")` out, `model_validate` back) — the same explicit-field treatment enums/datetimes get. `WorkspaceState` imports `TicketRef` under `TYPE_CHECKING` only (default `field(default_factory=list)` needs no runtime symbol) so the engine state stays on the depended-upon side of the import arrow — see [contracts](../contracts/CLAUDE.md).