Commit e25c7eeb20
Verified · cmc ci/build: success ci/test: success
Layout: unified · split
.gitbay/wiki/Parity.org +1
| @@ -222,6 +222,7 @@ rather than the one the web page shows. | ||
| 222 | 222 | | atom feeds | n/a | yes | n/a | |
| 223 | 223 | | release create, edit | yes | yes | yes | |
| 224 | 224 | | preview release notes | n/a | yes | no | |
| 225 | | summarize changes since the previous release | yes | yes | no | | |
| 225 | 226 | | build list | yes | yes | yes | |
| 226 | 227 | | build list paging (limit, cursor) | yes | yes | no | |
| 227 | 228 | | build row names its commit | yes | yes | no | |
.gitbay/wiki/Users.org +10
| @@ -477,8 +477,18 @@ gitbay release create v1.0 --title "First light" [--notes|--file -|$EDITOR] | ||
| 477 | 477 | gitbay release asset add v1.0 tool-linux-amd64 < dist/tool-linux-amd64 |
| 478 | 478 | gitbay release asset get v1.0 tool-linux-amd64 > tool # or the web download link |
| 479 | 479 | gitbay release list / show v1.0 / delete v1.0 --yes |
| 480 | gitbay release notes v1.0 [--since <tag>] # one line per merge request since the previous release | |
| 480 | 481 | #+end_src |
| 481 | 482 | |
| 483 | =release notes= drafts the notes: every merge request landed between the | |
| 484 | previous release's tag (or =--since=) and the ref, oldest first, as | |
| 485 | =- <title> (!<n>) by @<author>=. The ref may be a branch, for drafting | |
| 486 | before the tag exists. Pipe it into =release create --file -=, or press | |
| 487 | Summarize changes on the web form, which appends the same lines to the | |
| 488 | notes box. A merge request is attributed by the commit it landed as; | |
| 489 | one merged by squash or rebase before the instance recorded it is not | |
| 490 | listed. | |
| 491 | ||
| 482 | 492 | The web shows them under the repository's =releases= tab with rendered |
| 483 | 493 | notes, sha256 sums, and download links; writers add and remove assets |
| 484 | 494 | there (a file upload, up to =max_asset_bytes=; removal asks for the file |
cmd/gitbay/main.go +1
| @@ -676,6 +676,7 @@ func releaseCmd() *cobra.Command { | ||
| 676 | 676 | pass("edit", passOpts{server: []string{"release", "edit"}, needsRepo: true, stdinOK: true}), |
| 677 | 677 | pass("list", passOpts{server: []string{"release", "list"}, needsRepo: true}), |
| 678 | 678 | pass("show", passOpts{server: []string{"release", "show"}, needsRepo: true}), |
| 679 | pass("notes", passOpts{server: []string{"release", "notes"}, needsRepo: true}), | |
| 679 | 680 | pass("delete", passOpts{server: []string{"release", "delete"}, needsRepo: true}), |
| 680 | 681 | group("asset", "binary assets on a release", |
| 681 | 682 | pass("add", passOpts{server: []string{"release", "asset", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "the asset's bytes"}), |
cmd/gitbay/summaries_gen.go +1
| @@ -147,6 +147,7 @@ var summaries = map[string]string{ | ||
| 147 | 147 | "release delete": "delete a release and its assets", |
| 148 | 148 | "release edit": "update a release's title and notes", |
| 149 | 149 | "release list": "list releases", |
| 150 | "release notes": "draft notes: one line per merge request since the previous release", | |
| 150 | 151 | "release show": "show a release with assets", |
| 151 | 152 | "repo access grant": "grant access", |
| 152 | 153 | "repo access list": "list who can reach the repository, with the role and where it comes from", |
e2e/readonly_test.go +1
| @@ -157,6 +157,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | ||
| 157 | 157 | "build log": {"alice/app", "1"}, |
| 158 | 158 | "release list": {"alice/app"}, |
| 159 | 159 | "release show": {"alice/app", "v1"}, |
| 160 | "release notes": {"alice/app", "main"}, | |
| 160 | 161 | "release asset get": {"alice/app", "v1", "a.txt"}, |
| 161 | 162 | "snippet show": {snippetID}, |
| 162 | 163 | "snippet list": {}, |
e2e/releaseweb_test.go +28
| @@ -63,6 +63,34 @@ func TestReleaseAndBuildWeb(t *testing.T) { | ||
| 63 | 63 | if !strings.Contains(out, "Second light") || !strings.Contains(out, "revised") { |
| 64 | 64 | t.Fatalf("release not edited:\n%s", out) |
| 65 | 65 | } |
| 66 | // Summarize changes fills the notes box with one line per merge | |
| 67 | // request landed since the previous release, after what was typed, | |
| 68 | // and writes nothing (#331). | |
| 69 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") | |
| 70 | os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) | |
| 71 | mustGit(t, dir, env, "add", ".") | |
| 72 | mustGit(t, dir, env, "commit", "-q", "-m", "feat") | |
| 73 | mustGit(t, dir, env, "push", "-q", "origin", "feat") | |
| 74 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app", "--source", "feat", "--target", "main", "--title", "add-b"); code != 0 { | |
| 75 | t.Fatalf("mr create: %s", errOut) | |
| 76 | } | |
| 77 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1"); code != 0 { | |
| 78 | t.Fatalf("mr merge: %s", errOut) | |
| 79 | } | |
| 80 | mustGit(t, dir, env, "fetch", "-q", "origin") | |
| 81 | mustGit(t, dir, env, "tag", "v2.0", "origin/main") | |
| 82 | mustGit(t, dir, env, "push", "-q", "origin", "v2.0") | |
| 83 | status, page := browserPost(t, alice, base+"/releases", url.Values{ | |
| 84 | "tag": {"v2.0"}, "notes": {"Second cut."}, "summarize": {"1"}}) | |
| 85 | if status != 200 || !strings.Contains(page, "Second cut.\n\n- add-b (!1) by @alice\n</textarea>") || | |
| 86 | !strings.Contains(page, `<option value="v2.0" selected>`) { | |
| 87 | t.Fatalf("summarize did not fill the draft (%d):\n%s", status, page) | |
| 88 | } | |
| 89 | out, _, _ = inst.ssh(t, aliceKey, "", "release", "list", "alice/app", "--json") | |
| 90 | if strings.Contains(out, "v2.0") { | |
| 91 | t.Fatalf("summarize created a release: %s", out) | |
| 92 | } | |
| 93 | ||
| 66 | 94 | // A released tag is no longer offered for creation. |
| 67 | 95 | if _, page = browserGet(t, alice, base+"/releases"); strings.Contains(page, `<option value="v1.0"`) { |
| 68 | 96 | t.Fatalf("released tag still offered:\n%s", page) |
internal/control/ghimport.go +1 −1
| @@ -266,7 +266,7 @@ func runImportIssues(c *Ctx, args []string) int { | ||
| 266 | 266 | return c.fail(protocol.ExitFailure, "%v", err) |
| 267 | 267 | } |
| 268 | 268 | if pr.MergedAt != "" { |
| 269 | c.Store.MarkMerged(mr.ID, pr.Base.SHA, 0, pr.MergedAt) | |
| 269 | c.Store.MarkMerged(mr.ID, pr.Base.SHA, "", 0, pr.MergedAt) | |
| 270 | 270 | } else { |
| 271 | 271 | c.Store.MarkClosed(mr.ID, 0, it.ClosedAt) |
| 272 | 272 | } |
internal/control/migrate.go +1 −1
| @@ -292,7 +292,7 @@ func runAccountImportBundle(c *Ctx, args []string) int { | ||
| 292 | 292 | // MarkMerged, not SetMRState: a merged MR's diff is |
| 293 | 293 | // measured from the base recorded at merge time, and |
| 294 | 294 | // SetMRState leaves that empty. |
| 295 | c.Store.MarkMerged(mr.ID, bm.MergedBase, 0, bm.MergedAt) | |
| 295 | c.Store.MarkMerged(mr.ID, bm.MergedBase, "", 0, bm.MergedAt) | |
| 296 | 296 | case bm.State != "open": |
| 297 | 297 | state := bm.State |
| 298 | 298 | if state == "source_gone" { |
internal/control/mr.go +2 −2
| @@ -1420,7 +1420,7 @@ func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int { | ||
| 1420 | 1420 | // a merge whose ref update landed and whose record did not. Record |
| 1421 | 1421 | // it rather than refuse, so a merge request cannot be stuck open |
| 1422 | 1422 | // with no way to close it as merged (#108). |
| 1423 | if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil { | |
| 1423 | if err := c.Store.MarkMerged(mr.ID, targetSHA, headSHA, c.User.ID, ""); err != nil { | |
| 1424 | 1424 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1425 | 1425 | } |
| 1426 | 1426 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d}`, mr.Number)) |
| @@ -1636,7 +1636,7 @@ func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int { | ||
| 1636 | 1636 | if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil { |
| 1637 | 1637 | return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err) |
| 1638 | 1638 | } |
| 1639 | if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil { | |
| 1639 | if err := c.Store.MarkMerged(mr.ID, targetSHA, newSHA, c.User.ID, ""); err != nil { | |
| 1640 | 1640 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1641 | 1641 | } |
| 1642 | 1642 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA)) |
internal/control/mrprune_test.go +1 −1
| @@ -48,7 +48,7 @@ func prunedRepo(t *testing.T) (*store.Store, store.Repo, string, string) { | ||
| 48 | 48 | if err != nil { |
| 49 | 49 | t.Fatal(err) |
| 50 | 50 | } |
| 51 | if err := st.MarkMerged(mr.ID, headSHA, uid, ""); err != nil { | |
| 51 | if err := st.MarkMerged(mr.ID, headSHA, headSHA, uid, ""); err != nil { | |
| 52 | 52 | t.Fatal(err) |
| 53 | 53 | } |
| 54 | 54 | return st, repo, root, headSHA |
internal/control/release.go +106
| @@ -58,6 +58,17 @@ func init() { | ||
| 58 | 58 | Usage: "release show <owner/name> <tag>", |
| 59 | 59 | Examples: []string{"release show krz/gitbay v1.30.0"}, |
| 60 | 60 | ReadOnly: true, Run: runReleaseShow}) |
| 61 | register(Command{Path: []string{"release", "notes"}, | |
| 62 | Summary: "draft notes: one line per merge request since the previous release", | |
| 63 | Usage: "release notes <owner/name> <ref> [--since <tag>]", | |
| 64 | Flags: []Flag{ | |
| 65 | {"--since", "<tag>", "the start of the range", "the previous release's tag"}, | |
| 66 | }, | |
| 67 | Examples: []string{ | |
| 68 | "release notes krz/gitbay main", | |
| 69 | "release notes krz/gitbay v1.44.0 --since v1.43.0", | |
| 70 | }, | |
| 71 | ReadOnly: true, Run: runReleaseNotes}) | |
| 61 | 72 | register(Command{Path: []string{"release", "delete"}, |
| 62 | 73 | Summary: "delete a release and its assets", |
| 63 | 74 | Usage: "release delete <owner/name> <tag> --yes", |
| @@ -485,3 +496,98 @@ func runAssetRemove(c *Ctx, args []string) int { | ||
| 485 | 496 | fmt.Fprintf(w, "removed %s\n", name) |
| 486 | 497 | }) |
| 487 | 498 | } |
| 499 | ||
| 500 | type releaseNoteItem struct { | |
| 501 | Number int64 `json:"number"` | |
| 502 | Title string `json:"title"` | |
| 503 | Author string `json:"author"` | |
| 504 | MergedAt string `json:"merged_at"` | |
| 505 | } | |
| 506 | ||
| 507 | type releaseNotesOut struct { | |
| 508 | Since string `json:"since"` // the tag the range starts after; "" for all of history | |
| 509 | Items []releaseNoteItem `json:"items"` | |
| 510 | } | |
| 511 | ||
| 512 | // runReleaseNotes lists the merge requests landed in <since>..<ref>, | |
| 513 | // oldest first. A merge request is in the range when the commit it | |
| 514 | // landed as is; one merged before merged_sha existed is matched on its | |
| 515 | // head, which is right for fast-forward and merge-commit merges and | |
| 516 | // misses old squashes and rebases. Without --since the range starts at | |
| 517 | // the most recent release other than the ref's own, so regenerating a | |
| 518 | // released tag's notes gives the same answer as drafting them did. | |
| 519 | func runReleaseNotes(c *Ctx, args []string) int { | |
| 520 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--since"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 521 | if err != nil { | |
| 522 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 523 | } | |
| 524 | path, ref, since := f.pos(0), f.pos(1), f.Value("--since") | |
| 525 | if path == "" || ref == "" { | |
| 526 | return c.usage() | |
| 527 | } | |
| 528 | repo, code := resolveRepo(c, path, policy.CanRead) | |
| 529 | if code >= 0 { | |
| 530 | return code | |
| 531 | } | |
| 532 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 533 | refSHA, err := gitutil.ResolveRef(dir, ref) | |
| 534 | if err != nil { | |
| 535 | return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) | |
| 536 | } | |
| 537 | if since == "" { | |
| 538 | rels, err := c.Store.ListReleases(repo.ID) | |
| 539 | if err != nil { | |
| 540 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 541 | } | |
| 542 | for _, rel := range rels { | |
| 543 | if rel.Tag != ref { | |
| 544 | since = rel.Tag | |
| 545 | break | |
| 546 | } | |
| 547 | } | |
| 548 | } | |
| 549 | var shas []string | |
| 550 | if since == "" { | |
| 551 | shas, err = gitutil.RevList(dir, refSHA, -1) // a negative max-count is no limit | |
| 552 | } else { | |
| 553 | var sinceSHA string | |
| 554 | if sinceSHA, err = gitutil.ResolveRef(dir, since); err != nil { | |
| 555 | return c.fail(protocol.ExitNotFound, "no tag %q in %s", since, repo.Path()) | |
| 556 | } | |
| 557 | shas, err = gitutil.RevListRange(dir, sinceSHA, refSHA) | |
| 558 | } | |
| 559 | if err != nil { | |
| 560 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 561 | } | |
| 562 | mrs, err := c.Store.QueryMRs(repo.ID, store.MRFilter{State: "merged"}) | |
| 563 | if err != nil { | |
| 564 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 565 | } | |
| 566 | landed := map[string]store.MR{} | |
| 567 | for _, mr := range mrs { | |
| 568 | sha := mr.MergedSHA | |
| 569 | if sha == "" { | |
| 570 | sha = mr.HeadSHA | |
| 571 | } | |
| 572 | landed[sha] = mr | |
| 573 | } | |
| 574 | out := releaseNotesOut{Since: since, Items: []releaseNoteItem{}} | |
| 575 | for i := len(shas) - 1; i >= 0; i-- { | |
| 576 | if mr, ok := landed[shas[i]]; ok { | |
| 577 | out.Items = append(out.Items, releaseNoteItem{mr.Number, mr.Title, mr.Author, mr.MergedAt}) | |
| 578 | } | |
| 579 | } | |
| 580 | if len(out.Items) == 0 && !c.JSON { | |
| 581 | if since == "" { | |
| 582 | fmt.Fprintf(c.Stderr, "no merge requests landed on %s\n", ref) | |
| 583 | } else { | |
| 584 | fmt.Fprintf(c.Stderr, "no merge requests landed between %s and %s\n", since, ref) | |
| 585 | } | |
| 586 | return protocol.ExitOK | |
| 587 | } | |
| 588 | return c.emit(out, func(w io.Writer) { | |
| 589 | for _, it := range out.Items { | |
| 590 | fmt.Fprintf(w, "- %s (!%d) by @%s\n", it.Title, it.Number, it.Author) | |
| 591 | } | |
| 592 | }) | |
| 593 | } | |
internal/control/release_test.go +119
| @@ -2,6 +2,8 @@ package control | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | "encoding/json" | |
| 6 | "strconv" | |
| 5 | 7 | "strings" |
| 6 | 8 | "testing" |
| 7 | 9 | |
| @@ -84,3 +86,120 @@ func TestReleaseListPageSurvivesTheCursorReleaseBeingDeleted(t *testing.T) { | ||
| 84 | 86 | t.Fatalf("page 2 after deleting the cursor release: %q", got) |
| 85 | 87 | } |
| 86 | 88 | } |
| 89 | ||
| 90 | // A merge records the commit it landed as: the head for a fast-forward, | |
| 91 | // the new commit for a squash (#331). | |
| 92 | func TestMergeRecordsTheLandedCommit(t *testing.T) { | |
| 93 | f := newQueueFixture(t, nil) | |
| 94 | f.verified(f.alice) | |
| 95 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--strategy", "squash") | |
| 96 | tip := strings.TrimSpace(f.git(f.dir, "rev-parse", "main")) | |
| 97 | if mr := f.mr(); mr.MergedSHA != tip || tip == f.headSHA { | |
| 98 | t.Fatalf("merged_sha %q, main %q, head %q", mr.MergedSHA, tip, f.headSHA) | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | // land opens a merge request from a new branch holding one commit on top | |
| 103 | // of the repository's main and merges it with strategy, returning its | |
| 104 | // number. | |
| 105 | func (f *queueFixture) land(branch, title, strategy string) int64 { | |
| 106 | f.t.Helper() | |
| 107 | f.git(f.src, "fetch", "-q", f.dir, "main:refs/remotes/bare/main") | |
| 108 | f.git(f.src, "checkout", "-q", "-b", branch, "bare/main") | |
| 109 | f.write(branch+".txt", title+"\n") | |
| 110 | f.git(f.src, "add", ".") | |
| 111 | f.git(f.src, "commit", "-q", "-m", title) | |
| 112 | sha := strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD")) | |
| 113 | f.git(f.dir, "fetch", "-q", f.src, "refs/heads/"+branch+":refs/heads/"+branch) | |
| 114 | id, err := f.st.CreateMR(f.repo.ID, f.alice.ID, f.repo.ID, branch, "main", title, "", sha, "md", false) | |
| 115 | if err != nil { | |
| 116 | f.t.Fatal(err) | |
| 117 | } | |
| 118 | mr, err := f.st.MRByID(id) | |
| 119 | if err != nil { | |
| 120 | f.t.Fatal(err) | |
| 121 | } | |
| 122 | f.git(f.dir, "update-ref", mrHeadRef(mr.Number), sha) | |
| 123 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), strconv.FormatInt(mr.Number, 10), "--strategy", strategy) | |
| 124 | return mr.Number | |
| 125 | } | |
| 126 | ||
| 127 | // release notes lists the merge requests landed between the previous | |
| 128 | // release's tag and a ref, oldest first, one line each; --since names | |
| 129 | // the range's start instead (#331). | |
| 130 | func TestReleaseNotesListsMergedMRsSinceThePreviousRelease(t *testing.T) { | |
| 131 | f := newQueueFixture(t, nil) | |
| 132 | f.verified(f.alice) | |
| 133 | f.git(f.dir, "tag", "v0", f.targetSH) | |
| 134 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--strategy", "ff") | |
| 135 | ||
| 136 | // No release yet: the whole history of the ref. | |
| 137 | if out := f.mustRun(f.alice, "release", "notes", f.repo.Path(), "main"); out != "- t (!1) by @alice\n" { | |
| 138 | t.Fatalf("no previous release:\n%s", out) | |
| 139 | } | |
| 140 | ||
| 141 | f.git(f.dir, "tag", "v1", "main") | |
| 142 | f.mustRun(f.alice, "release", "create", f.repo.Path(), "v1") | |
| 143 | f.land("two", "two", "squash") | |
| 144 | f.land("three", "three", "ff") | |
| 145 | // An open merge request and one into another branch are not changes. | |
| 146 | f.branch("dev", f.targetSH) | |
| 147 | f.git(f.src, "checkout", "-q", "-b", "side", f.targetSH) | |
| 148 | f.write("side.txt", "s\n") | |
| 149 | f.git(f.src, "add", ".") | |
| 150 | f.git(f.src, "commit", "-q", "-m", "side") | |
| 151 | side := strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD")) | |
| 152 | f.git(f.dir, "fetch", "-q", f.src, "refs/heads/side:refs/heads/side") | |
| 153 | if _, err := f.st.CreateMR(f.repo.ID, f.alice.ID, f.repo.ID, "side", "dev", "side", "", side, "md", false); err != nil { | |
| 154 | t.Fatal(err) | |
| 155 | } | |
| 156 | f.git(f.dir, "update-ref", mrHeadRef(4), side) | |
| 157 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "4", "--strategy", "ff") | |
| 158 | ||
| 159 | if out := f.mustRun(f.alice, "release", "notes", f.repo.Path(), "main"); out != "- two (!2) by @alice\n- three (!3) by @alice\n" { | |
| 160 | t.Fatalf("since v1:\n%s", out) | |
| 161 | } | |
| 162 | // --since overrides the previous release; a row merged before 0079 | |
| 163 | // has no merged_sha and is matched on its head. | |
| 164 | if _, err := f.st.DB.Exec("UPDATE merge_requests SET merged_sha = '' WHERE number = 1"); err != nil { | |
| 165 | t.Fatal(err) | |
| 166 | } | |
| 167 | if out := f.mustRun(f.alice, "release", "notes", f.repo.Path(), "main", "--since", "v0"); out != "- t (!1) by @alice\n- two (!2) by @alice\n- three (!3) by @alice\n" { | |
| 168 | t.Fatalf("--since v0:\n%s", out) | |
| 169 | } | |
| 170 | // Regenerating for a tag that already has a release starts at the one before it. | |
| 171 | f.git(f.dir, "tag", "v2", "main") | |
| 172 | f.mustRun(f.alice, "release", "create", f.repo.Path(), "v2") | |
| 173 | if out := f.mustRun(f.alice, "release", "notes", f.repo.Path(), "v2"); out != "- two (!2) by @alice\n- three (!3) by @alice\n" { | |
| 174 | t.Fatalf("v2 with its own release:\n%s", out) | |
| 175 | } | |
| 176 | ||
| 177 | out := f.mustRun(f.alice, "release", "notes", f.repo.Path(), "v2", "--json") | |
| 178 | var env struct { | |
| 179 | Data struct { | |
| 180 | Since string `json:"since"` | |
| 181 | Items []struct { | |
| 182 | Number int64 `json:"number"` | |
| 183 | Title string `json:"title"` | |
| 184 | Author string `json:"author"` | |
| 185 | MergedAt string `json:"merged_at"` | |
| 186 | } `json:"items"` | |
| 187 | } `json:"data"` | |
| 188 | } | |
| 189 | if err := json.Unmarshal([]byte(out), &env); err != nil { | |
| 190 | t.Fatal(err) | |
| 191 | } | |
| 192 | d := env.Data | |
| 193 | if d.Since != "v1" || len(d.Items) != 2 || d.Items[0].Number != 2 || d.Items[1].Title != "three" || | |
| 194 | d.Items[0].Author != "alice" || d.Items[0].MergedAt == "" { | |
| 195 | t.Fatalf("json: %s", out) | |
| 196 | } | |
| 197 | ||
| 198 | // An unknown ref or --since is not found. | |
| 199 | if code, _, _ := f.run(f.alice, "release", "notes", f.repo.Path(), "nope"); code != protocol.ExitNotFound { | |
| 200 | t.Fatalf("unknown ref: exit %d", code) | |
| 201 | } | |
| 202 | if code, _, _ := f.run(f.alice, "release", "notes", f.repo.Path(), "main", "--since", "nope"); code != protocol.ExitNotFound { | |
| 203 | t.Fatalf("unknown --since: exit %d", code) | |
| 204 | } | |
| 205 | } | |
internal/httpd/releaseactions.go +18
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "net/http" |
| 6 | 6 | "strings" |
| 7 | 7 | |
| 8 | "gitbay.org/gitbay/internal/protocol" | |
| 8 | 9 | "gitbay.org/gitbay/internal/store" |
| 9 | 10 | ) |
| 10 | 11 | |
| @@ -39,6 +40,23 @@ func (s *Server) releaseSubmit(w http.ResponseWriter, r *http.Request, u store.U | ||
| 39 | 40 | return |
| 40 | 41 | } |
| 41 | 42 | back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) } |
| 43 | // Summarize changes: the create form back with one line per merge | |
| 44 | // request since the previous release appended to the notes, nothing | |
| 45 | // written (#331). An empty range reports why instead of a silent | |
| 46 | // round trip. | |
| 47 | if r.FormValue("summarize") != "" { | |
| 48 | out, msg, code := s.runControlCode(u, []string{"release", "notes", repo, tag}) | |
| 49 | if code != protocol.ExitOK || out == "" { | |
| 50 | back(w, r, msg) | |
| 51 | return | |
| 52 | } | |
| 53 | if notes != "" { | |
| 54 | out = notes + "\n\n" + out | |
| 55 | } | |
| 56 | r.Form.Set("notes", out) | |
| 57 | s.releasesPage(w, r, "release") | |
| 58 | return | |
| 59 | } | |
| 42 | 60 | // The CLI's --yes guards against a mistyped tag; here the tag comes |
| 43 | 61 | // from the page, so the browser's own confirm field stands in. |
| 44 | 62 | if r.FormValue("action") == "delete" { |
internal/store/mergequeue_test.go +1 −1
| @@ -62,7 +62,7 @@ func TestMergeQueueLeftOnMergeOrClose(t *testing.T) { | ||
| 62 | 62 | s, repoID, uid := mrFixture(t) |
| 63 | 63 | mr, _ := s.MRByNumber(repoID, 1) |
| 64 | 64 | for _, mark := range []func() error{ |
| 65 | func() error { return s.MarkMerged(mr.ID, "base", uid, "") }, | |
| 65 | func() error { return s.MarkMerged(mr.ID, "base", "", uid, "") }, | |
| 66 | 66 | func() error { return s.MarkClosed(mr.ID, uid, "") }, |
| 67 | 67 | } { |
| 68 | 68 | if err := s.SetMRState(mr.ID, "open"); err != nil { |
internal/store/migrations/0079_mr_merged_sha.down.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | ALTER TABLE merge_requests DROP COLUMN merged_sha; | |
internal/store/migrations/0079_mr_merged_sha.up.sql added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | -- The commit a merge request landed as: the head itself for a | |
| 2 | -- fast-forward, the new commit for merge, squash and rebase. Release | |
| 3 | -- notes attribute merge requests to a commit range by it. Rows merged | |
| 4 | -- before this column stay '' and are matched on head_sha. | |
| 5 | ALTER TABLE merge_requests ADD COLUMN merged_sha TEXT NOT NULL DEFAULT ''; | |
internal/store/mrs.go +12 −9
| @@ -25,6 +25,7 @@ type MR struct { | ||
| 25 | 25 | Milestone string |
| 26 | 26 | HeadSHA string |
| 27 | 27 | MergedBase string // target tip at merge time; base for historical diffs |
| 28 | MergedSHA string // the commit it landed as; "" when merged before 0079 | |
| 28 | 29 | MergedAt string // "" unless merged |
| 29 | 30 | MergedBy string // "" when unknown (imports) or the account is gone |
| 30 | 31 | ClosedAt string // "" unless closed without merging |
| @@ -102,7 +103,7 @@ const mrSelect = ` | ||
| 102 | 103 | COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''), |
| 103 | 104 | m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft, |
| 104 | 105 | COALESCE(ms.title, ''), m.head_sha, |
| 105 | m.merged_base, m.merged_at, COALESCE(mu.username, ''), | |
| 106 | m.merged_base, m.merged_sha, m.merged_at, COALESCE(mu.username, ''), | |
| 106 | 107 | m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at, |
| 107 | 108 | COALESCE(q.user_id, 0), COALESCE(qu.username, ''), COALESCE(q.strategy, ''), |
| 108 | 109 | COALESCE(q.reason, ''), COALESCE(q.queued_at, '') |
| @@ -120,7 +121,7 @@ const mrSelect = ` | ||
| 120 | 121 | func scanMR(row interface{ Scan(...any) error }) (MR, error) { |
| 121 | 122 | var m MR |
| 122 | 123 | err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, |
| 123 | &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase, | |
| 124 | &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase, &m.MergedSHA, | |
| 124 | 125 | &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt, |
| 125 | 126 | &m.QueuedByID, &m.QueuedBy, &m.QueueStrategy, &m.QueueReason, &m.QueuedAt) |
| 126 | 127 | return m, err |
| @@ -282,16 +283,18 @@ func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, err | ||
| 282 | 283 | } |
| 283 | 284 | |
| 284 | 285 | // MarkMerged records the merge along with the target tip it landed on, so |
| 285 | // the MR's diff stays reconstructable after fast-forwards. actorID 0 and an | |
| 286 | // empty at leave the merger unknown and stamp the current time, which is | |
| 287 | // what an import that carries neither can say. | |
| 288 | func (s *Store) MarkMerged(mrID int64, baseSHA string, actorID int64, at string) error { | |
| 286 | // the MR's diff stays reconstructable after fast-forwards, and the commit | |
| 287 | // it landed as, which release notes match against a range. actorID 0 and | |
| 288 | // an empty at leave the merger unknown and stamp the current time, and an | |
| 289 | // empty mergedSHA leaves the landed commit unknown, which is what an | |
| 290 | // import that carries none of them can say. | |
| 291 | func (s *Store) MarkMerged(mrID int64, baseSHA, mergedSHA string, actorID int64, at string) error { | |
| 289 | 292 | _, err := s.DB.Exec( |
| 290 | `UPDATE merge_requests SET state = 'merged', merged_base = ?, | |
| 293 | `UPDATE merge_requests SET state = 'merged', merged_base = ?, merged_sha = ?, | |
| 291 | 294 | merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), |
| 292 | 295 | merged_by = NULLIF(?, 0), |
| 293 | 296 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, |
| 294 | baseSHA, at, actorID, mrID) | |
| 297 | baseSHA, mergedSHA, at, actorID, mrID) | |
| 295 | 298 | return err |
| 296 | 299 | } |
| 297 | 300 | |
| @@ -343,7 +346,7 @@ func (s *Store) MRsSuperseding(repoID, number int64) ([]MR, error) { | ||
| 343 | 346 | func (s *Store) SetMRState(mrID int64, state string) error { |
| 344 | 347 | stamp := "" |
| 345 | 348 | if state == "open" || state == "source_gone" { |
| 346 | stamp = ", merged_at = '', merged_by = NULL, closed_at = '', closed_by = NULL" | |
| 349 | stamp = ", merged_sha = '', merged_at = '', merged_by = NULL, closed_at = '', closed_by = NULL" | |
| 347 | 350 | } |
| 348 | 351 | res, err := s.DB.Exec( |
| 349 | 352 | "UPDATE merge_requests SET state = ?"+stamp+", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", |
internal/store/mrs_test.go +5 −5
| @@ -33,11 +33,11 @@ func TestResolutionStamps(t *testing.T) { | ||
| 33 | 33 | if mr.MergedAt != "" || mr.ClosedAt != "" { |
| 34 | 34 | t.Fatalf("open MR carries a stamp: %+v", mr) |
| 35 | 35 | } |
| 36 | if err := s.MarkMerged(mr.ID, "base1", uid, ""); err != nil { | |
| 36 | if err := s.MarkMerged(mr.ID, "base1", "landed1", uid, ""); err != nil { | |
| 37 | 37 | t.Fatal(err) |
| 38 | 38 | } |
| 39 | 39 | mr, _ = s.MRByNumber(repoID, 1) |
| 40 | if mr.State != "merged" || mr.MergedAt == "" || mr.MergedBy != "cmc" { | |
| 40 | if mr.State != "merged" || mr.MergedAt == "" || mr.MergedBy != "cmc" || mr.MergedSHA != "landed1" { | |
| 41 | 41 | t.Fatalf("merge stamp: %+v", mr) |
| 42 | 42 | } |
| 43 | 43 | |
| @@ -46,7 +46,7 @@ func TestResolutionStamps(t *testing.T) { | ||
| 46 | 46 | t.Fatal(err) |
| 47 | 47 | } |
| 48 | 48 | mr, _ = s.MRByNumber(repoID, 1) |
| 49 | if mr.MergedAt != "" || mr.MergedBy != "" { | |
| 49 | if mr.MergedAt != "" || mr.MergedBy != "" || mr.MergedSHA != "" { | |
| 50 | 50 | t.Fatalf("reopen kept the merge stamp: %+v", mr) |
| 51 | 51 | } |
| 52 | 52 | |
| @@ -105,11 +105,11 @@ func TestSupersededBy(t *testing.T) { | ||
| 105 | 105 | func TestResolutionStampImported(t *testing.T) { |
| 106 | 106 | s, repoID, _ := mrFixture(t) |
| 107 | 107 | mr, _ := s.MRByNumber(repoID, 1) |
| 108 | if err := s.MarkMerged(mr.ID, "base1", 0, "2024-03-02T10:00:00Z"); err != nil { | |
| 108 | if err := s.MarkMerged(mr.ID, "base1", "", 0, "2024-03-02T10:00:00Z"); err != nil { | |
| 109 | 109 | t.Fatal(err) |
| 110 | 110 | } |
| 111 | 111 | mr, _ = s.MRByNumber(repoID, 1) |
| 112 | if mr.MergedAt != "2024-03-02T10:00:00Z" || mr.MergedBy != "" { | |
| 112 | if mr.MergedAt != "2024-03-02T10:00:00Z" || mr.MergedBy != "" || mr.MergedSHA != "" { | |
| 113 | 113 | t.Fatalf("imported merge stamp: %+v", mr) |
| 114 | 114 | } |
| 115 | 115 | } |
internal/store/savedqueries_test.go +1 −1
| @@ -153,7 +153,7 @@ func TestQueryItemsFilters(t *testing.T) { | ||
| 153 | 153 | t.Fatal(err) |
| 154 | 154 | } |
| 155 | 155 | merged := f.mr(t, f.pub, f.alice, "merged mr") |
| 156 | if err := f.s.MarkMerged(merged.ID, "", f.alice, ""); err != nil { | |
| 156 | if err := f.s.MarkMerged(merged.ID, "", "", f.alice, ""); err != nil { | |
| 157 | 157 | t.Fatal(err) |
| 158 | 158 | } |
| 159 | 159 | f.mr(t, f.pub, f.bob, "open mr") |
internal/web/templates/releases.html +1 −1
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | </p> |
| 16 | 16 | <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)" value="{{.Draft.Or "release" "title" ""}}"></p> |
| 17 | 17 | <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed">{{.Draft.Or "release" "notes" ""}}</textarea></p> |
| 18 | <p><button type="submit" class="btn">Create release</button> {{template "previewbtn"}}</p> | |
| 18 | <p><button type="submit" class="btn">Create release</button> {{template "previewbtn"}} <button type="submit" name="summarize" value="1" class="btn">Summarize changes</button></p> | |
| 19 | 19 | </form> |
| 20 | 20 | </details> |
| 21 | 21 | {{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}} |