Files
orca/src/main/github/merged-pr-commit-membership.ts
T
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00

147 lines
5.7 KiB
TypeScript

import { ghExecFileAsync } from './gh-utils'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit'
import { githubHostExecOptions } from './github-api-repository'
import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key'
import type { OwnerRepo } from './github-repository-identity'
type GhExecOptions = Parameters<typeof ghExecFileAsync>[1]
// Why: a merged PR's commit set is immutable, so definitive answers — member
// or not — never change; that TTL only bounds memory. Errors (a commit not on
// GitHub yet, network) get a short TTL so a future push can flip the answer
// without the checks-panel poll re-probing every cycle.
const MEMBERSHIP_CACHE_MAX_ENTRIES = 200
const MEMBERSHIP_DEFINITIVE_TTL_MS = 6 * 60 * 60 * 1000
const MEMBERSHIP_ERROR_TTL_MS = 5 * 60 * 1000
const COMMIT_PULLS_PAGE_SIZE = 100
// Why: a worktree HEAD is associated with ~1 PR, so page 1 is short in practice
// and this cap is never reached; it only bounds the pathological case of a commit
// linked to hundreds of PRs, where staying 'unknown' is the safe answer.
const COMMIT_PULLS_MAX_PAGES = 5
export type MergedPRCommitMembership = 'contained' | 'not-contained' | 'unknown'
const membershipCache = new Map<string, { value: MergedPRCommitMembership; expiresAt: number }>()
function pruneMergedPRCommitMembershipCache(now = Date.now()): void {
for (const [cacheKey, cached] of membershipCache) {
if (cached.expiresAt <= now) {
membershipCache.delete(cacheKey)
}
}
while (membershipCache.size > MEMBERSHIP_CACHE_MAX_ENTRIES) {
const oldestKey = membershipCache.keys().next().value
if (oldestKey === undefined) {
break
}
membershipCache.delete(oldestKey)
}
}
export function resetMergedPRCommitMembershipCacheForTest(): void {
membershipCache.clear()
}
/**
* Whether `commitOid` is part of pull request `prNumber` on GitHub — i.e. the
* commit belongs to that PR's history rather than merely sharing a branch name.
* A worktree sitting on such a commit is on the PR's own line of work (for
* example behind web-committed suggestions or an update-branch merge), not a
* reused branch name. Conservative on any failure: returns unknown.
*/
export async function isCommitPartOfMergedPR(args: {
ownerRepo: OwnerRepo
prNumber: number
commitOid: string
ghOptions: GhExecOptions
}): Promise<MergedPRCommitMembership> {
const oid = args.commitOid.trim().toLowerCase()
if (!/^[0-9a-f]{4,64}$/.test(oid) || !Number.isInteger(args.prNumber)) {
return 'unknown'
}
const owner = args.ownerRepo.owner
const repo = args.ownerRepo.repo
const cacheKey = `${githubRepoIdentityKey(args.ownerRepo)}#${args.prNumber}@${oid}`
const ghOptions = { ...args.ghOptions, ...githubHostExecOptions(args.ownerRepo) }
const now = Date.now()
pruneMergedPRCommitMembershipCache(now)
const cached = membershipCache.get(cacheKey)
if (cached && cached.expiresAt > now) {
return cached.value
}
// Why blocked stays unknown: hiding a transient branch match is safe, but
// callers must not clear a durable linked PR when the probe never ran.
if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) {
return 'unknown'
}
try {
// Why paginate: a full page that omits the target PR may just be truncated (a
// commit can belong to many PRs). Reading only page 1 and calling it
// 'not-contained' would wrongly clear a durable link; calling it 'unknown'
// would never clear one that genuinely diverged. Walk pages until the PR is
// found (contained) or a short page proves absence (not-contained); only the
// pathological all-full case up to the cap stays 'unknown'.
for (let page = 1; page <= COMMIT_PULLS_MAX_PAGES; page += 1) {
if (page > 1 && repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) {
membershipCache.set(cacheKey, {
value: 'unknown',
expiresAt: now + MEMBERSHIP_ERROR_TTL_MS
})
return 'unknown'
}
noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions)
const { stdout } = await ghExecFileAsync(
[
'api',
`repos/${owner}/${repo}/commits/${oid}/pulls?per_page=${COMMIT_PULLS_PAGE_SIZE}&page=${page}`
],
ghOptions
)
const parsed = JSON.parse(stdout) as unknown
// Why: a non-array success payload is a shape mismatch, not an empty page;
// caching it as definitive not-contained could wrongly clear a durable link.
if (!Array.isArray(parsed)) {
membershipCache.set(cacheKey, {
value: 'unknown',
expiresAt: now + MEMBERSHIP_ERROR_TTL_MS
})
return 'unknown'
}
const entries = parsed
const contained = entries.some(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
(entry as { number?: unknown }).number === args.prNumber
)
if (contained) {
membershipCache.set(cacheKey, {
value: 'contained',
expiresAt: now + MEMBERSHIP_DEFINITIVE_TTL_MS
})
return 'contained'
}
if (entries.length < COMMIT_PULLS_PAGE_SIZE) {
membershipCache.set(cacheKey, {
value: 'not-contained',
expiresAt: now + MEMBERSHIP_DEFINITIVE_TTL_MS
})
return 'not-contained'
}
}
membershipCache.set(cacheKey, {
value: 'unknown',
expiresAt: now + MEMBERSHIP_ERROR_TTL_MS
})
return 'unknown'
} catch {
// Why: 422 often means "new local work" today, but a later push can make
// the answer knowable; preserve durable links until a probe succeeds.
membershipCache.set(cacheKey, {
value: 'unknown',
expiresAt: now + MEMBERSHIP_ERROR_TTL_MS
})
return 'unknown'
}
}