Pull requests
S22 ships the PR core: open, view (Conversation / Commits / Files / Checks tabs), edit, close/reopen, draft → ready, three merge strategies (merge / squash / rebase), and auto-synchronize on push.
Schema (migration 0023)
pull_requests — keyed on issue_id (FK PK to issues)
pull_request_commits — head-side commit list, refreshed on synchronize
pull_request_files — changed-files list, refreshed on synchronize
Plus four columns on repos that S11 left to S22:
| Column | Default | Notes |
|---|---|---|
allow_squash_merge |
true |
UI hides the option when off |
allow_rebase_merge |
true |
UI hides the option when off |
allow_merge_commit |
true |
UI hides the option when off |
default_merge_method |
'merge' |
preselected in the merge form |
A CHECK constraint enforces at least one method enabled so the UI can never end up unable to merge.
A PR's title / body / state / comments / labels / milestones /
assignees live on the issues row (kind = 'pr'); the PR-only
fields (base/head refs, draft, mergeable_state, merge metadata) live
on pull_requests. Single number space, single comments table —
matches the S21 design.
Same-repo only (v1)
head_repo_id is on the table from day one but always equals
repo_id for v1. A CHECK constraint ensures base_ref <> head_ref
so a self-merge can't be opened. Cross-fork PRs land in S27.
Routes
| Route | Auth |
|---|---|
GET /{owner}/{repo}/pulls?state=open|closed |
public |
GET /{owner}/{repo}/pulls/{number} |
public |
GET /{owner}/{repo}/pulls/{number}/files |
public |
GET /{owner}/{repo}/pulls/{number}/commits |
public |
GET /{owner}/{repo}/pulls/{number}/checks |
public |
GET /{owner}/{repo}/pulls/new?base=…&head=… |
RequireUser |
POST /{owner}/{repo}/pulls |
RequireUser |
POST /{owner}/{repo}/pulls/{number}/edit |
RequireUser |
POST /{owner}/{repo}/pulls/{number}/state |
RequireUser |
POST /{owner}/{repo}/pulls/{number}/ready |
RequireUser |
POST /{owner}/{repo}/pulls/{number}/merge |
RequireUser |
POST /{owner}/{repo}/pulls/{number}/delete-branch |
RequireUser |
The pull-request list's "New pull request" button starts at
/{owner}/{repo}/compare, where the user picks base/head refs. Once
the head is ahead of base, compare links into
/pulls/new?base=...&head=.... /pulls/new redirects back to
compare when no head ref is supplied so the GitHub-style branch picker
remains the canonical entry point.
Auto-synchronize on head push
internal/worker/jobs/push_process.go is extended: after a push to
refs/heads/<X>, list every open PR with head_repo_id = repo_id
AND head_ref = X, and enqueue pr:synchronize for each one. The
synchronize job:
- Resolves base + head OIDs via
repogit.ResolveRefOID. - Refreshes
pull_request_commits+pull_request_files. - Resets
mergeable_statetounknown. - Emits a
synchronizedtimeline event with the new head OID. - Chains a
pr:mergeabilityjob.
Failure-modes are best-effort — push:process logs but doesn't block the push pipeline if PR enumeration / enqueue fails.
Mergeability detection (pr:mergeability)
Implemented via `git merge-tree --write-tree --no-messages
` (Git ≥ 2.38). Git auto-computes the merge base.| Exit | Meaning | mergeable_state |
|---|---|---|
| 0 | Clean merge (TreeOID printed) | clean |
| 1 | Conflicts (first line is tree OID, rest are paths) | dirty |
| ≥ 2 | Real error — wrapped + retried via worker backoff | unchanged |
A separate cheap check runs first: if git log base..head is empty
the head has no commits ahead and we set behind without invoking
merge-tree.
The earlier draft of this code passed --merge-base=<base> to
merge-tree, which sets the common ancestor to base itself and
therefore can never produce a conflict. That was wrong — fixed via
TestMergeability_Dirty.
Merge operation
internal/repos/git/mergeops.go::PerformMerge runs entirely
server-side in a temp worktree on the same volume as the bare repo
(S04 mandates /data/repos/ and tempdirs share the volume). The
worktree is created via git worktree add --detach <wt> <base_oid>
so the bare repo's branch refs aren't polluted; only the final
update-ref writes back.
Strategies:
- merge —
git merge --no-ff --no-edit -m <subject>against the worktree, then update-ref. - squash —
git merge --squash(stages without committing) thengit commit -m <subject>with author = PR author, committer = merger. - rebase —
git rebase --onto <base_oid> <base_oid> <head_oid>. Each PR commit's author is preserved; committer becomes the merger.
The orchestrator wraps the merge in a tx that holds SELECT … FOR UPDATE on pull_requests for the whole job. That row lock
serializes concurrent merges — TestMerge_RejectsConcurrentDouble
fans two goroutines and asserts exactly one wins.
update-ref <ref> <new> <old> is gated on the expected <old> OID
so a concurrent push to base between the merge-tree probe and the
update-ref also fails cleanly.
Linked-issue auto-close
Closes/Fixes/Resolves #N (case-insensitive, all tenses) parsed from:
- The PR body.
- Each head-side commit's subject + body, fetched from
pull_request_commits.
On merge, every linked still-open issue in the same repo flips to
closed, state_reason = 'completed', and gets a closed event
citing the PR id. Self-references skip silently. The regex lives at
internal/pulls/linked_issues.go::reLinkedIssue.
Identity propagation
Per the spec:
| Strategy | Author | Committer |
|---|---|---|
| merge | merger | merger |
| squash | PR author | merger |
| rebase | original commit author | merger |
Email = the user's primary verified email. When unset (rare for v1
since repo create requires a verified primary), we synthesize
<username>@noreply.shithub.local rather than fail the merge. Real
noreply emails are post-MVP.
Web UI
- Compare/new-PR entry follows GitHub's range editor: base and head
dropdowns list branches and tags with live filtering, preserve the
opposite side of the comparison, and render compare URLs with the
base...headshape. - The open-PR page reuses the compare state: ahead/behind counts, mergeability probe, commits, and three-dot diff all render before submission. The form posts the selected refs as hidden fields.
- The new PR description uses the shared GitHub-like Markdown editor (write/preview, toolbar, mentions/references/saved replies shell). Copilot suggestions are intentionally omitted.
- Tabbed view at
/pulls/{number}switches between Conversation, Commits, Files, Checks via theTabfield on the template data. - Conversation follows GitHub's PageHeader + tab strip shape: state pill, branch summary, tab count badges, left timeline, right metadata rail, and a mergeability box below the timeline.
- Files tab uses the existing S19 diff renderer fed from
compareSourceMergeBase(three-dot diff, base...head). - The Checks tab is a placeholder — real check runs land in S24.
- The merge action is a GitHub-style two-step flow: the ready merge
row opens a confirmation panel with editable commit subject/body,
hidden disallowed methods, and the repo's
default_merge_methodpreselected. The handler still validates the selected method server-side. - After a successful same-repo merge, the conversation shows the
merged timeline event plus the "successfully merged and closed"
branch-cleanup card. The timeline event links to the merge commit's
commit detail page. Deleting the head branch uses
git update-ref -d refs/heads/<head> <expected_head_oid>so a branch that moved after merge cannot be removed accidentally.
Errors
| Error | UI message |
|---|---|
ErrSameBranch |
"Base and head must differ." |
ErrBaseNotFound |
"Base branch not found." / "base branch no longer exists" on reopen |
ErrHeadNotFound |
"Head branch not found." / "head branch no longer exists" on reopen |
ErrNoCommitsToMerge |
"Head has no commits ahead of base." |
ErrAlreadyMerged |
"already merged" |
ErrAlreadyClosed |
"already closed" |
ErrMergeBlocked |
"merge blocked — branch has conflicts or hasn't been checked yet" |
ErrMergeMethodOff |
"this merge method is disabled on this repo" |
ErrConcurrentMerge |
"PR is being merged by another request" |
Tests
| File | Covers |
|---|---|
internal/pulls/linked_issues_test.go |
Closes/Fixes/Resolves regex, dedup, prose-vs-keyword |
internal/pulls/pulls_test.go::Create… |
Issue/PR row pair + initial commits/files snapshot |
internal/pulls/pulls_test.go::Mergeability_Clean |
Probe sets clean on simple fast-forward |
internal/pulls/pulls_test.go::Mergeability_Dirty |
Probe sets dirty on real shared-file conflict |
internal/pulls/pulls_test.go::Merge_MergeCommit |
Worktree merge writes commit, PR closes with merged_at |
internal/pulls/pulls_test.go::Merge_RejectsConcurrentDouble |
Row lock + update-ref serialize merges |
internal/pulls/pulls_test.go::LinkedIssueAutoClose |
Fixes #N in body closes #N on merge |
Pitfalls handled
merge-tree --merge-base=<base>was wrong in the early draft; it sets the ancestor to base and reports clean for any input. Drop the flag and let git auto-compute.- Non-fast-forward merge commit —
--no-ffso we always get a merge commit, even when head is strictly ahead of base. - Worktree leak on panic —
defer cleanup()inPerformMergerunsgit worktree remove --force+os.RemoveAll. A periodic janitor for<gitDir>/.tmp-worktrees/*older than 1h is an S37 follow-up. - Squash author mismatch — author = PR author (looked up via
users.id); committer = merger. Documented in identityFor(). - Reopen with deleted branches —
pulls.SetState(open)validates base + head still resolve; otherwise returnsErrBaseNotFound/ErrHeadNotFoundfor a friendly handler message. - Stale mergeability — synchronize resets to
unknownso a concurrent merge attempt sees the "checking…" banner instead of acting on a staleclean. - Pre-receive bypass on merge — the internal
update-refskips the hook by design. Branch protection is checked at the merge policy gate (policy.ActionPullMerge) instead of via push.
Deferred
- Cross-fork PRs → S27 (forks). The
head_repo_idcolumn + fan-out path are in place; resolver + UI to follow. - File-level review threads / approve / request-changes → S23.
- Real CI / required-checks gating → S24. The Checks tab is the visual scaffold.
- Auto-merge / merge-queue → post-MVP.
- Inline conflict resolver UI → post-MVP. v1 surfaces a banner with manual-resolve instructions.
- Auto-delete head branch on merge → S32 (repo settings) once the toggle UI lands. Spec leans opt-in checkbox.
@teammentions in PR body → S31.- Worktree janitor → S37.
- Author-noreply emails → post-MVP.
- N+1 on PR list (author, label fan-out) → S36.
View source
| 1 | # Pull requests |
| 2 | |
| 3 | S22 ships the PR core: open, view (Conversation / Commits / Files / |
| 4 | Checks tabs), edit, close/reopen, draft → ready, three merge |
| 5 | strategies (merge / squash / rebase), and auto-synchronize on push. |
| 6 | |
| 7 | ## Schema (migration 0023) |
| 8 | |
| 9 | ``` |
| 10 | pull_requests — keyed on issue_id (FK PK to issues) |
| 11 | pull_request_commits — head-side commit list, refreshed on synchronize |
| 12 | pull_request_files — changed-files list, refreshed on synchronize |
| 13 | ``` |
| 14 | |
| 15 | Plus four columns on `repos` that S11 left to S22: |
| 16 | |
| 17 | | Column | Default | Notes | |
| 18 | | ---------------------- | --------- | -------------------------------------------------- | |
| 19 | | `allow_squash_merge` | `true` | UI hides the option when off | |
| 20 | | `allow_rebase_merge` | `true` | UI hides the option when off | |
| 21 | | `allow_merge_commit` | `true` | UI hides the option when off | |
| 22 | | `default_merge_method` | `'merge'` | preselected in the merge form | |
| 23 | |
| 24 | A CHECK constraint enforces at least one method enabled so the UI |
| 25 | can never end up unable to merge. |
| 26 | |
| 27 | A PR's title / body / state / comments / labels / milestones / |
| 28 | assignees live on the **issues** row (`kind = 'pr'`); the PR-only |
| 29 | fields (base/head refs, draft, mergeable_state, merge metadata) live |
| 30 | on `pull_requests`. Single number space, single comments table — |
| 31 | matches the S21 design. |
| 32 | |
| 33 | ## Same-repo only (v1) |
| 34 | |
| 35 | `head_repo_id` is on the table from day one but always equals |
| 36 | `repo_id` for v1. A CHECK constraint ensures `base_ref <> head_ref` |
| 37 | so a self-merge can't be opened. Cross-fork PRs land in S27. |
| 38 | |
| 39 | ## Routes |
| 40 | |
| 41 | | Route | Auth | |
| 42 | | ----------------------------------------------------- | ------------- | |
| 43 | | `GET /{owner}/{repo}/pulls?state=open\|closed` | public | |
| 44 | | `GET /{owner}/{repo}/pulls/{number}` | public | |
| 45 | | `GET /{owner}/{repo}/pulls/{number}/files` | public | |
| 46 | | `GET /{owner}/{repo}/pulls/{number}/commits` | public | |
| 47 | | `GET /{owner}/{repo}/pulls/{number}/checks` | public | |
| 48 | | `GET /{owner}/{repo}/pulls/new?base=…&head=…` | RequireUser | |
| 49 | | `POST /{owner}/{repo}/pulls` | RequireUser | |
| 50 | | `POST /{owner}/{repo}/pulls/{number}/edit` | RequireUser | |
| 51 | | `POST /{owner}/{repo}/pulls/{number}/state` | RequireUser | |
| 52 | | `POST /{owner}/{repo}/pulls/{number}/ready` | RequireUser | |
| 53 | | `POST /{owner}/{repo}/pulls/{number}/merge` | RequireUser | |
| 54 | | `POST /{owner}/{repo}/pulls/{number}/delete-branch` | RequireUser | |
| 55 | |
| 56 | The pull-request list's "New pull request" button starts at |
| 57 | `/{owner}/{repo}/compare`, where the user picks base/head refs. Once |
| 58 | the head is ahead of base, compare links into |
| 59 | `/pulls/new?base=...&head=...`. `/pulls/new` redirects back to |
| 60 | compare when no head ref is supplied so the GitHub-style branch picker |
| 61 | remains the canonical entry point. |
| 62 | |
| 63 | ## Auto-synchronize on head push |
| 64 | |
| 65 | `internal/worker/jobs/push_process.go` is extended: after a push to |
| 66 | `refs/heads/<X>`, list every open PR with `head_repo_id = repo_id` |
| 67 | AND `head_ref = X`, and enqueue `pr:synchronize` for each one. The |
| 68 | synchronize job: |
| 69 | |
| 70 | 1. Resolves base + head OIDs via `repogit.ResolveRefOID`. |
| 71 | 2. Refreshes `pull_request_commits` + `pull_request_files`. |
| 72 | 3. Resets `mergeable_state` to `unknown`. |
| 73 | 4. Emits a `synchronized` timeline event with the new head OID. |
| 74 | 5. Chains a `pr:mergeability` job. |
| 75 | |
| 76 | Failure-modes are best-effort — push:process logs but doesn't block |
| 77 | the push pipeline if PR enumeration / enqueue fails. |
| 78 | |
| 79 | ## Mergeability detection (`pr:mergeability`) |
| 80 | |
| 81 | Implemented via `git merge-tree --write-tree --no-messages <base> |
| 82 | <head>` (Git ≥ 2.38). Git auto-computes the merge base. |
| 83 | |
| 84 | | Exit | Meaning | mergeable_state | |
| 85 | | ---- | ----------------------------------------------------- | --------------- | |
| 86 | | 0 | Clean merge (TreeOID printed) | `clean` | |
| 87 | | 1 | Conflicts (first line is tree OID, rest are paths) | `dirty` | |
| 88 | | ≥ 2 | Real error — wrapped + retried via worker backoff | unchanged | |
| 89 | |
| 90 | A separate cheap check runs first: if `git log base..head` is empty |
| 91 | the head has no commits ahead and we set `behind` without invoking |
| 92 | merge-tree. |
| 93 | |
| 94 | The earlier draft of this code passed `--merge-base=<base>` to |
| 95 | `merge-tree`, which sets the common ancestor *to base itself* and |
| 96 | therefore can never produce a conflict. That was wrong — fixed via |
| 97 | `TestMergeability_Dirty`. |
| 98 | |
| 99 | ## Merge operation |
| 100 | |
| 101 | `internal/repos/git/mergeops.go::PerformMerge` runs entirely |
| 102 | server-side in a temp worktree on the same volume as the bare repo |
| 103 | (S04 mandates `/data/repos/` and tempdirs share the volume). The |
| 104 | worktree is created via `git worktree add --detach <wt> <base_oid>` |
| 105 | so the bare repo's branch refs aren't polluted; only the final |
| 106 | `update-ref` writes back. |
| 107 | |
| 108 | Strategies: |
| 109 | |
| 110 | - **merge** — `git merge --no-ff --no-edit -m <subject>` against the |
| 111 | worktree, then update-ref. |
| 112 | - **squash** — `git merge --squash` (stages without committing) then |
| 113 | `git commit -m <subject>` with author = PR author, committer = merger. |
| 114 | - **rebase** — `git rebase --onto <base_oid> <base_oid> <head_oid>`. |
| 115 | Each PR commit's author is preserved; committer becomes the merger. |
| 116 | |
| 117 | The orchestrator wraps the merge in a tx that holds `SELECT … FOR |
| 118 | UPDATE` on `pull_requests` for the whole job. That row lock |
| 119 | serializes concurrent merges — `TestMerge_RejectsConcurrentDouble` |
| 120 | fans two goroutines and asserts exactly one wins. |
| 121 | |
| 122 | `update-ref <ref> <new> <old>` is gated on the expected `<old>` OID |
| 123 | so a concurrent push to base between the merge-tree probe and the |
| 124 | update-ref also fails cleanly. |
| 125 | |
| 126 | ## Linked-issue auto-close |
| 127 | |
| 128 | `Closes/Fixes/Resolves #N` (case-insensitive, all tenses) parsed from: |
| 129 | |
| 130 | - The PR body. |
| 131 | - Each head-side commit's subject + body, fetched from |
| 132 | `pull_request_commits`. |
| 133 | |
| 134 | On merge, every linked still-open issue in the same repo flips to |
| 135 | `closed`, `state_reason = 'completed'`, and gets a `closed` event |
| 136 | citing the PR id. Self-references skip silently. The regex lives at |
| 137 | `internal/pulls/linked_issues.go::reLinkedIssue`. |
| 138 | |
| 139 | ## Identity propagation |
| 140 | |
| 141 | Per the spec: |
| 142 | |
| 143 | | Strategy | Author | Committer | |
| 144 | | -------- | --------------------- | ----------- | |
| 145 | | merge | merger | merger | |
| 146 | | squash | PR author | merger | |
| 147 | | rebase | original commit author | merger | |
| 148 | |
| 149 | Email = the user's primary verified email. When unset (rare for v1 |
| 150 | since repo create requires a verified primary), we synthesize |
| 151 | `<username>@noreply.shithub.local` rather than fail the merge. Real |
| 152 | noreply emails are post-MVP. |
| 153 | |
| 154 | ## Web UI |
| 155 | |
| 156 | - Compare/new-PR entry follows GitHub's range editor: base and head |
| 157 | dropdowns list branches and tags with live filtering, preserve the |
| 158 | opposite side of the comparison, and render compare URLs with the |
| 159 | `base...head` shape. |
| 160 | - The open-PR page reuses the compare state: ahead/behind counts, |
| 161 | mergeability probe, commits, and three-dot diff all render before |
| 162 | submission. The form posts the selected refs as hidden fields. |
| 163 | - The new PR description uses the shared GitHub-like Markdown editor |
| 164 | (write/preview, toolbar, mentions/references/saved replies shell). |
| 165 | Copilot suggestions are intentionally omitted. |
| 166 | - Tabbed view at `/pulls/{number}` switches between Conversation, |
| 167 | Commits, Files, Checks via the `Tab` field on the template data. |
| 168 | - Conversation follows GitHub's PageHeader + tab strip shape: state |
| 169 | pill, branch summary, tab count badges, left timeline, right metadata |
| 170 | rail, and a mergeability box below the timeline. |
| 171 | - Files tab uses the existing S19 diff renderer fed from |
| 172 | `compareSourceMergeBase` (three-dot diff, base...head). |
| 173 | - The Checks tab is a placeholder — real check runs land in S24. |
| 174 | - The merge action is a GitHub-style two-step flow: the ready merge |
| 175 | row opens a confirmation panel with editable commit subject/body, |
| 176 | hidden disallowed methods, and the repo's `default_merge_method` |
| 177 | preselected. The handler still validates the selected method |
| 178 | server-side. |
| 179 | - After a successful same-repo merge, the conversation shows the |
| 180 | merged timeline event plus the "successfully merged and closed" |
| 181 | branch-cleanup card. The timeline event links to the merge commit's |
| 182 | commit detail page. Deleting the head branch uses |
| 183 | `git update-ref -d refs/heads/<head> <expected_head_oid>` so a |
| 184 | branch that moved after merge cannot be removed accidentally. |
| 185 | |
| 186 | ## Errors |
| 187 | |
| 188 | | Error | UI message | |
| 189 | | ---------------------- | --------------------------------------------------------------------- | |
| 190 | | `ErrSameBranch` | "Base and head must differ." | |
| 191 | | `ErrBaseNotFound` | "Base branch not found." / "base branch no longer exists" on reopen | |
| 192 | | `ErrHeadNotFound` | "Head branch not found." / "head branch no longer exists" on reopen | |
| 193 | | `ErrNoCommitsToMerge` | "Head has no commits ahead of base." | |
| 194 | | `ErrAlreadyMerged` | "already merged" | |
| 195 | | `ErrAlreadyClosed` | "already closed" | |
| 196 | | `ErrMergeBlocked` | "merge blocked — branch has conflicts or hasn't been checked yet" | |
| 197 | | `ErrMergeMethodOff` | "this merge method is disabled on this repo" | |
| 198 | | `ErrConcurrentMerge` | "PR is being merged by another request" | |
| 199 | |
| 200 | ## Tests |
| 201 | |
| 202 | | File | Covers | |
| 203 | | --------------------------------------------- | ------------------------------------------------------------ | |
| 204 | | `internal/pulls/linked_issues_test.go` | Closes/Fixes/Resolves regex, dedup, prose-vs-keyword | |
| 205 | | `internal/pulls/pulls_test.go::Create…` | Issue/PR row pair + initial commits/files snapshot | |
| 206 | | `internal/pulls/pulls_test.go::Mergeability_Clean` | Probe sets `clean` on simple fast-forward | |
| 207 | | `internal/pulls/pulls_test.go::Mergeability_Dirty` | Probe sets `dirty` on real shared-file conflict | |
| 208 | | `internal/pulls/pulls_test.go::Merge_MergeCommit` | Worktree merge writes commit, PR closes with merged_at | |
| 209 | | `internal/pulls/pulls_test.go::Merge_RejectsConcurrentDouble` | Row lock + update-ref serialize merges | |
| 210 | | `internal/pulls/pulls_test.go::LinkedIssueAutoClose` | `Fixes #N` in body closes #N on merge | |
| 211 | |
| 212 | ## Pitfalls handled |
| 213 | |
| 214 | - **`merge-tree --merge-base=<base>` was wrong** in the early draft; |
| 215 | it sets the ancestor *to base* and reports clean for any input. |
| 216 | Drop the flag and let git auto-compute. |
| 217 | - **Non-fast-forward merge commit** — `--no-ff` so we always get a |
| 218 | merge commit, even when head is strictly ahead of base. |
| 219 | - **Worktree leak on panic** — `defer cleanup()` in `PerformMerge` |
| 220 | runs `git worktree remove --force` + `os.RemoveAll`. A periodic |
| 221 | janitor for `<gitDir>/.tmp-worktrees/*` older than 1h is an S37 |
| 222 | follow-up. |
| 223 | - **Squash author mismatch** — author = PR author (looked up via |
| 224 | `users.id`); committer = merger. Documented in identityFor(). |
| 225 | - **Reopen with deleted branches** — `pulls.SetState(open)` validates |
| 226 | base + head still resolve; otherwise returns `ErrBaseNotFound` / |
| 227 | `ErrHeadNotFound` for a friendly handler message. |
| 228 | - **Stale mergeability** — synchronize resets to `unknown` so a |
| 229 | concurrent merge attempt sees the "checking…" banner instead of |
| 230 | acting on a stale `clean`. |
| 231 | - **Pre-receive bypass on merge** — the internal `update-ref` skips |
| 232 | the hook by design. Branch protection is checked at the merge |
| 233 | policy gate (`policy.ActionPullMerge`) instead of via push. |
| 234 | |
| 235 | ## Deferred |
| 236 | |
| 237 | - **Cross-fork PRs** → S27 (forks). The `head_repo_id` column + |
| 238 | fan-out path are in place; resolver + UI to follow. |
| 239 | - **File-level review threads / approve / request-changes** → S23. |
| 240 | - **Real CI / required-checks gating** → S24. The Checks tab is the |
| 241 | visual scaffold. |
| 242 | - **Auto-merge / merge-queue** → post-MVP. |
| 243 | - **Inline conflict resolver UI** → post-MVP. v1 surfaces a banner |
| 244 | with manual-resolve instructions. |
| 245 | - **Auto-delete head branch on merge** → S32 (repo settings) once |
| 246 | the toggle UI lands. Spec leans opt-in checkbox. |
| 247 | - **`@team` mentions in PR body** → S31. |
| 248 | - **Worktree janitor** → S37. |
| 249 | - **Author-noreply emails** → post-MVP. |
| 250 | - **N+1 on PR list (author, label fan-out)** → S36. |