Align pull request merge flow
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
ea8624223c026e64abe77417e970fd9dd9ebeab7- Parents
-
5eb7056 - Tree
806a06c
ea86242
ea8624223c026e64abe77417e970fd9dd9ebeab75eb7056
806a06c| Status | File | + | - |
|---|---|---|---|
| M |
docs/internal/pull-requests.md
|
12 | 2 |
| M |
internal/repos/git/branchops.go
|
33 | 0 |
| A |
internal/repos/git/branchops_test.go
|
48 | 0 |
| M |
internal/web/handlers/repo/issues.go
|
18 | 0 |
| A |
internal/web/handlers/repo/pull_view_helpers_test.go
|
55 | 0 |
| M |
internal/web/handlers/repo/pulls.go
|
127 | 22 |
| M |
internal/web/static/css/shithub.css
|
53 | 6 |
| A |
internal/web/static/js/pull-view.js
|
37 | 0 |
| M |
internal/web/templates/_layout.html
|
1 | 0 |
| M |
internal/web/templates/repo/pull_view.html
|
43 | 9 |
docs/internal/pull-requests.mdmodified@@ -51,6 +51,7 @@ so a self-merge can't be opened. Cross-fork PRs land in S27. | ||
| 51 | 51 | | `POST /{owner}/{repo}/pulls/{number}/state` | RequireUser | |
| 52 | 52 | | `POST /{owner}/{repo}/pulls/{number}/ready` | RequireUser | |
| 53 | 53 | | `POST /{owner}/{repo}/pulls/{number}/merge` | RequireUser | |
| 54 | +| `POST /{owner}/{repo}/pulls/{number}/delete-branch` | RequireUser | | |
| 54 | 55 | |
| 55 | 56 | The pull-request list's "New pull request" button starts at |
| 56 | 57 | `/{owner}/{repo}/compare`, where the user picks base/head refs. Once |
@@ -170,8 +171,17 @@ noreply emails are post-MVP. | ||
| 170 | 171 | - Files tab uses the existing S19 diff renderer fed from |
| 171 | 172 | `compareSourceMergeBase` (three-dot diff, base...head). |
| 172 | 173 | - The Checks tab is a placeholder — real check runs land in S24. |
| 173 | -- The merge form hides disallowed methods and preselects the repo's | |
| 174 | - `default_merge_method`. | |
| 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. | |
| 175 | 185 | |
| 176 | 186 | ## Errors |
| 177 | 187 | |
internal/repos/git/branchops.gomodified@@ -99,6 +99,39 @@ func UpdateRefCAS(ctx context.Context, gitDir, ref, newOID, oldOID string) error | ||
| 99 | 99 | // ref moved between our read and our update. |
| 100 | 100 | var ErrRefRaced = errors.New("repogit: ref moved concurrently") |
| 101 | 101 | |
| 102 | +// DeleteBranch removes refs/heads/<branch>. When oldOID is non-empty, | |
| 103 | +// git's update-ref compare-and-delete guard is used so a branch that | |
| 104 | +// moved after the caller rendered the page is not deleted accidentally. | |
| 105 | +func DeleteBranch(ctx context.Context, gitDir, branch, oldOID string) error { | |
| 106 | + branch = strings.TrimSpace(branch) | |
| 107 | + if branch == "" || strings.HasPrefix(branch, "-") { | |
| 108 | + return ErrRefNotFound | |
| 109 | + } | |
| 110 | + check := exec.CommandContext(ctx, "git", "-C", gitDir, "check-ref-format", "--branch", branch) | |
| 111 | + if out, err := check.CombinedOutput(); err != nil { | |
| 112 | + return fmt.Errorf("check-ref-format %s: %w (%s)", branch, err, strings.TrimSpace(string(out))) | |
| 113 | + } | |
| 114 | + ref := "refs/heads/" + branch | |
| 115 | + args := []string{"-C", gitDir, "update-ref", "-d", ref} | |
| 116 | + if strings.TrimSpace(oldOID) != "" { | |
| 117 | + args = append(args, oldOID) | |
| 118 | + } | |
| 119 | + cmd := exec.CommandContext(ctx, "git", args...) | |
| 120 | + out, err := cmd.CombinedOutput() | |
| 121 | + if err == nil { | |
| 122 | + return nil | |
| 123 | + } | |
| 124 | + msg := strings.TrimSpace(string(out)) | |
| 125 | + switch { | |
| 126 | + case strings.Contains(msg, "unable to resolve reference"), strings.Contains(msg, "reference does not exist"): | |
| 127 | + return ErrRefNotFound | |
| 128 | + case strings.Contains(msg, "cannot lock ref"), strings.Contains(msg, "expected"): | |
| 129 | + return ErrRefRaced | |
| 130 | + default: | |
| 131 | + return fmt.Errorf("delete-ref %s: %w (%s)", ref, err, msg) | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 102 | 135 | // FetchIntoNamespace fetches a single ref from `srcRepoDir` into |
| 103 | 136 | // `dstRepoDir` under the supplied refspec. The dst-side ref name is |
| 104 | 137 | // the second half of the refspec. Used by S27 cross-fork PR support |
internal/repos/git/branchops_test.goadded@@ -0,0 +1,48 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package git_test | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "errors" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + repogit "github.com/tenseleyFlow/shithub/internal/repos/git" | |
| 13 | +) | |
| 14 | + | |
| 15 | +func TestDeleteBranchUsesExpectedOID(t *testing.T) { | |
| 16 | + t.Parallel() | |
| 17 | + ctx := context.Background() | |
| 18 | + gitDir := initBare(t) | |
| 19 | + when := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) | |
| 20 | + base, err := repogit.InitialCommit{ | |
| 21 | + GitDir: gitDir, | |
| 22 | + AuthorName: "Alice", | |
| 23 | + AuthorEmail: "alice@example.com", | |
| 24 | + Branch: "trunk", | |
| 25 | + When: when, | |
| 26 | + Files: []repogit.FileEntry{{Path: "README.md", Body: []byte("base\n")}}, | |
| 27 | + }.Build(ctx) | |
| 28 | + if err != nil { | |
| 29 | + t.Fatalf("Build base: %v", err) | |
| 30 | + } | |
| 31 | + if out, err := gitCmd("-C", gitDir, "update-ref", "refs/heads/topic", base).CombinedOutput(); err != nil { | |
| 32 | + t.Fatalf("create branch: %v\n%s", err, out) | |
| 33 | + } | |
| 34 | + | |
| 35 | + if err := repogit.DeleteBranch(ctx, gitDir, "topic", strings.Repeat("f", 40)); !errors.Is(err, repogit.ErrRefRaced) { | |
| 36 | + t.Fatalf("DeleteBranch stale oid = %v, want ErrRefRaced", err) | |
| 37 | + } | |
| 38 | + if _, err := repogit.ResolveRefOID(ctx, gitDir, "refs/heads/topic"); err != nil { | |
| 39 | + t.Fatalf("topic should still exist after stale delete: %v", err) | |
| 40 | + } | |
| 41 | + | |
| 42 | + if err := repogit.DeleteBranch(ctx, gitDir, "topic", base); err != nil { | |
| 43 | + t.Fatalf("DeleteBranch current oid: %v", err) | |
| 44 | + } | |
| 45 | + if _, err := repogit.ResolveRefOID(ctx, gitDir, "refs/heads/topic"); !errors.Is(err, repogit.ErrRefNotFound) { | |
| 46 | + t.Fatalf("topic lookup after delete = %v, want ErrRefNotFound", err) | |
| 47 | + } | |
| 48 | +} | |
internal/web/handlers/repo/issues.gomodified@@ -370,6 +370,8 @@ type issueTimelineRow struct { | ||
| 370 | 370 | LabelName string |
| 371 | 371 | LabelColor string |
| 372 | 372 | CommentID int64 |
| 373 | + CommitSHA string | |
| 374 | + ShortCommit string | |
| 373 | 375 | LinkedState bool |
| 374 | 376 | } |
| 375 | 377 | |
@@ -411,6 +413,10 @@ func (h *Handlers) issueTimelineRows( | ||
| 411 | 413 | row.CommentID = id |
| 412 | 414 | row.LinkedState = e.Kind == "closed" || e.Kind == "reopened" |
| 413 | 415 | } |
| 416 | + if commit := metaString(meta, "commit"); commit != "" { | |
| 417 | + row.CommitSHA = commit | |
| 418 | + row.ShortCommit = shortSHA(commit) | |
| 419 | + } | |
| 414 | 420 | if id := metaInt64(meta, "label_id"); id != 0 { |
| 415 | 421 | if l, ok := labelByID[id]; ok { |
| 416 | 422 | row.LabelName = l.Name |
@@ -471,6 +477,16 @@ func metaInt64(meta map[string]any, key string) int64 { | ||
| 471 | 477 | } |
| 472 | 478 | } |
| 473 | 479 | |
| 480 | +func metaString(meta map[string]any, key string) string { | |
| 481 | + if meta == nil { | |
| 482 | + return "" | |
| 483 | + } | |
| 484 | + if s, ok := meta[key].(string); ok { | |
| 485 | + return s | |
| 486 | + } | |
| 487 | + return "" | |
| 488 | +} | |
| 489 | + | |
| 474 | 490 | func issueEventMessage(kind string) string { |
| 475 | 491 | switch kind { |
| 476 | 492 | case "closed": |
@@ -495,6 +511,8 @@ func issueEventMessage(kind string) string { | ||
| 495 | 511 | return "unassigned a user" |
| 496 | 512 | case "referenced": |
| 497 | 513 | return "referenced this issue" |
| 514 | + case "merged": | |
| 515 | + return "merged commit" | |
| 498 | 516 | default: |
| 499 | 517 | return kind |
| 500 | 518 | } |
internal/web/handlers/repo/pull_view_helpers_test.goadded@@ -0,0 +1,55 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package repo | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "encoding/json" | |
| 7 | + "testing" | |
| 8 | + "time" | |
| 9 | + | |
| 10 | + "github.com/jackc/pgx/v5/pgtype" | |
| 11 | + | |
| 12 | + issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" | |
| 13 | + pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc" | |
| 14 | +) | |
| 15 | + | |
| 16 | +func TestDefaultMergeSubjectUsesPullNumberAndHeadOwner(t *testing.T) { | |
| 17 | + t.Parallel() | |
| 18 | + got := defaultMergeSubject(pullsdb.GetPullRequestByRepoAndNumberRow{ | |
| 19 | + INumber: 138, | |
| 20 | + HeadRef: "s41h/dogfood-ga-audit", | |
| 21 | + }, "tenseleyFlow") | |
| 22 | + want := "Merge pull request #138 from tenseleyFlow/s41h/dogfood-ga-audit" | |
| 23 | + if got != want { | |
| 24 | + t.Fatalf("defaultMergeSubject = %q, want %q", got, want) | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +func TestIssueTimelineRowsDecoratesMergedEvent(t *testing.T) { | |
| 29 | + t.Parallel() | |
| 30 | + meta, err := json.Marshal(map[string]string{ | |
| 31 | + "method": "merge", | |
| 32 | + "commit": "5eb70568f00dbabe111111111111111111111111", | |
| 33 | + }) | |
| 34 | + if err != nil { | |
| 35 | + t.Fatal(err) | |
| 36 | + } | |
| 37 | + rows := (*Handlers)(nil).issueTimelineRows(nil, []issuesdb.IssueEvent{{ | |
| 38 | + Kind: "merged", | |
| 39 | + Meta: meta, | |
| 40 | + ActorUserID: pgtype.Int8{Int64: 7, Valid: true}, | |
| 41 | + CreatedAt: pgtype.Timestamptz{Time: time.Unix(10, 0), Valid: true}, | |
| 42 | + }}, nil, nil, func(id int64) string { | |
| 43 | + if id == 7 { | |
| 44 | + return "mfwolffe" | |
| 45 | + } | |
| 46 | + return "" | |
| 47 | + }) | |
| 48 | + if len(rows) != 1 { | |
| 49 | + t.Fatalf("rows len = %d, want 1", len(rows)) | |
| 50 | + } | |
| 51 | + row := rows[0] | |
| 52 | + if row.Message != "merged commit" || row.CommitSHA == "" || row.ShortCommit != "5eb7056" || row.ActorName != "mfwolffe" { | |
| 53 | + t.Fatalf("merged row = %#v", row) | |
| 54 | + } | |
| 55 | +} | |
internal/web/handlers/repo/pulls.gomodified@@ -99,6 +99,7 @@ func (h *Handlers) MountPulls(r chi.Router) { | ||
| 99 | 99 | r.Post("/{owner}/{repo}/pulls/{number}/state", h.pullSetState) |
| 100 | 100 | r.Post("/{owner}/{repo}/pulls/{number}/ready", h.pullSetReady) |
| 101 | 101 | r.Post("/{owner}/{repo}/pulls/{number}/merge", h.pullMerge) |
| 102 | + r.Post("/{owner}/{repo}/pulls/{number}/delete-branch", h.pullDeleteHeadBranch) | |
| 102 | 103 | }) |
| 103 | 104 | // S23 review surface — its own group so the auth-required wrapper |
| 104 | 105 | // is shared cleanly without rewriting this file's existing one. |
@@ -396,6 +397,38 @@ func (h *Handlers) renderPullPage(w http.ResponseWriter, r *http.Request, tab st | ||
| 396 | 397 | pr.BaseOid != "" && pr.HeadOid != "" { |
| 397 | 398 | h.kickMergeability(r, pr.IID) |
| 398 | 399 | } |
| 400 | + viewer := middleware.CurrentUserFromContext(r.Context()) | |
| 401 | + actor := viewer.PolicyActor() | |
| 402 | + pdeps := policy.Deps{Pool: h.d.Pool} | |
| 403 | + repoRef := policy.NewRepoRefFromRepo(row) | |
| 404 | + stateRef := repoRef | |
| 405 | + if pr.IAuthorUserID.Valid { | |
| 406 | + stateRef.AuthorUserID = pr.IAuthorUserID.Int64 | |
| 407 | + } | |
| 408 | + canReviewPull := policy.Can(r.Context(), pdeps, actor, policy.ActionPullReview, repoRef).Allow | |
| 409 | + canMergePull := policy.Can(r.Context(), pdeps, actor, policy.ActionPullMerge, repoRef).Allow | |
| 410 | + canSetPullState := policy.Can(r.Context(), pdeps, actor, policy.ActionPullClose, stateRef).Allow | |
| 411 | + canReadyPull := policy.Can(r.Context(), pdeps, actor, policy.ActionPullCreate, repoRef).Allow | |
| 412 | + headOwner := owner.Username | |
| 413 | + if pr.HeadRepoID != 0 { | |
| 414 | + if headRepo, err := h.rq.GetRepoOwnerUsernameByID(r.Context(), h.d.Pool, pr.HeadRepoID); err == nil { | |
| 415 | + if ownerName := repoOwnerName(headRepo.OwnerUsername); ownerName != "" { | |
| 416 | + headOwner = ownerName | |
| 417 | + } | |
| 418 | + } | |
| 419 | + } | |
| 420 | + headBranchExists := false | |
| 421 | + if pr.HeadRepoID == row.ID && pr.HeadRef != "" && pr.HeadRef != row.DefaultBranch { | |
| 422 | + if gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name); err == nil { | |
| 423 | + if _, err := repogit.ResolveRefOID(r.Context(), gitDir, "refs/heads/"+pr.HeadRef); err == nil { | |
| 424 | + headBranchExists = true | |
| 425 | + } | |
| 426 | + } | |
| 427 | + } | |
| 428 | + defaultMethod := string(row.DefaultMergeMethod) | |
| 429 | + if defaultMethod == "" { | |
| 430 | + defaultMethod = "merge" | |
| 431 | + } | |
| 399 | 432 | checkGroups := h.pullCheckGroups(r.Context(), row.ID, pr.HeadOid) |
| 400 | 433 | stats := h.pullStats(r.Context(), pr, checkGroups) |
| 401 | 434 | data := map[string]any{ |
@@ -411,21 +444,20 @@ func (h *Handlers) renderPullPage(w http.ResponseWriter, r *http.Request, tab st | ||
| 411 | 444 | "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 412 | 445 | "RepoActions": h.repoActions(r, row.ID), |
| 413 | 446 | "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount), |
| 414 | - "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), | |
| 447 | + "CanSettings": h.canViewSettings(viewer), | |
| 415 | 448 | "ActiveSubnav": "pulls", |
| 416 | - } | |
| 417 | - viewer := middleware.CurrentUserFromContext(r.Context()) | |
| 418 | - actor := viewer.PolicyActor() | |
| 419 | - pdeps := policy.Deps{Pool: h.d.Pool} | |
| 420 | - repoRef := policy.NewRepoRefFromRepo(row) | |
| 421 | - stateRef := repoRef | |
| 422 | - if pr.IAuthorUserID.Valid { | |
| 423 | - stateRef.AuthorUserID = pr.IAuthorUserID.Int64 | |
| 424 | - } | |
| 425 | - data["CanReviewPull"] = policy.Can(r.Context(), pdeps, actor, policy.ActionPullReview, repoRef).Allow | |
| 426 | - data["CanMergePull"] = policy.Can(r.Context(), pdeps, actor, policy.ActionPullMerge, repoRef).Allow | |
| 427 | - data["CanSetPullState"] = policy.Can(r.Context(), pdeps, actor, policy.ActionPullClose, stateRef).Allow | |
| 428 | - data["CanReadyPull"] = policy.Can(r.Context(), pdeps, actor, policy.ActionPullCreate, repoRef).Allow | |
| 449 | + "UsePullViewJS": true, | |
| 450 | + "MergeDefaultMethod": defaultMethod, | |
| 451 | + "MergeFormSubject": defaultMergeSubject(pr, headOwner), | |
| 452 | + "MergeFormBody": strings.TrimSpace(pr.ITitle), | |
| 453 | + "MergeAuthorLine": h.mergeAuthorLine(r.Context(), viewer), | |
| 454 | + "CanDeleteHeadBranch": canMergePull && pr.MergedAt.Valid && pr.HeadRepoID == row.ID && pr.HeadRef != row.DefaultBranch && headBranchExists, | |
| 455 | + "HeadBranchAlreadyGone": pr.MergedAt.Valid && pr.HeadRepoID == row.ID && pr.HeadRef != row.DefaultBranch && !headBranchExists, | |
| 456 | + } | |
| 457 | + data["CanReviewPull"] = canReviewPull | |
| 458 | + data["CanMergePull"] = canMergePull | |
| 459 | + data["CanSetPullState"] = canSetPullState | |
| 460 | + data["CanReadyPull"] = canReadyPull | |
| 429 | 461 | for k, v := range extras { |
| 430 | 462 | data[k] = v |
| 431 | 463 | } |
@@ -433,6 +465,41 @@ func (h *Handlers) renderPullPage(w http.ResponseWriter, r *http.Request, tab st | ||
| 433 | 465 | _ = h.d.Render.RenderPage(w, r, "repo/pull_view", data) |
| 434 | 466 | } |
| 435 | 467 | |
| 468 | +func repoOwnerName(raw interface{}) string { | |
| 469 | + switch v := raw.(type) { | |
| 470 | + case string: | |
| 471 | + return v | |
| 472 | + case []byte: | |
| 473 | + return string(v) | |
| 474 | + default: | |
| 475 | + return "" | |
| 476 | + } | |
| 477 | +} | |
| 478 | + | |
| 479 | +func defaultMergeSubject(pr pullsdb.GetPullRequestByRepoAndNumberRow, headOwner string) string { | |
| 480 | + head := strings.TrimSpace(headOwner) | |
| 481 | + if head == "" { | |
| 482 | + head = "head" | |
| 483 | + } | |
| 484 | + if pr.HeadRef != "" { | |
| 485 | + head += "/" + pr.HeadRef | |
| 486 | + } | |
| 487 | + return "Merge pull request #" + strconv.FormatInt(pr.INumber, 10) + " from " + head | |
| 488 | +} | |
| 489 | + | |
| 490 | +func (h *Handlers) mergeAuthorLine(ctx context.Context, viewer middleware.CurrentUser) string { | |
| 491 | + if viewer.IsAnonymous() { | |
| 492 | + return "" | |
| 493 | + } | |
| 494 | + email := viewer.Username + "@noreply.shithub.local" | |
| 495 | + if u, err := h.uq.GetUserByID(ctx, h.d.Pool, viewer.ID); err == nil && u.PrimaryEmailID.Valid { | |
| 496 | + if em, err := h.uq.GetUserEmailByID(ctx, h.d.Pool, u.PrimaryEmailID.Int64); err == nil && em.Verified { | |
| 497 | + email = string(em.Email) | |
| 498 | + } | |
| 499 | + } | |
| 500 | + return "This commit will be authored by " + email + "." | |
| 501 | +} | |
| 502 | + | |
| 436 | 503 | func (h *Handlers) pullStats(ctx context.Context, pr pullsdb.GetPullRequestByRepoAndNumberRow, checkGroups []pullCheckSuiteView) pullPageStats { |
| 437 | 504 | stats := pullPageStats{CheckState: "none"} |
| 438 | 505 | if comments, err := h.iq.ListIssueComments(ctx, h.d.Pool, pr.IID); err == nil { |
@@ -972,6 +1039,44 @@ func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) { | ||
| 972 | 1039 | h.redirectPull(w, r, owner.Username, row.Name, pr.INumber) |
| 973 | 1040 | } |
| 974 | 1041 | |
| 1042 | +func (h *Handlers) pullDeleteHeadBranch(w http.ResponseWriter, r *http.Request) { | |
| 1043 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullMerge) | |
| 1044 | + if !ok { | |
| 1045 | + return | |
| 1046 | + } | |
| 1047 | + pr, ok := h.loadPullByNumber(w, r, row.ID) | |
| 1048 | + if !ok { | |
| 1049 | + return | |
| 1050 | + } | |
| 1051 | + if err := r.ParseForm(); err != nil { | |
| 1052 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse") | |
| 1053 | + return | |
| 1054 | + } | |
| 1055 | + if !pr.MergedAt.Valid || pr.HeadRepoID != row.ID || strings.TrimSpace(pr.HeadRef) == "" || pr.HeadRef == row.DefaultBranch { | |
| 1056 | + h.d.Render.HTTPError(w, r, http.StatusConflict, "head branch cannot be deleted from this pull request") | |
| 1057 | + return | |
| 1058 | + } | |
| 1059 | + gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name) | |
| 1060 | + if err != nil { | |
| 1061 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") | |
| 1062 | + return | |
| 1063 | + } | |
| 1064 | + if err := repogit.DeleteBranch(r.Context(), gitDir, pr.HeadRef, pr.HeadOid); err != nil { | |
| 1065 | + if errors.Is(err, repogit.ErrRefNotFound) { | |
| 1066 | + h.redirectPull(w, r, owner.Username, row.Name, pr.INumber) | |
| 1067 | + return | |
| 1068 | + } | |
| 1069 | + if errors.Is(err, repogit.ErrRefRaced) { | |
| 1070 | + h.d.Render.HTTPError(w, r, http.StatusConflict, "branch moved after this pull request was merged") | |
| 1071 | + return | |
| 1072 | + } | |
| 1073 | + h.d.Logger.WarnContext(r.Context(), "pulls: delete head branch", "error", err, "repo_id", row.ID, "pr", pr.INumber, "branch", pr.HeadRef) | |
| 1074 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 1075 | + return | |
| 1076 | + } | |
| 1077 | + h.redirectPull(w, r, owner.Username, row.Name, pr.INumber) | |
| 1078 | +} | |
| 1079 | + | |
| 975 | 1080 | func (h *Handlers) handlePullWriteError(w http.ResponseWriter, r *http.Request, err error) { |
| 976 | 1081 | switch { |
| 977 | 1082 | case errors.Is(err, pulls.ErrAlreadyMerged): |
internal/web/static/css/shithub.cssmodified@@ -8442,7 +8442,8 @@ button.shithub-repo-action { | ||
| 8442 | 8442 | .shithub-event { |
| 8443 | 8443 | color: var(--fg-muted); |
| 8444 | 8444 | font-size: 0.85rem; |
| 8445 | - display: flex; | |
| 8445 | + display: grid; | |
| 8446 | + grid-template-columns: auto minmax(0, 1fr) auto; | |
| 8446 | 8447 | align-items: center; |
| 8447 | 8448 | gap: 0.5rem; |
| 8448 | 8449 | padding: 0.55rem 0; |
@@ -8475,6 +8476,11 @@ button.shithub-repo-action { | ||
| 8475 | 8476 | .shithub-event-icon svg { width: 0.8rem; height: 0.8rem; } |
| 8476 | 8477 | .shithub-event-linked .shithub-event-icon { color: var(--fg-muted); } |
| 8477 | 8478 | .shithub-event a { font-weight: 600; } |
| 8479 | +.shithub-event-text { min-width: 0; } | |
| 8480 | +.shithub-event-actions { | |
| 8481 | + display: flex; | |
| 8482 | + gap: 0.5rem; | |
| 8483 | +} | |
| 8478 | 8484 | .shithub-comment-form { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; } |
| 8479 | 8485 | .shithub-comment-form textarea, .shithub-issue-form textarea, .shithub-issue-form input[type=text] { |
| 8480 | 8486 | font: inherit; padding: 0.5rem; border: 1px solid var(--border-default); border-radius: 6px; width: 100%; |
@@ -8681,16 +8687,17 @@ button.shithub-repo-action { | ||
| 8681 | 8687 | padding: 0.85rem 1rem; |
| 8682 | 8688 | } |
| 8683 | 8689 | .shithub-pull-deploy-box p { margin: 0.15rem 0 0; } |
| 8684 | -.shithub-pull-merge-box-ready { border-color: rgba(26, 127, 55, 0.55); } | |
| 8690 | +.shithub-pull-merge-box-ready { border-color: rgba(26, 127, 55, 0.45); } | |
| 8685 | 8691 | .shithub-pull-merge-box-blocked { border-color: rgba(207, 34, 46, 0.55); } |
| 8686 | 8692 | .shithub-pull-merge-box-pending { border-color: rgba(154, 103, 0, 0.55); } |
| 8687 | 8693 | .shithub-pull-merge-box-merged { border-color: rgba(130, 80, 223, 0.55); } |
| 8688 | 8694 | .shithub-pull-merge-row { |
| 8689 | 8695 | display: grid; |
| 8690 | - grid-template-columns: 2rem minmax(0, 1fr); | |
| 8696 | + grid-template-columns: 2rem minmax(0, 1fr) auto; | |
| 8691 | 8697 | gap: 0.75rem; |
| 8692 | 8698 | padding: 0.85rem 1rem; |
| 8693 | 8699 | border-bottom: 1px solid var(--border-default); |
| 8700 | + align-items: start; | |
| 8694 | 8701 | } |
| 8695 | 8702 | .shithub-pull-merge-row:last-child { border-bottom: none; } |
| 8696 | 8703 | .shithub-pull-merge-row p { margin: 0.15rem 0 0; } |
@@ -8709,6 +8716,11 @@ button.shithub-repo-action { | ||
| 8709 | 8716 | .shithub-pull-check-row-failure .shithub-pull-status-icon { color: #cf222e; } |
| 8710 | 8717 | .shithub-pull-check-row-pending .shithub-pull-status-icon { color: #bf8700; } |
| 8711 | 8718 | .shithub-pull-status-icon-merged { color: #8250df; } |
| 8719 | +.shithub-mono-link { | |
| 8720 | + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; | |
| 8721 | + font-size: 0.92em; | |
| 8722 | + color: var(--fg-muted); | |
| 8723 | +} | |
| 8712 | 8724 | .shithub-pull-merge-checks { |
| 8713 | 8725 | list-style: none; |
| 8714 | 8726 | margin: 0; |
@@ -8732,13 +8744,13 @@ button.shithub-repo-action { | ||
| 8732 | 8744 | background: var(--canvas-subtle); |
| 8733 | 8745 | border-radius: 0 0 6px 6px; |
| 8734 | 8746 | } |
| 8735 | -.shithub-pull-merge-form { | |
| 8747 | +.shithub-pull-merge-choice { | |
| 8736 | 8748 | display: flex; |
| 8737 | 8749 | align-items: center; |
| 8738 | 8750 | gap: 0; |
| 8739 | 8751 | margin: 0; |
| 8740 | 8752 | } |
| 8741 | -.shithub-pull-merge-form select { | |
| 8753 | +.shithub-pull-merge-choice select { | |
| 8742 | 8754 | min-height: 32px; |
| 8743 | 8755 | padding: 0 0.55rem; |
| 8744 | 8756 | border: 1px solid var(--border-default); |
@@ -8748,7 +8760,42 @@ button.shithub-repo-action { | ||
| 8748 | 8760 | color: var(--fg-default); |
| 8749 | 8761 | font: inherit; |
| 8750 | 8762 | } |
| 8751 | -.shithub-pull-merge-form .shithub-button { border-radius: 6px 0 0 6px; } | |
| 8763 | +.shithub-pull-merge-choice .shithub-button { border-radius: 6px 0 0 6px; } | |
| 8764 | +.shithub-pull-merge-confirm { | |
| 8765 | + display: grid; | |
| 8766 | + gap: 0.85rem; | |
| 8767 | + flex: 1 1 100%; | |
| 8768 | + margin: 0; | |
| 8769 | +} | |
| 8770 | +.shithub-pull-merge-confirm[hidden] { display: none; } | |
| 8771 | +.shithub-pull-merge-confirm label { | |
| 8772 | + display: grid; | |
| 8773 | + gap: 0.35rem; | |
| 8774 | + font-weight: 600; | |
| 8775 | +} | |
| 8776 | +.shithub-pull-merge-confirm input, | |
| 8777 | +.shithub-pull-merge-confirm textarea { | |
| 8778 | + width: 100%; | |
| 8779 | + border: 1px solid var(--border-default); | |
| 8780 | + border-radius: 6px; | |
| 8781 | + background: var(--canvas-default); | |
| 8782 | + color: var(--fg-default); | |
| 8783 | + font: inherit; | |
| 8784 | + padding: 0.55rem 0.65rem; | |
| 8785 | +} | |
| 8786 | +.shithub-pull-merge-confirm textarea { | |
| 8787 | + min-height: 10rem; | |
| 8788 | + resize: vertical; | |
| 8789 | + line-height: 1.5; | |
| 8790 | +} | |
| 8791 | +.shithub-pull-delete-branch-form { | |
| 8792 | + align-self: center; | |
| 8793 | + margin: 0; | |
| 8794 | +} | |
| 8795 | +.shithub-pull-branch-deleted { | |
| 8796 | + align-self: center; | |
| 8797 | + white-space: nowrap; | |
| 8798 | +} | |
| 8752 | 8799 | .shithub-pull-state-form { margin-top: 0.5rem; } |
| 8753 | 8800 | .shithub-pull-refs { display: flex; gap: 0.4rem; align-items: flex-end; } |
| 8754 | 8801 | .shithub-pull-refs label { flex: 1; } |
internal/web/static/js/pull-view.jsadded@@ -0,0 +1,37 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +(function () { | |
| 4 | + function initMergeBox(root) { | |
| 5 | + const openButton = root.querySelector("[data-pull-merge-open]"); | |
| 6 | + const cancelButton = root.querySelector("[data-pull-merge-cancel]"); | |
| 7 | + const choice = root.querySelector("[data-pull-merge-choice]"); | |
| 8 | + const confirm = root.querySelector("[data-pull-merge-confirm]"); | |
| 9 | + const method = root.querySelector("[data-pull-merge-method]"); | |
| 10 | + const confirmMethod = root.querySelector("[data-pull-merge-confirm-method]"); | |
| 11 | + const subject = root.querySelector("[data-pull-merge-subject]"); | |
| 12 | + if (!openButton || !choice || !confirm) return; | |
| 13 | + | |
| 14 | + function syncMethod() { | |
| 15 | + if (method && confirmMethod) confirmMethod.value = method.value; | |
| 16 | + } | |
| 17 | + | |
| 18 | + openButton.addEventListener("click", function () { | |
| 19 | + syncMethod(); | |
| 20 | + choice.hidden = true; | |
| 21 | + confirm.hidden = false; | |
| 22 | + if (subject) subject.focus(); | |
| 23 | + }); | |
| 24 | + | |
| 25 | + if (cancelButton) { | |
| 26 | + cancelButton.addEventListener("click", function () { | |
| 27 | + confirm.hidden = true; | |
| 28 | + choice.hidden = false; | |
| 29 | + openButton.focus(); | |
| 30 | + }); | |
| 31 | + } | |
| 32 | + | |
| 33 | + if (method) method.addEventListener("change", syncMethod); | |
| 34 | + } | |
| 35 | + | |
| 36 | + document.querySelectorAll("[data-pull-merge-box]").forEach(initMergeBox); | |
| 37 | +})(); | |
internal/web/templates/_layout.htmlmodified@@ -37,6 +37,7 @@ | ||
| 37 | 37 | <link rel="stylesheet" href="/static/css/chroma.css"> |
| 38 | 38 | {{ if flag . "UseHTMX" }}<script src="/static/vendor/htmx/htmx.min.js" defer></script>{{ end }} |
| 39 | 39 | {{ if flag . "UseCompareJS" }}<script src="/static/js/compare.js" defer></script>{{ end }} |
| 40 | + {{ if flag . "UsePullViewJS" }}<script src="/static/js/pull-view.js" defer></script>{{ end }} | |
| 40 | 41 | {{ if flag . "UseCommentEditor" }}<script src="/static/js/comment-editor.js" defer></script>{{ end }} |
| 41 | 42 | </head> |
| 42 | 43 | <body class="shithub-body"> |
internal/web/templates/repo/pull_view.htmlmodified@@ -87,7 +87,7 @@ | ||
| 87 | 87 | <time datetime="{{ .PR.ICreatedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .PR.ICreatedAt.Time }}</time> |
| 88 | 88 | </div> |
| 89 | 89 | <div class="shithub-comment-body markdown-body"> |
| 90 | - {{ if .PR.IBodyHtmlCached.Valid }}{{ safeHTML .PR.IBodyHtmlCached.String }}{{ else }}<p>{{ .PR.IBody }}</p>{{ end }} | |
| 90 | + {{ if .PR.IBodyHtmlCached.Valid }}{{ safeHTML .PR.IBodyHtmlCached.String }}{{ else if .PR.IBody }}<p>{{ .PR.IBody }}</p>{{ else }}<p><em>No description provided.</em></p>{{ end }} | |
| 91 | 91 | </div> |
| 92 | 92 | </div> |
| 93 | 93 | |
@@ -108,6 +108,7 @@ | ||
| 108 | 108 | <span class="shithub-event-icon" aria-hidden="true"> |
| 109 | 109 | {{ if eq .E.Kind "closed" }}{{ octicon "issue-closed" }} |
| 110 | 110 | {{ else if eq .E.Kind "reopened" }}{{ octicon "issue-opened" }} |
| 111 | + {{ else if eq .E.Kind "merged" }}{{ octicon "git-merge" }} | |
| 111 | 112 | {{ else if eq .E.Kind "locked" }}{{ octicon "lock" }} |
| 112 | 113 | {{ else if eq .E.Kind "unlocked" }}{{ octicon "unlock" }} |
| 113 | 114 | {{ else if or (eq .E.Kind "labeled") (eq .E.Kind "unlabeled") }}{{ octicon "tag" }} |
@@ -115,9 +116,13 @@ | ||
| 115 | 116 | {{ else if or (eq .E.Kind "milestoned") (eq .E.Kind "demilestoned") }}{{ octicon "milestone" }} |
| 116 | 117 | {{ else }}{{ octicon "comment" }}{{ end }} |
| 117 | 118 | </span> |
| 118 | - <span> | |
| 119 | + <span class="shithub-event-text"> | |
| 119 | 120 | {{ if .ActorName }}<a href="/{{ .ActorName }}">{{ .ActorName }}</a>{{ else }}Someone{{ end }} |
| 120 | - {{ if .LabelName }} | |
| 121 | + {{ if eq .E.Kind "merged" }} | |
| 122 | + merged commit | |
| 123 | + {{ if .CommitSHA }}<a class="shithub-mono-link" href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .CommitSHA }}">{{ .ShortCommit }}</a>{{ end }} | |
| 124 | + into <a class="shithub-branch-name" href="/{{ $.Owner }}/{{ $.Repo.Name }}/tree/{{ $.PR.BaseRef }}">{{ $.PR.BaseRef }}</a> | |
| 125 | + {{ else if .LabelName }} | |
| 121 | 126 | {{ if eq .E.Kind "labeled" }}added the{{ else }}removed the{{ end }} |
| 122 | 127 | <span class="shithub-label" style="background-color: #{{ .LabelColor }}">{{ .LabelName }}</span> |
| 123 | 128 | label |
@@ -126,6 +131,11 @@ | ||
| 126 | 131 | {{ end }} |
| 127 | 132 | <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .CreatedAt }}</time> |
| 128 | 133 | </span> |
| 134 | + {{ if and (eq .E.Kind "merged") .CommitSHA }} | |
| 135 | + <span class="shithub-event-actions"> | |
| 136 | + <a class="shithub-button" href="/{{ $.Owner }}/{{ $.Repo.Name }}/commit/{{ .CommitSHA }}">View details</a> | |
| 137 | + </span> | |
| 138 | + {{ end }} | |
| 129 | 139 | </div> |
| 130 | 140 | {{ end }} |
| 131 | 141 | {{ end }} |
@@ -140,14 +150,22 @@ | ||
| 140 | 150 | </section> |
| 141 | 151 | {{ end }} |
| 142 | 152 | |
| 143 | - <section class="shithub-pull-merge-box shithub-pull-merge-box-{{ if .PR.MergedAt.Valid }}merged{{ else if eq (printf "%s" .PR.IState) "closed" }}closed{{ else if eq $mergeable "clean" }}ready{{ else if eq $mergeable "dirty" }}blocked{{ else }}pending{{ end }}"> | |
| 153 | + <section class="shithub-pull-merge-box shithub-pull-merge-box-{{ if .PR.MergedAt.Valid }}merged{{ else if eq (printf "%s" .PR.IState) "closed" }}closed{{ else if eq $mergeable "clean" }}ready{{ else if eq $mergeable "dirty" }}blocked{{ else }}pending{{ end }}" data-pull-merge-box> | |
| 144 | 154 | {{ if .PR.MergedAt.Valid }} |
| 145 | 155 | <div class="shithub-pull-merge-row"> |
| 146 | 156 | <span class="shithub-pull-status-icon shithub-pull-status-icon-merged">{{ octicon "git-merge" }}</span> |
| 147 | 157 | <div> |
| 148 | 158 | <strong>Pull request successfully merged and closed</strong> |
| 149 | - <p class="shithub-muted">Merged via <code>{{ printf "%s" .PR.MergeMethod.PrMergeMethod }}</code>.</p> | |
| 159 | + <p class="shithub-muted">You're all set — the <a class="shithub-branch-name" href="/{{ .Owner }}/{{ .Repo.Name }}/tree/{{ .PR.HeadRef }}">{{ .PR.HeadRef }}</a> branch can be safely deleted.</p> | |
| 150 | 160 | </div> |
| 161 | + {{ if .CanDeleteHeadBranch }} | |
| 162 | + <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/delete-branch" class="shithub-pull-delete-branch-form"> | |
| 163 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 164 | + <button type="submit" class="shithub-button">Delete branch</button> | |
| 165 | + </form> | |
| 166 | + {{ else if .HeadBranchAlreadyGone }} | |
| 167 | + <span class="shithub-muted shithub-pull-branch-deleted">Branch deleted</span> | |
| 168 | + {{ end }} | |
| 151 | 169 | </div> |
| 152 | 170 | {{ else if eq (printf "%s" .PR.IState) "closed" }} |
| 153 | 171 | <div class="shithub-pull-merge-row"> |
@@ -227,14 +245,30 @@ | ||
| 227 | 245 | <div class="shithub-pull-merge-actions"> |
| 228 | 246 | {{ if .CanMergePull }} |
| 229 | 247 | {{ if eq $mergeable "clean" }} |
| 230 | - <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/merge" class="shithub-pull-merge-form"> | |
| 231 | - <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 232 | - <button type="submit" class="shithub-button shithub-button-primary">Merge pull request</button> | |
| 233 | - <select name="method" aria-label="Merge method"> | |
| 248 | + <div class="shithub-pull-merge-choice" data-pull-merge-choice> | |
| 249 | + <button type="button" class="shithub-button shithub-button-primary" data-pull-merge-open>Merge pull request</button> | |
| 250 | + <select aria-label="Merge method" data-pull-merge-method> | |
| 234 | 251 | {{ if .Repo.AllowMergeCommit }}<option value="merge" {{ if eq (printf "%s" .Repo.DefaultMergeMethod) "merge" }}selected{{ end }}>Merge commit</option>{{ end }} |
| 235 | 252 | {{ if .Repo.AllowSquashMerge }}<option value="squash" {{ if eq (printf "%s" .Repo.DefaultMergeMethod) "squash" }}selected{{ end }}>Squash and merge</option>{{ end }} |
| 236 | 253 | {{ if .Repo.AllowRebaseMerge }}<option value="rebase" {{ if eq (printf "%s" .Repo.DefaultMergeMethod) "rebase" }}selected{{ end }}>Rebase and merge</option>{{ end }} |
| 237 | 254 | </select> |
| 255 | + </div> | |
| 256 | + <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}/merge" class="shithub-pull-merge-confirm" data-pull-merge-confirm hidden> | |
| 257 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 258 | + <input type="hidden" name="method" value="{{ .MergeDefaultMethod }}" data-pull-merge-confirm-method> | |
| 259 | + <label> | |
| 260 | + <span>Commit message</span> | |
| 261 | + <input type="text" name="subject" value="{{ .MergeFormSubject }}" data-pull-merge-subject> | |
| 262 | + </label> | |
| 263 | + <label> | |
| 264 | + <span>Extended description</span> | |
| 265 | + <textarea name="body" rows="8">{{ .MergeFormBody }}</textarea> | |
| 266 | + </label> | |
| 267 | + {{ if .MergeAuthorLine }}<p class="shithub-muted">{{ .MergeAuthorLine }}</p>{{ end }} | |
| 268 | + <div class="shithub-form-actions shithub-form-actions-start"> | |
| 269 | + <button type="submit" class="shithub-button shithub-button-primary">Confirm merge</button> | |
| 270 | + <button type="button" class="shithub-button" data-pull-merge-cancel>Cancel</button> | |
| 271 | + </div> | |
| 238 | 272 | </form> |
| 239 | 273 | {{ end }} |
| 240 | 274 | {{ end }} |