fix(mobile): use desktop's merge gating and PR link rules in the PR panel (#33982)

* fix(mobile): gate PR merge and linking on desktop's shared rules

Merge state moves to src/shared so the phone stops offering Merge on drafts,
conflicts and blocked PRs, and stops letting branch PRs override explicit links.

* fix(mobile): keep stack merges and stale-link recovery in the PR panel

Mobile now applies desktop's stack override and drops a linked PR the branch has moved
past, and only offers Ready for review when a relaying desktop also forwards it.

* fix(mobile): persist stale PR link clears and add a merge recheck

The phone now writes desktop's link clear instead of rereading the stale link, and a
blocked Merge can be rechecked since nothing on mobile polls PR state.

* fix(mobile): guard stale PR link clears like desktop

Re-reads the worktree before clearing so a link chosen after the lookup survives, and
localizes the merge-state strings the lift exposed to the coverage gate.

* test(mobile): drop identity test for worktree link params

* refactor(mobile): share one stack-merge decision and trim PR panel wiring

* fix(mobile): default worktree bare/archived flags for the stale-link guard

* fix(mobile): label the PR merge button from its default merge method
This commit is contained in:
Kelvin Amoaba
2026-10-27 02:12:00 +00:00
committed by GitHub
parent 9c0d30df5e
commit 89e272abc5
70 changed files with 21332 additions and 17573 deletions
+83
View File
@@ -0,0 +1,83 @@
import type { PRInfo } from './pull-request-types'
import type { Worktree } from '../worktree/types'
export function shouldClearDivergedLinkedMergedPR(args: {
pr: PRInfo | null
linkedPRNumber: number | null
requestHeadOid: string | null
}): boolean {
const { pr, linkedPRNumber, requestHeadOid } = args
return (
linkedPRNumber != null &&
requestHeadOid !== null &&
pr?.number === linkedPRNumber &&
pr.state === 'merged' &&
// Head-scoped: clear only the worktree whose head diverged, so a PR-number-coalesced broadcast can't clear a sibling still on the PR's line of work.
pr.headDivergedFromMergedPRAtOid === requestHeadOid &&
pr.headSha !== requestHeadOid &&
pr.confirmedContainedHeadOid !== requestHeadOid
)
}
// Clear linked PR after branch switch only when neither push target nor HEAD matches its head; otherwise Checks remains pinned to old work.
export function shouldClearBranchMismatchedLinkedOpenPR(args: {
pr: PRInfo | null
linkedPRNumber: number | null
branch: string
requestHeadOid: string | null
pushTargetBranch: string | null
}): boolean {
const { pr, linkedPRNumber, branch, requestHeadOid, pushTargetBranch } = args
const headRefName = pr?.headRefName?.trim() ?? ''
const currentBranch = branch.replace(/^refs\/heads\//, '').trim()
return (
linkedPRNumber != null &&
pr?.number === linkedPRNumber &&
// Draft reviews are open PRs too; don't let their distinct renderer state leave a stale durable link wedged after a branch switch.
(pr.state === 'open' || pr.state === 'draft') &&
requestHeadOid !== null &&
headRefName !== '' &&
currentBranch !== '' &&
headRefName !== currentBranch &&
(pushTargetBranch === null || pushTargetBranch !== headRefName) &&
// A worktree parked on the PR's head commit is the same line of work (e.g. renamed local branch); keep the link.
!(pr.headSha != null && pr.headSha === requestHeadOid)
)
}
export function shouldApplyDivergedLinkedPRClear(args: {
worktree: Pick<Worktree, 'linkedPR' | 'branch' | 'head' | 'isBare' | 'isArchived'> | undefined
linkedPRNumber: number
branch: string
requestHeadOid: string | null
}): boolean {
const { worktree, linkedPRNumber, branch, requestHeadOid } = args
return (
Boolean(worktree) &&
requestHeadOid !== null &&
worktree?.linkedPR === linkedPRNumber &&
worktree.branch.replace(/^refs\/heads\//, '') === branch &&
worktree.head === requestHeadOid &&
worktree.isBare !== true &&
worktree.isArchived !== true
)
}
export function shouldApplyBranchMismatchedLinkedPRClear(args: {
worktree: Pick<Worktree, 'linkedPR' | 'branch' | 'head' | 'isBare' | 'isArchived'> | undefined
linkedPRNumber: number
branch: string
requestHeadOid: string | null
}): boolean {
const { worktree, linkedPRNumber, branch, requestHeadOid } = args
return (
Boolean(worktree) &&
requestHeadOid !== null &&
worktree?.linkedPR === linkedPRNumber &&
// Branch-scoped: clear only while still on the branch the mismatch was computed against; a newer switch re-validates.
worktree.branch.replace(/^refs\/heads\//, '') === branch.replace(/^refs\/heads\//, '') &&
worktree.head === requestHeadOid &&
worktree.isBare !== true &&
worktree.isArchived !== true
)
}
@@ -0,0 +1,136 @@
import type { CheckStatus, ProviderCheckSummary } from './pull-request-types'
import {
canShowGitHubPRAutoMergeControl,
type GitHubPRAutoMergeAvailabilityInput
} from './pull-request-auto-merge-availability'
export type GitHubPRMergeStateInput = GitHubPRAutoMergeAvailabilityInput & {
checksStatus?: CheckStatus
checksSummary?: ProviderCheckSummary
}
export type GitHubPRMergeStateKind =
| 'merged'
| 'closed'
| 'draft'
| 'review_required'
| 'changes_requested'
| 'conflicts'
| 'merge_queue'
| 'merge_queue_auto_merge_on'
| 'merge_status_unavailable'
| 'behind'
| 'blocked'
| 'mergeable_checks_failed'
| 'mergeable_checks_pending'
| 'mergeable_checks_passed'
| 'mergeable'
| 'checks_passed'
| 'checking'
export type GitHubPRAutoMergeActionIntent =
| 'disable_auto_merge'
| 'merge_when_ready'
| 'enable_auto_merge'
export type GitHubPRMergeStateDecision = {
kind: GitHubPRMergeStateKind
directMergeAvailable: boolean
autoMergeAction: GitHubPRAutoMergeActionIntent | null
}
function checksState(item: GitHubPRMergeStateInput): CheckStatus | 'none' | undefined {
if (item.checksSummary) {
return item.checksSummary.state
}
return item.checksStatus
}
function hasFullMergeMetadata(item: GitHubPRMergeStateInput): boolean {
return item.mergeable !== undefined || item.mergeStateStatus !== undefined
}
function resolveAutoMergeAction(
item: GitHubPRMergeStateInput
): GitHubPRAutoMergeActionIntent | null {
if (!canShowGitHubPRAutoMergeControl(item)) {
return null
}
if (item.autoMergeEnabled === true) {
return 'disable_auto_merge'
}
if (item.mergeQueueRequired === true) {
return 'merge_when_ready'
}
return 'enable_auto_merge'
}
function resolveMergeStateKind(item: GitHubPRMergeStateInput): GitHubPRMergeStateKind {
if (item.state === 'merged') {
return 'merged'
}
if (item.state === 'closed') {
return 'closed'
}
if (item.state === 'draft') {
return 'draft'
}
if (item.reviewDecision === 'REVIEW_REQUIRED') {
return 'review_required'
}
if (item.reviewDecision === 'CHANGES_REQUESTED') {
return 'changes_requested'
}
if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY') {
return 'conflicts'
}
if (item.mergeQueueRequired === true) {
return item.autoMergeEnabled ? 'merge_queue_auto_merge_on' : 'merge_queue'
}
const checkState = checksState(item)
if (!hasFullMergeMetadata(item)) {
// Missing metadata can coexist with green checks; main-process preflight remains the merge authority.
return checkState === 'success' ? 'checks_passed' : 'merge_status_unavailable'
}
if (item.mergeStateStatus === 'BEHIND') {
return 'behind'
}
if (item.mergeStateStatus === 'BLOCKED') {
return 'blocked'
}
if (
item.mergeable === 'MERGEABLE' ||
item.mergeStateStatus === 'CLEAN' ||
item.mergeStateStatus === 'UNSTABLE' ||
item.mergeStateStatus === 'HAS_HOOKS'
) {
if (checkState === 'failure') {
return 'mergeable_checks_failed'
}
if (checkState === 'pending') {
return 'mergeable_checks_pending'
}
return checkState === 'success' ? 'mergeable_checks_passed' : 'mergeable'
}
// Main-process preflight rechecks blockers when GitHub mergeability is still intermediate despite green checks.
return checkState === 'success' ? 'checks_passed' : 'checking'
}
const DIRECT_MERGE_KINDS: ReadonlySet<GitHubPRMergeStateKind> = new Set([
'mergeable_checks_failed',
'mergeable_checks_pending',
'mergeable_checks_passed',
'mergeable',
'checks_passed'
])
export function resolveGitHubPRMergeState(
item: GitHubPRMergeStateInput
): GitHubPRMergeStateDecision {
const kind = resolveMergeStateKind(item)
return {
kind,
directMergeAvailable: DIRECT_MERGE_KINDS.has(kind),
autoMergeAction: resolveAutoMergeAction(item)
}
}
@@ -0,0 +1,104 @@
import type { GitHubPRStack, GitHubPRStackEntry } from './pull-request-types'
export type GitHubPRStackMergeScope = {
count: number
complete: boolean
entries: GitHubPRStackEntry[]
}
export type GitHubPRStackMergeBlocker = {
reason:
| 'draft'
| 'closed'
| 'conflicts'
| 'changes_requested'
| 'review_required'
| 'behind'
| 'blocked'
prNumber: number
}
export function isGitHubPRStackMergeQueueRequired(
reviewMergeQueueRequired: boolean | null | undefined,
stackMergeQueueRequired: boolean | null | undefined
): boolean {
return reviewMergeQueueRequired === true || stackMergeQueueRequired === true
}
export function getGitHubPRStackMergeScope(stack: GitHubPRStack): GitHubPRStackMergeScope {
const entries = [...(stack.entries ?? [])]
.filter((entry) => entry.position <= stack.position)
.sort((a, b) => a.position - b.position)
const count = stack.position
const complete =
entries.length === count && entries.every((entry, index) => entry.position === index + 1)
return { count, complete, entries }
}
function stackEntryBlockerReason(
entry: GitHubPRStackEntry
): GitHubPRStackMergeBlocker['reason'] | null {
if (entry.state === 'draft') {
return 'draft'
}
if (entry.state === 'closed') {
return 'closed'
}
if (entry.mergeable === 'CONFLICTING' || entry.mergeStateStatus === 'DIRTY') {
return 'conflicts'
}
if (entry.reviewDecision === 'CHANGES_REQUESTED') {
return 'changes_requested'
}
if (entry.reviewDecision === 'REVIEW_REQUIRED') {
return 'review_required'
}
if (entry.mergeStateStatus === 'BEHIND') {
return 'behind'
}
if (entry.mergeStateStatus === 'BLOCKED') {
return 'blocked'
}
return null
}
export function getGitHubPRStackMergeBlocker(
scope: GitHubPRStackMergeScope
): GitHubPRStackMergeBlocker | null {
for (const entry of scope.entries) {
const reason = stackEntryBlockerReason(entry)
if (reason) {
return { reason, prNumber: entry.number }
}
}
return null
}
export type GitHubPRStackMergeDecision = {
scope: GitHubPRStackMergeScope
blocker: GitHubPRStackMergeBlocker | null
usesMergeQueue: boolean
directMergeAvailable: boolean
}
// A stack merge lands every PR below this one, so the stack's readiness replaces this PR's merge state.
export function resolveGitHubPRStackMergeDecision(args: {
stack: GitHubPRStack
reviewMergeQueueRequired: boolean | null | undefined
stackMergeQueueRequired: boolean | null | undefined
directMergeAvailable: boolean
}): GitHubPRStackMergeDecision {
const scope = getGitHubPRStackMergeScope(args.stack)
const blocker = getGitHubPRStackMergeBlocker(scope)
const usesMergeQueue = isGitHubPRStackMergeQueueRequired(
args.reviewMergeQueueRequired,
args.stackMergeQueueRequired
)
return {
scope,
blocker,
usesMergeQueue,
directMergeAvailable:
!blocker && (scope.complete || args.directMergeAvailable || usesMergeQueue)
}
}