MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-2922

Capture a merged PR's changed file paths and its merge instant — the substrate a subsumption check needs, and the one `github_pull_request` does not carry

Done
Description

Repo: motir-core. Surfaced by motir run MOTIR-2903 under run.md guard #4 (the "X doesn't exist" claim gate). MOTIR-2903's recommended mechanism reads "a merged PR's diff touches every path its acceptance criteria name" and adds "the channel already computes description↔graph references; this is the same read against the PR set." It is not the same read: the PR set carries no diff. This card supplies the substrate; MOTIR-2903 then builds the advisory on it.

The absence, verified against shipped reality (rung 2, on origin/main @ 64fb9e6e)

  • prisma/schema.prismaGithubPullRequest carries provider · repoId · number · state · merged · headRef · title · workItemId · linkedManually · createdAt · updatedAt. No path column, and no merge INSTANT (merged is a boolean; updatedAt moves on any later delivery, so it cannot answer "did this land after that card was filed?"). A scan of all 82 models finds no table holding a pull request's files. The one model in the schema that stores repository paths is DesignAsset.sourcePath, and those paths are pushed by CI through POST /api/work-items/[id]/design-evidence — never read back from a pull request.
  • lib/repositories/githubPullRequestRepository.ts — nine methods (findByRepoAndNumber, lockByRepoAndNumber, findByRepoAndHeadRef, countOtherOpenByWorkItem, listByWorkItemWithContext, findByIdWithInstallation, searchCandidates, setWorkItemLink, upsert). None returns a path.
  • lib/github/ — twenty read leaves, none of which calls GET /repos/{owner}/{repo}/pulls/{n}/files. historicalPullRequests.ts is the closest shape and the model to copy: an installation-authenticated, paginated, rate-limit-aware REST read that normalizes PR metadata only.

Why the plan-side substitute is ruled out, and by which measurement

The obvious cheaper answer is to compare the subsumed card's prose against the COVERING card's prose and never call GitHub at all. That answer is dead on the canonical fixture, and the measurement is short enough to repeat:

  • The card that actually swept up MOTIR-2757 is MOTIR-2846 ("bind the production call sites the scanner reports in-scope"). Its description — title, body, acceptance criteria, context refs — contains zero occurrences of workflowsService, getWorkflow, listStatusesByProject, getStatusByKey, or lib/services/workflowsService.ts. Its parent story MOTIR-2796 contains none either, and none of 2796's twenty-one children names a workflow read.
  • The merged DIFF does: c99efdc7 (PR #2059, 2026-08-15) touches lib/services/workflowsService.ts, three days after MOTIR-2757 was filed and two days before a run claimed it.
  • So the fact that connects the two cards exists only in the diff. Any check reading solely the plan is blind to it by construction, which makes MOTIR-2903's criterion 4 ("MOTIR-2757's state on 2026-08-16 fires the check") unsatisfiable without this card.

What to build

  1. GithubPullRequest.mergedAt DateTime? — stamped from the webhook payload's pull_request.merged_at on the delivery that reports the merge, left null otherwise. This is the ordering fact a subsumption check needs; updatedAt cannot serve, because a later check_suite-driven upsert moves it.
  2. GithubPullRequest.changedPaths String[] — the repo-relative paths the merge touched, capped (a MAX_CAPTURED_PR_PATHS constant; a PR past the cap stores the first N and sets a changedPathsTruncated Boolean, so a consumer can never read a partial set as a complete one — the no-silent-caps rule). A column on the existing row rather than a child table, deliberately: the shipped github_pull_request RLS policy then covers it unchanged, and no consumer wants a path without its PR.
  3. lib/github/pullRequestFiles.ts — a new read leaf on historicalPullRequests.ts's exact shape: installation-authenticated, per_page=100, bounded page walk, rate-limit backoff, a typed error class, and a page cap that REPORTS truncation rather than stopping quietly.
  4. The capture pointgithubWebhookService's pull_request handler, on the transition into merged. Best-effort and post-commit, on enqueueCodeGraphIndex's precedent (lib/github/indexEnqueue.ts): the status sync is the load-bearing effect of that delivery and a GitHub blip must never fail or roll it back. A failed capture is swallowed, logged, and leaves changedPaths empty.
  5. A repository accessorfindMergedTouchingPaths(workspaceId, paths, since, excludeWorkItemId, tx) (name it as you like), one query, returning the merged rows whose changedPaths intersect paths and whose mergedAt is after since. This is the single read MOTIR-2903 consumes, and it belongs here so that card adds no data access of its own.

Acceptance criteria

  • A pull_request delivery reporting a merge stores that PR's changed paths and its merge instant on the github_pull_request row; a delivery for an OPEN pull request stores neither. Both directions asserted against a real Postgres row, not against the fetcher's return value.
  • The GitHub fetch is stubbed at the transport in tests (the undici MockAgent convention the sibling GitHub suites use) and asserted to request /repos/{owner}/{repo}/pulls/{number}/files with the installation token — never the app JWT.
  • A fetch that throws, times out, or returns a non-2xx leaves the row's status-sync outcome byte-identical to a delivery where the fetch succeeded, with changedPaths empty. Assert the transition the sync performed, not merely that no exception escaped — a swallowed error that also swallowed the sync is the failure mode.
  • A pull request whose file list exceeds MAX_CAPTURED_PR_PATHS stores exactly that many paths and sets the truncation flag; a pull request under the cap stores every path and leaves the flag false.
  • The repository accessor returns a merged row whose changedPaths contains a queried path and whose mergedAt is later than the since argument, and omits: a row merged before since, a row whose paths do not intersect, an OPEN row, and the row linked to excludeWorkItemId. One case per omission, so a single over-broad WHERE cannot pass them all.
  • prisma migrate diff --from-schema … --to-config-datasource --exit-code reports no drift after the migration — the build job's own gate, run locally before the pull request opens.
  • Existing rows are unaffected: changedPaths defaults to empty and mergedAt to null, and no shipped consumer of GithubPullRequest changes behaviour. Assert the Development-surface DTO's shape is unchanged.

Out of scope

  • The subsumption advisory itself — that is MOTIR-2903, which this card unblocks. Add no advisory family, no DTO variant, and no renderer here.
  • Backfilling already-merged rows. MOTIR-2903's fixtures seed rows directly, so it needs no backfill to be built or tested. If a live retro-check is wanted later it is an operator script on scripts/'s existing pattern, and its own card.
  • GitLab. historicalPullRequests.ts records why a GitHub-specific read leaf sits beside the GitProvider seam rather than inside it; the same reasoning holds here.

Context refs

  • prisma/schema.prisma — the GithubPullRequest model (~L4262) and its @@unique([repoId, number]).
  • lib/github/historicalPullRequests.ts — the read leaf to mirror: pagination, retryDelayMs, MAX_PULL_REQUEST_PAGES, the typed error, and the header explaining why it is a leaf and not a seam method.
  • lib/github/indexEnqueue.ts — the best-effort, post-commit side-effect precedent, and its PROD-443 reasoning for why a transport call must not be able to fail a committed mutation.
  • lib/services/githubWebhookService.ts — the pull_request handler and changeRequestStatusSync, the effect that must stay unaffected.
  • lib/repositories/githubPullRequestRepository.ts — where the accessor lands, and the countOtherOpenByWorkItem shape to follow.
  • MOTIR-2903 — the consumer, and the incident that made the gap visible.