markdown · 12714 bytes Raw Blame History

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:

  1. Resolves base + head OIDs via repogit.ResolveRefOID.
  2. Refreshes pull_request_commits + pull_request_files.
  3. Resets mergeable_state to unknown.
  4. Emits a synchronized timeline event with the new head OID.
  5. Chains a pr:mergeability job.

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:

  • mergegit merge --no-ff --no-edit -m <subject> against the worktree, then update-ref.
  • squashgit merge --squash (stages without committing) then git commit -m <subject> with author = PR author, committer = merger.
  • rebasegit 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...head shape.
  • 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 the Tab field 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_method preselected. 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-ff so we always get a merge commit, even when head is strictly ahead of base.
  • Worktree leak on panicdefer cleanup() in PerformMerge runs git 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 branchespulls.SetState(open) validates base + head still resolve; otherwise returns ErrBaseNotFound / ErrHeadNotFound for a friendly handler message.
  • Stale mergeability — synchronize resets to unknown so a concurrent merge attempt sees the "checking…" banner instead of acting on a stale clean.
  • Pre-receive bypass on merge — the internal update-ref skips 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_id column + 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.
  • @team mentions 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.