mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user