Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Merge past a rule that is holding a pull request back.** Once GitHub answers
that a rule on the base branch stands in the way, gh refuses the merge without
ever calling GitHub — so there was nothing lich could do with that pull request
but leave for the browser. The Merge menu now offers the same merge as a
bypass, GitHub's own administrator override, and says so in every label it
offers. It appears only where an override can actually help — a rule holding
the merge, or a base branch that has moved — never over a conflict or a draft,
which no override resolves, and never for an account that does not administer
the repository, so a bypass on offer is one that will go through.

### Changed

- **A refused merge says which kind of refusal it was.** gh writes one sentence
for two very different failures and puts the difference in its tail; lich read
only the opening and claimed conflicts either way, sending you to look for
conflicts a pull request did not have. A conflict now says so, and a rule on
the base branch says that instead.
- **A merge blocked by a commit rule names the rule.** An approved pull request
with green checks and no conflicts could still be refused, with nothing on
screen accounting for it: the cause was a ruleset testing the *commit message*
the merge would write, which no field of a pull request mentions. Where the
base branch carries one, the Merge button and the failure now name what it
requires — the message, author or committer email patterns, and what each one
expects.

## [0.23.0] - 2026-07-30

### Added
Expand Down
45 changes: 37 additions & 8 deletions frontend/src/components/pulls/PullRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import {
subscribePendingReview,
} from "@/lib/pulls/pending-review-store"
import { conversationCount, conversationTimeline } from "@/lib/pulls/conversation-timeline"
import { allowedMergeMethods, mergeBlockedReason } from "@/lib/pulls/merge-gate"
import {
allowedMergeMethods,
canAdminOverride,
mergeBlockedReason,
mergeRuleNote,
} from "@/lib/pulls/merge-gate"
import { useBranchRules } from "@/lib/pulls/use-branch-rules"
import { cn, errorText } from "@/lib/utils"
import { Markdown } from "@/components/Markdown"
Expand Down Expand Up @@ -128,15 +133,20 @@ export function PullRequestView({
const blocked = mergeBlockedReason(detail)
const methods = allowedMergeMethods(rules)
const editableMethods = methods.filter((method) => method !== "rebase")
const ruleNote = mergeRuleNote(detail, rules)
const bypass = canAdminOverride(detail, rules)

const merge = async (method: MergeMethod, subject = "", body = "") => {
const merge = async (method: MergeMethod, subject = "", body = "", admin = false) => {
setMerging(true)
try {
await ProjectService.MergePullRequest(path, detail.number, method, subject, body)
await ProjectService.MergePullRequest(path, detail.number, method, subject, body, admin)
setEdit(null)
onMerged()
} catch (err: unknown) {
toast.error(`Merge failed: ${errorText(err)}`)
// gh's refusal names a kind of cause, never the rule behind it; the note
// is the only thing on screen that can, so it rides along when there is
// one to say.
toast.error([`Merge failed: ${errorText(err)}`, ruleNote].filter(Boolean).join(" "))
} finally {
setMerging(false)
}
Expand Down Expand Up @@ -281,11 +291,11 @@ export function PullRequestView({
blocking, the same spot carries what would make you think twice
— red CI that no rule requires, which GitHub merges over
without a word. */}
<span title={blocked ?? failingChecks(detail.checks.failed)}>
<span title={blocked ?? ruleNote ?? failingChecks(detail.checks.failed)}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="sm" disabled={merging || blocked !== null}>
<Button size="sm" disabled={merging || (blocked !== null && !bypass)}>
<GitMerge />
{merging ? "Merging…" : "Merge"}
{/* The checks readout says this too, a line above — but
Expand All @@ -301,10 +311,28 @@ export function PullRequestView({
</Button>
}
/>
{/* Every item bypasses, or none does — never a menu offering
both. Once GitHub answers BLOCKED or BEHIND gh refuses a
plain merge from the client without ever calling GitHub, so
where the bypass is available the ordinary items are already
dead and keeping them would put the click that cannot work
beside the one that can. Without the privilege the dead
click stays, on purpose: it is what carries gh's refusal and
the rule note back (merge-gate.ts).

The consequence to know: BLOCKED is what GitHub answers for
any governed base branch, rules met or not, so an
administrator's ordinary approved merge goes out with
--admin here and GitHub records it as an override. gh leaves
no other way through. */}
<DropdownMenuContent align="end">
{methods.map((method) => (
<DropdownMenuItem key={method} onClick={() => void merge(method)}>
<DropdownMenuItem
key={method}
onClick={() => void merge(method, "", "", bypass)}
>
{MERGE_LABELS[method]}
{bypass && ", bypass rules"}
</DropdownMenuItem>
))}
{/* Rebase replays the branch's own commits, so gh takes no
Expand All @@ -316,6 +344,7 @@ export function PullRequestView({
onClick={() => setEdit(mergeEditFor(method, detail))}
>
{MERGE_LABELS[method]}, edit message…
{bypass && " (bypasses rules)"}
</DropdownMenuItem>
))}
</DropdownMenuContent>
Expand Down Expand Up @@ -442,7 +471,7 @@ export function PullRequestView({
merging={merging}
onChange={setEdit}
onCancel={() => setEdit(null)}
onConfirm={() => void merge(edit.method, edit.subject, edit.body)}
onConfirm={() => void merge(edit.method, edit.subject, edit.body, bypass)}
/>
)}
</>
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ export interface BranchRules {
* about it", which is also what an unreadable answer reports — so an empty
* list must always widen the menu, never empty it. */
allowedMergeMethods: string[] | null
/** The rules that read a commit's own text. Empty means "no rule of this
* kind" — never "nothing blocks this merge". */
commitPatterns: CommitPattern[] | null
/** This account administers the repository, and so may merge past the rules
* above. The one field here that fails closed: anything short of a clear yes
* is false, so the bypass is hidden rather than offered and refused. */
viewerCanBypass: boolean
}

/** internal/project.CommitPattern — one ruleset rule testing the text of a
* commit rather than the state of a pull request. The one class of rule
* nothing on a pull request hints at, and so the one worth naming on screen. */
export interface CommitPattern {
/** What the rule reads: "message" | "author email" | "committer email". */
target: string
/** GitHub's spelling: regex | starts_with | ends_with | contains. */
operator: string
pattern: string
/** The pattern is what the text must *not* be. */
negate: boolean
}

/** internal/project.CheckItem — one check of the PR's rollup, for the Checks tab. */
Expand Down
139 changes: 132 additions & 7 deletions frontend/src/lib/pulls/merge-gate.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { describe, expect, it } from "vitest"
import { allowedMergeMethods, mergeBlockedReason } from "./merge-gate"
import type { PullRequestDetail } from "@/lib/api-types"
import {
allowedMergeMethods,
canAdminOverride,
mergeBlockedReason,
mergeRuleNote,
} from "./merge-gate"
import type { BranchRules, PullRequestDetail } from "@/lib/api-types"

const rules = (over: Partial<BranchRules> = {}): BranchRules => ({
allowedMergeMethods: null,
commitPatterns: null,
viewerCanBypass: false,
...over,
})

const admin = (over: Partial<BranchRules> = {}) => rules({ viewerCanBypass: true, ...over })

const detail = (over: Partial<PullRequestDetail> = {}): PullRequestDetail => ({
number: 130,
Expand Down Expand Up @@ -116,11 +130,11 @@ describe("mergeBlockedReason", () => {

describe("allowedMergeMethods", () => {
it("narrows the menu to what the base branch's ruleset accepts", () => {
expect(allowedMergeMethods({ allowedMergeMethods: ["squash"] })).toEqual(["squash"])
expect(allowedMergeMethods(rules({ allowedMergeMethods: ["squash"] }))).toEqual(["squash"])
})

it("keeps the menu's own order, not the ruleset's", () => {
expect(allowedMergeMethods({ allowedMergeMethods: ["rebase", "squash"] })).toEqual([
expect(allowedMergeMethods(rules({ allowedMergeMethods: ["rebase", "squash"] }))).toEqual([
"squash",
"rebase",
])
Expand All @@ -134,9 +148,120 @@ describe("allowedMergeMethods", () => {
// The call failed, or has not answered yet.
expect(allowedMergeMethods(null)).toEqual(all)
// The branch has no ruleset, or one that says nothing about merging.
expect(allowedMergeMethods({ allowedMergeMethods: [] })).toEqual(all)
expect(allowedMergeMethods({ allowedMergeMethods: null })).toEqual(all)
expect(allowedMergeMethods(rules({ allowedMergeMethods: [] }))).toEqual(all)
expect(allowedMergeMethods(rules({ allowedMergeMethods: null }))).toEqual(all)
// A ruleset naming only methods this build cannot ask gh for.
expect(allowedMergeMethods({ allowedMergeMethods: ["fast_forward"] })).toEqual(all)
expect(allowedMergeMethods(rules({ allowedMergeMethods: ["fast_forward"] }))).toEqual(all)
})
})

describe("canAdminOverride", () => {
// The two gh itself allows --admin on; anything else and gh refuses the flag
// as well, so offering it would trade one dead click for another.
it("offers the bypass on the states GitHub lets an administrator past", () => {
expect(canAdminOverride(detail({ mergeStateStatus: "BLOCKED" }), admin())).toBe(true)
expect(canAdminOverride(detail({ mergeStateStatus: "BEHIND" }), admin())).toBe(true)
})

// The rest of this module widens on an unknown; this one hides. A bypass
// reads as a privilege you hold, so offering one that is not there turns
// GitHub's refusal into what looks like a broken button.
it("hides the bypass from an account that does not administer the repository", () => {
const blocked = detail({ mergeStateStatus: "BLOCKED" })
expect(canAdminOverride(blocked, rules())).toBe(false)
// The rules have not answered yet, or the read failed.
expect(canAdminOverride(blocked, null)).toBe(false)
})

it("does not offer it where nothing is in the way", () => {
expect(canAdminOverride(detail(), admin())).toBe(false)
expect(canAdminOverride(detail({ mergeStateStatus: "UNSTABLE" }), admin())).toBe(false)
})

// An override skips a rule; it does not resolve a conflict or finish a draft.
it("does not offer it for what an override cannot fix", () => {
expect(
canAdminOverride(detail({ mergeStateStatus: "DIRTY", mergeable: "CONFLICTING" }), admin()),
).toBe(false)
// The conflict a stale merge state hides — BLOCKED is on the override list,
// and only the second field says there is nothing to override.
expect(
canAdminOverride(detail({ mergeStateStatus: "BLOCKED", mergeable: "CONFLICTING" }), admin()),
).toBe(false)
expect(canAdminOverride(detail({ isDraft: true, mergeStateStatus: "BLOCKED" }), admin())).toBe(
false,
)
expect(
canAdminOverride(detail({ state: "MERGED", mergeStateStatus: "BLOCKED" }), admin()),
).toBe(false)
})
})

describe("mergeRuleNote", () => {
const pattern = {
target: "message",
operator: "regex",
pattern: "^(feat|fix): .+",
negate: false,
}

// The case the note exists for: approved, green, no conflict, still BLOCKED.
it("names the commit rule governing the base branch", () => {
expect(
mergeRuleNote(
detail({ mergeStateStatus: "BLOCKED", reviewDecision: "APPROVED", baseRefName: "develop" }),
rules({ commitPatterns: [pattern] }),
),
).toBe("A ruleset governs develop: every commit message must match ^(feat|fix): .+.")
})

it("reads a negated rule as the prohibition it is", () => {
expect(
mergeRuleNote(
detail({ mergeStateStatus: "BLOCKED" }),
rules({
commitPatterns: [
{ target: "author email", operator: "ends_with", pattern: "@ex.com", negate: true },
],
}),
),
).toBe("A ruleset governs main: every commit author email must not end with @ex.com.")
})

it("names every rule when the branch carries more than one", () => {
const note = mergeRuleNote(
detail({ mergeStateStatus: "BLOCKED" }),
rules({
commitPatterns: [
pattern,
{ target: "committer email", operator: "contains", pattern: "@", negate: false },
],
}),
)
expect(note).toContain("message must match ^(feat|fix): .+")
expect(note).toContain("committer email must contain @")
})

// An operator GitHub adds after this build shipped still gets its pattern on
// screen — a slightly wrong verb beats no note at all.
it("falls back to a verb for an operator it has never seen", () => {
expect(
mergeRuleNote(
detail({ mergeStateStatus: "BLOCKED" }),
rules({ commitPatterns: [{ ...pattern, operator: "SOMETHING_NEW" }] }),
),
).toContain("message must match ^(feat|fix): .+")
})

it("says nothing when GitHub is not holding the merge back", () => {
expect(mergeRuleNote(detail(), rules({ commitPatterns: [pattern] }))).toBeNull()
})

// A rule note is not a verdict: no rule of this kind never means "unblocked".
it("says nothing when no rule of this kind is known", () => {
const blocked = detail({ mergeStateStatus: "BLOCKED" })
expect(mergeRuleNote(blocked, null)).toBeNull()
expect(mergeRuleNote(blocked, rules())).toBeNull()
expect(mergeRuleNote(blocked, rules({ commitPatterns: [] }))).toBeNull()
})
})
70 changes: 69 additions & 1 deletion frontend/src/lib/pulls/merge-gate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { BranchRules, MergeMethod, PullRequestDetail } from "@/lib/api-types"
import type { BranchRules, CommitPattern, MergeMethod, PullRequestDetail } from "@/lib/api-types"

// Every method lich knows how to ask gh for, in the order the menu offers them.
const EVERY_METHOD: MergeMethod[] = ["squash", "merge", "rebase"]
Expand Down Expand Up @@ -65,3 +65,71 @@ export function mergeBlockedReason(detail: PullRequestDetail): string | null {
}
return null
}

// The merge states an administrator override gets past, which is gh's own
// allowsAdminOverride — pass --admin on anything else and gh refuses from the
// client anyway. A conflict is absent on purpose: the override skips a rule, it
// does not compute a merge that has no answer.
const OVERRIDABLE = ["BLOCKED", "BEHIND"]

// canAdminOverride reports whether the Merge menu should offer to bypass the
// rules on the base branch: something has to be in the way that an override
// actually clears, and the account has to hold the privilege.
//
// This is the one place the menu does *not* widen on an unknown. Everything
// else here would rather offer an option GitHub turns down than drop one it
// would have taken, because a refusal at least says what happened — but a
// bypass is read as a permission you have, so offering one that is not there
// makes GitHub's refusal look like a broken button. Absent is the honest shape
// of "not yours"; the backend answers the same way (branchrules.go).
export function canAdminOverride(detail: PullRequestDetail, rules: BranchRules | null): boolean {
if (!rules?.viewerCanBypass) {
return false
}
if (detail.state !== "OPEN" || detail.isDraft) {
return false
}
// Both fields again, for the reason mergeBlockedReason reads both: GitHub
// answers BLOCKED over a conflict while its merge state is still stale, and a
// bypass offered there is the dead click this whole gate exists to remove.
if (detail.mergeable === "CONFLICTING") {
return false
}
return OVERRIDABLE.includes(detail.mergeStateStatus)
}

// GitHub's pattern operators in the words a sentence needs. An operator this
// build has never seen falls back to "match": the pattern beside it still says
// more than no note at all.
const PATTERN_VERBS: Record<string, string> = {
regex: "match",
starts_with: "start with",
ends_with: "end with",
contains: "contain",
}

function describePattern(rule: CommitPattern): string {
const verb = PATTERN_VERBS[rule.operator] ?? "match"
return `${rule.target} must ${rule.negate ? "not " : ""}${verb} ${rule.pattern}`
}

// mergeRuleNote names what governs the base branch when GitHub answers BLOCKED
// and nothing on the pull request accounts for it — the approved, green,
// conflict-free case, where the cause is a rule read against the commit the
// merge would write and no field on screen mentions it.
//
// It is a note, never a verdict: it names the rules that exist, not the one
// that fired. GitHub publishes no "why is this BLOCKED", so the honest answer
// is where to look — and re-running the pattern here would be a guess dressed
// as an answer, in a regex dialect that is not the one GitHub evaluates.
export function mergeRuleNote(detail: PullRequestDetail, rules: BranchRules | null): string | null {
if (detail.mergeStateStatus !== "BLOCKED") {
return null
}
const patterns = rules?.commitPatterns ?? []
if (patterns.length === 0) {
return null
}
const clauses = patterns.map(describePattern).join("; ")
return `A ruleset governs ${detail.baseRefName}: every commit ${clauses}.`
}
Loading