Skip to content

feat(tickets): data model + GitHub/Notion providers - #172

Merged
frenchie4111 merged 2 commits into
feat/ticketsfrom
feat/tickets-data
Jun 19, 2026
Merged

frenchie4111 merged 2 commits into
feat/ticketsfrom
feat/tickets-data

Conversation

@frenchie4111

Copy link
Copy Markdown
Collaborator

Summary

Data-side half of the first-party ticketing-system work. Sibling PR (UI workstream) builds the picker + Settings page against this surface. Both PRs target feat/tickets, not main.

The shared contract in src/shared/tickets.ts is already on feat/tickets; this PR fills in the slices, persistence, providers, and IPC surface the UI consumes.

What's in here

  • ticketProviders slice — configured provider instances keyed by uuid. Events: loaded, added, updated, removed.
  • tickets slice — per-provider cache of last list() result (tickets[], loading, lastFetched, error). Dumb timestamp-based invalidation; events: fetchStarted, fetchSucceeded, fetchFailed.
  • M2M repo↔provider link — RepoConfig.ticketProviderIds[] persisted in .harness.json, with effectiveTicketProviderIds() helper.
  • Worktree-ticket link — Worktree.linkedTicket + PendingWorktree.linkedTicket carry the (providerId, externalId) tuple. Side-table for the worktree map lives in config.json under worktreeTicketLinks; WorktreesFSM merges it into the dispatched list on every refresh and prunes entries on worktree removal.
  • Providers — src/main/tickets/{provider-registry,github-issues,notion}.ts. GitHub uses the existing token resolution + filters PRs out of the issues endpoint. Notion tokens resolve from secrets.enc keyed ticket-provider-token:<id>.
  • IPC surface — tickets:{listProviders, addProvider, updateProvider, removeProvider, list, get, linkRepoProvider, unlinkRepoProvider} plus linkedTicket? threaded through worktrees:runPending(PR).
  • Tests — one reducer test per event variant in both new slices; also covers effectiveTicketProviderIds.

Out of scope (UI workstream's PR)

  • Settings page for providers.
  • Ticket-picker modal.
  • "Spawn worktree from ticket" UI flow (this PR provides the hook: addWorktree accepts linkedTicket).
  • Worktree-row badge showing the linked ticket title.

Test plan

  • npm run typecheck
  • npx electron-vite build
  • npx vitest run src/shared (696 passed)
  • Smoke: configure a GitHub provider in config.json manually, hit tickets:list via devtools, confirm tickets show up
  • Smoke: configure a Notion provider + token, hit tickets:list, confirm a real database returns
  • Smoke: spawn a worktree with linkedTicket set, restart Harness, confirm the link survives the boot

🤖 Generated with Claude Code

Implement the data-side half of first-party ticket-system support. The
shared contract in `src/shared/tickets.ts` already landed; this PR fills
in the slices, persistence, providers, and IPC surface the UI workstream
consumes.

- `ticketProviders` slice: configured provider instances keyed by uuid,
  events for loaded / added / updated / removed.
- `tickets` slice: per-provider cache of last `list()` result
  (tickets[], loading, lastFetched, error). Dumb timestamp-based
  invalidation; events for fetchStarted / fetchSucceeded / fetchFailed.
- `RepoConfig.ticketProviderIds[]` for the M2M repo↔provider link,
  persisted in `.harness.json`, with `effectiveTicketProviderIds`
  helper.
- `Worktree.linkedTicket` + `PendingWorktree.linkedTicket` carry the
  (providerId, externalId) tuple. The side-table for the worktree map
  lives in `config.json` under `worktreeTicketLinks`; WorktreesFSM
  merges it into the dispatched list on every refresh and prunes
  entries on worktree removal.
- `src/main/tickets/{provider-registry,github-issues,notion}.ts`
  implement the runtime providers. Notion tokens resolve from
  `secrets.enc` keyed `ticket-provider-token:<id>`. GitHub uses the
  existing token resolution.
- IPC surface: `tickets:{listProviders,addProvider,updateProvider,
  removeProvider,list,get,linkRepoProvider,unlinkRepoProvider}`, plus
  `linkedTicket?` threaded through `worktrees:runPending(PR)`.
- One reducer test per event variant in both new slices.

Sibling PR (UI workstream) builds the picker + Settings page against
this surface. Both merge into `feat/tickets`, not `main`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@frenchie4111

Copy link
Copy Markdown
Collaborator Author

Review

Data-half of the ticketing work, targeting feat/tickets. Adds two slices (ticketProviders, tickets), threads linkedTicket through Worktree / PendingWorktree with a side-table on disk to survive the git re-read, ships GitHub Issues + Notion provider implementations, and exposes the IPC surface the UI workstream is building against. +1117/-10 across 18 files; reducer tests pass.

✅ What's solid

  • Slice pattern is followed faithfully — both reducers preserve reference identity on no-op cases (if (!existing) return state, if (!(id in byId)) return state), with tests asserting next === start. That's the cascade anti-pattern guidance from CLAUDE.md done right.
  • Side-table for linkedTicket is the right call — worktrees are reconstructed from git worktree list on every refresh, so a Harness-only field can't live on the git record. Persisting in config.json and merging in WorktreesFSM.refreshList is well-documented in code.
  • Reverse-cleanup on removeProvider strips the id from every linked repo's ticketProviderIds so no dangling references survive (src/main/index.ts:3625).
  • Provider registry uses _exhaustive: never so a future TicketProviderType literal that lands without an implementation fails the build.
  • Tokens are write-only over IPC — no read path returns them.

🐛 Bugs to fix before merge

1. Notion search filter is dead unless titleProperty is explicitly set.
src/main/tickets/notion.ts:141:

if (query && config.titleProperty !== undefined) {
  body.filter = { property: config.titleProperty || 'Name', ... }
}

The default for titleProperty is 'Name' (used in toTicket), but the filter is gated on config.titleProperty !== undefined, so a brand-new provider configured without titleProperty will never narrow on search. Should be:

if (query) {
  body.filter = { property: config.titleProperty || 'Name', title: { contains: query } }
}

2. tickets:updateProvider accepts a type-mismatched config payload.
src/main/index.ts around line 3577 — {...existing, ...patch} with no runtime check that patch.config is shape-compatible with patch.type ?? existing.type. A Notion config payload on a github-issues provider would persist to disk and only crash on the next tickets:list inside parseRepo. Easiest fix: if patch.config is set, validate against the discriminated type before saving (or refuse type changes outright since addProvider is the supported way to switch).

3. tickets:addProvider casts the config without validating.
src/main/index.ts:3559 does input.config as GithubIssuesConfig with no runtime check. A malformed payload from a renderer bug (or future external caller) lands on disk and crashes on first list(). Add a minimal validateConfig(type, config) helper that checks the required fields (repo non-empty for github, databaseId non-empty for notion) and returns null on failure.

4. Stored token for a github-issues provider is dead weight.
The GH provider uses getCachedToken / resolveGitHubToken (shared GitHub auth), so a token passed to tickets:addProvider({type: 'github-issues', ...}, token) lands in secrets.enc under ticket-provider-token:<id> and is never read. Either branch on type and skip the setSecret call, or document that per-provider tokens are notion-only. The UI workstream also needs to know — its add-provider form shouldn't ask for a token when type is github-issues (unless we want per-repo GH PATs later, in which case land the read path now).

⚠️ Smaller concerns

  • worktreeTicketLinks orphan on creation failure. setWorktreeTicketLink is called after addWorktree succeeds but before finishCreate. If setup fails badly enough that the worktree is removed, the link entry stays on disk — refreshList ignores absent paths but doesn't prune the side-table. Add a sweep in the post-failure cleanup, or prune inside refreshList for paths not in the flat list.
  • worktreeTicketLinks orphan on provider removal. The removeProvider handler strips ids from RepoConfig.ticketProviderIds but leaves worktreeTicketLinks entries pointing at the removed providerId. The UI chip will resolve to an unknown provider. Sweep worktreeTicketLinks for the removed id too.
  • tickets:listProviders reads config.ticketProviders directly instead of store.getSnapshot().state.ticketProviders.byId. They're kept in sync by the handlers but the store is canonical — minor preference for consistency.
  • trackedFetch from github-recorder used for Notion calls (src/main/tickets/notion.ts:8). The module name lies. Either alias the import (trackedFetch as recordedFetch) or extract to a neutral name in a follow-up — not blocking, but Notion latency will show up in something called "github-recorder" which confuses future debugging.
  • setWorktreeTicketLink accepts undefined to drop, but no caller exercises it. The IPC remove handler in index.ts:1296 pokes at config.worktreeTicketLinks directly instead of routing through the setter. Either route through the setter (cleaner) or drop the undefined branch.

🧪 Tests

  • Reducer tests are thorough — one per event variant, plus reference-identity checks for no-op cases. ✓
  • Untested: WorktreesFSM.refreshList's link-decoration step. Worth a unit test that constructs an FSM with a fake getWorktreeTicketLinks returning {[path]: link} and asserts the dispatched list carries linkedTicket — this is the load-bearing trick of the whole approach.
  • Untested: the IPC handlers' validation paths (Add GitHub setup banner on app launch #2, Persist terminal tabs and scrollback across restarts #3 above). If those become unit-testable after extracting validateConfig, add quick coverage.

🔐 Security

  • Tokens correctly never travel over IPC outbound. ✓
  • Notion bearer is in the Authorization header, not URL. ✓
  • 401 handling on GitHub re-resolves the token cache. ✓
  • No PII / log leakage in the error paths — formatErr strips the noise. ✓

Verdict

Approve with changes. The architecture is clean and the side-table trick is the right answer for the worktree-metadata persistence problem. Bugs #1–#4 should land before merge — #1 is user-visible (search broken in the common case), #2 and #3 are correctness, #4 is a UI-workstream coordination point. The orphan-cleanup smells are not blocking but worth a follow-up commit.

Comment left on behalf of @frenchie4111 by Claude via Harness.

Addresses Claude's review on #172:

- Notion search filter now triggers on `query` alone (was gated on
  `config.titleProperty !== undefined`, which made search dead for
  every provider that didn't explicitly set the property name).
- `validateProviderConfig(type, raw)` in provider-registry shape-checks
  the discriminated `config` payload before it lands on disk. Both
  `tickets:addProvider` and `tickets:updateProvider` validate; type
  changes via update are refused (use remove + add instead).
- Token slot is now only used for Notion providers — GitHub reuses the
  shared PAT / gh-cli token, so any token passed for a `github-issues`
  add is dropped silently.
- `tickets:removeProvider` now also strips `worktreeTicketLinks`
  entries pointing at the removed provider, and triggers a
  `refreshList` so the linkedTicket disappears from the sidebar
  decoration in one render.
- `WorktreesFSM.refreshList` calls a new `pruneWorktreeTicketLinks`
  callback with the live worktree path set, so side-table orphans
  (left behind by setup-failure cleanup, manual `git worktree
  remove`, or an older Harness) get garbage-collected.
- `tickets:listProviders` reads from `store.getSnapshot()` instead of
  `config` directly — the store is canonical.
- `worktree:remove` IPC handler routes through the named
  `setWorktreeTicketLink` helper instead of poking `config` directly.
- `trackedFetch` aliased to `recordedFetch` in `notion.ts` — the name
  is GitHub-flavored but the function is generic, and Notion latency
  showing up under "github-recorder" in stack traces was misleading.

Tests added:

- `WorktreesFSM.refreshList` decorates from the side-table, calls the
  prune callback with the right path set, and returns the git list
  verbatim when no getter is wired.
- `validateProviderConfig` for both provider types, including
  cross-type-shape rejection and unknown-field stripping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@frenchie4111

Copy link
Copy Markdown
Collaborator Author

Fixed in e09da15:

Bugs

  1. ✅ Notion search filter now triggers on `query` alone (was dead unless `titleProperty` was explicitly set).
  2. ✅ `tickets:updateProvider` refuses type changes outright (remove + add to switch), and validates the `config` payload via the new `validateProviderConfig(type, raw)` helper.
  3. ✅ `tickets:addProvider` also goes through `validateProviderConfig` — malformed payloads return `null` instead of landing on disk and crashing later.
  4. ✅ Token slot is now only used for Notion. A token passed to `addProvider({type: 'github-issues', ...}, token)` is dropped silently. UI workstream: hide the token input when type is `github-issues`.

Smaller concerns

  • ✅ `worktreeTicketLinks` orphan on creation failure → `WorktreesFSM.refreshList` now calls a `pruneWorktreeTicketLinks(livePaths)` callback so anything not in the current git-derived list gets GCed.
  • ✅ `worktreeTicketLinks` orphan on provider removal → `tickets:removeProvider` sweeps links pointing at the removed id and kicks a `refreshList` so the sidebar decoration disappears in one render.
  • ✅ `tickets:listProviders` reads from the store snapshot now.
  • ✅ `trackedFetch` aliased to `recordedFetch` in notion.ts.
  • ✅ `worktree:remove` IPC handler routes the link drop through the named `setWorktreeTicketLink` helper.

Tests

  • ✅ `WorktreesFSM.refreshList` link-decoration + prune-callback contract.
  • ✅ `validateProviderConfig` across both provider types (good / malformed / cross-shape / unknown fields / non-object inputs).

714 tests pass on the branch.

Comment left on behalf of @frenchie4111 by Claude via Harness.

@frenchie4111
frenchie4111 merged commit 8662743 into feat/tickets Jun 19, 2026
1 check passed
frenchie4111 added a commit that referenced this pull request Jun 19, 2026
Conflicts in build-backend.ts + types.ts — resolved by keeping the
nested `tickets: TicketsAPI` namespace the UI consumes (backed by the
in-renderer stub) and dropping the flat `ticketsListProviders` /
`ticketsLinkRepoProvider` / etc. methods the data PR added. The data
slice models per-repo M2M (`linkRepoProvider` / `unlinkRepoProvider` +
`RepoConfig.ticketProviderIds`); the UI here drives the inverted
"provider owns its project membership" model (`appliesToRepoRoots`).
Reconciling — extending the data slice with `appliesToRepoRoots` and
deleting the M2M side-table — is the follow-up tracked in the
build-backend.ts comment block; once that lands, the stub goes away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant