Commit e25c7eeb20

e25c7eeb20b824db1bfece28de6ebf2646cc7617

parent: 3db6190825

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-14 03:39 UTC

release notes: one line per merge request since the previous release

release notes <owner/name> <ref> [--since <tag>] lists the merge requests
landed between the previous release's tag and the ref, oldest first, as
"- <title> (!<n>) by @<author>". The releases page gets a Summarize
changes button on the create form that appends the same lines to the
notes box without writing anything. Migration 0079 adds merged_sha, the
commit a merge landed as, which the match uses; rows merged before it
are matched on head_sha.

Closes #331

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -222,6 +222,7 @@ rather than the one the web page shows.
222| atom feeds | n/a | yes | n/a | 222| atom feeds | n/a | yes | n/a |
223| release create, edit | yes | yes | yes | 223| release create, edit | yes | yes | yes |
224| preview release notes | n/a | yes | no | 224| preview release notes | n/a | yes | no |
225| summarize changes since the previous release | yes | yes | no |
225| build list | yes | yes | yes | 226| build list | yes | yes | yes |
226| build list paging (limit, cursor) | yes | yes | no | 227| build list paging (limit, cursor) | yes | yes | no |
227| build row names its commit | yes | yes | no | 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]
477gitbay release asset add v1.0 tool-linux-amd64 < dist/tool-linux-amd64 477gitbay release asset add v1.0 tool-linux-amd64 < dist/tool-linux-amd64
478gitbay release asset get v1.0 tool-linux-amd64 > tool # or the web download link 478gitbay release asset get v1.0 tool-linux-amd64 > tool # or the web download link
479gitbay release list / show v1.0 / delete v1.0 --yes 479gitbay release list / show v1.0 / delete v1.0 --yes
480gitbay release notes v1.0 [--since <tag>] # one line per merge request since the previous release
480#+end_src 481#+end_src
481 482
483=release notes= drafts the notes: every merge request landed between the
484previous release's tag (or =--since=) and the ref, oldest first, as
485=- <title> (!<n>) by @<author>=. The ref may be a branch, for drafting
486before the tag exists. Pipe it into =release create --file -=, or press
487Summarize changes on the web form, which appends the same lines to the
488notes box. A merge request is attributed by the commit it landed as;
489one merged by squash or rebase before the instance recorded it is not
490listed.
491
482The web shows them under the repository's =releases= tab with rendered 492The web shows them under the repository's =releases= tab with rendered
483notes, sha256 sums, and download links; writers add and remove assets 493notes, sha256 sums, and download links; writers add and remove assets
484there (a file upload, up to =max_asset_bytes=; removal asks for the file 494there (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 pass("edit", passOpts{server: []string{"release", "edit"}, needsRepo: true, stdinOK: true}), 676 pass("edit", passOpts{server: []string{"release", "edit"}, needsRepo: true, stdinOK: true}),
677 pass("list", passOpts{server: []string{"release", "list"}, needsRepo: true}), 677 pass("list", passOpts{server: []string{"release", "list"}, needsRepo: true}),
678 pass("show", passOpts{server: []string{"release", "show"}, needsRepo: true}), 678 pass("show", passOpts{server: []string{"release", "show"}, needsRepo: true}),
679 pass("notes", passOpts{server: []string{"release", "notes"}, needsRepo: true}),
679 pass("delete", passOpts{server: []string{"release", "delete"}, needsRepo: true}), 680 pass("delete", passOpts{server: []string{"release", "delete"}, needsRepo: true}),
680 group("asset", "binary assets on a release", 681 group("asset", "binary assets on a release",
681 pass("add", passOpts{server: []string{"release", "asset", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "the asset's bytes"}), 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 "release delete": "delete a release and its assets", 147 "release delete": "delete a release and its assets",
148 "release edit": "update a release's title and notes", 148 "release edit": "update a release's title and notes",
149 "release list": "list releases", 149 "release list": "list releases",
150 "release notes": "draft notes: one line per merge request since the previous release",
150 "release show": "show a release with assets", 151 "release show": "show a release with assets",
151 "repo access grant": "grant access", 152 "repo access grant": "grant access",
152 "repo access list": "list who can reach the repository, with the role and where it comes from", 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 "build log": {"alice/app", "1"}, 157 "build log": {"alice/app", "1"},
158 "release list": {"alice/app"}, 158 "release list": {"alice/app"},
159 "release show": {"alice/app", "v1"}, 159 "release show": {"alice/app", "v1"},
160 "release notes": {"alice/app", "main"},
160 "release asset get": {"alice/app", "v1", "a.txt"}, 161 "release asset get": {"alice/app", "v1", "a.txt"},
161 "snippet show": {snippetID}, 162 "snippet show": {snippetID},
162 "snippet list": {}, 163 "snippet list": {},
e2e/releaseweb_test.go +28
@@ -63,6 +63,34 @@ func TestReleaseAndBuildWeb(t *testing.T) {
63 if !strings.Contains(out, "Second light") || !strings.Contains(out, "revised") { 63 if !strings.Contains(out, "Second light") || !strings.Contains(out, "revised") {
64 t.Fatalf("release not edited:\n%s", out) 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 // A released tag is no longer offered for creation. 94 // A released tag is no longer offered for creation.
67 if _, page = browserGet(t, alice, base+"/releases"); strings.Contains(page, `<option value="v1.0"`) { 95 if _, page = browserGet(t, alice, base+"/releases"); strings.Contains(page, `<option value="v1.0"`) {
68 t.Fatalf("released tag still offered:\n%s", page) 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 return c.fail(protocol.ExitFailure, "%v", err) 266 return c.fail(protocol.ExitFailure, "%v", err)
267 } 267 }
268 if pr.MergedAt != "" { 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 } else { 270 } else {
271 c.Store.MarkClosed(mr.ID, 0, it.ClosedAt) 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 // MarkMerged, not SetMRState: a merged MR's diff is 292 // MarkMerged, not SetMRState: a merged MR's diff is
293 // measured from the base recorded at merge time, and 293 // measured from the base recorded at merge time, and
294 // SetMRState leaves that empty. 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 case bm.State != "open": 296 case bm.State != "open":
297 state := bm.State 297 state := bm.State
298 if state == "source_gone" { 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 // a merge whose ref update landed and whose record did not. Record 1420 // a merge whose ref update landed and whose record did not. Record
1421 // it rather than refuse, so a merge request cannot be stuck open 1421 // it rather than refuse, so a merge request cannot be stuck open
1422 // with no way to close it as merged (#108). 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 return c.fail(protocol.ExitFailure, "%v", err) 1424 return c.fail(protocol.ExitFailure, "%v", err)
1425 } 1425 }
1426 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d}`, mr.Number)) 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 if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil { 1636 if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
1637 return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err) 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 return c.fail(protocol.ExitFailure, "%v", err) 1640 return c.fail(protocol.ExitFailure, "%v", err)
1641 } 1641 }
1642 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA)) 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 if err != nil { 48 if err != nil {
49 t.Fatal(err) 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 t.Fatal(err) 52 t.Fatal(err)
53 } 53 }
54 return st, repo, root, headSHA 54 return st, repo, root, headSHA
internal/control/release.go +106
@@ -58,6 +58,17 @@ func init() {
58 Usage: "release show <owner/name> <tag>", 58 Usage: "release show <owner/name> <tag>",
59 Examples: []string{"release show krz/gitbay v1.30.0"}, 59 Examples: []string{"release show krz/gitbay v1.30.0"},
60 ReadOnly: true, Run: runReleaseShow}) 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 register(Command{Path: []string{"release", "delete"}, 72 register(Command{Path: []string{"release", "delete"},
62 Summary: "delete a release and its assets", 73 Summary: "delete a release and its assets",
63 Usage: "release delete <owner/name> <tag> --yes", 74 Usage: "release delete <owner/name> <tag> --yes",
@@ -485,3 +496,98 @@ func runAssetRemove(c *Ctx, args []string) int {
485 fmt.Fprintf(w, "removed %s\n", name) 496 fmt.Fprintf(w, "removed %s\n", name)
486 }) 497 })
487} 498}
499
500type 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
507type 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.
519func 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
3import ( 3import (
4 "bytes" 4 "bytes"
5 "encoding/json"
6 "strconv"
5 "strings" 7 "strings"
6 "testing" 8 "testing"
7 9
@@ -84,3 +86,120 @@ func TestReleaseListPageSurvivesTheCursorReleaseBeingDeleted(t *testing.T) {
84 t.Fatalf("page 2 after deleting the cursor release: %q", got) 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).
92func 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.
105func (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).
130func 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 "net/http" 5 "net/http"
6 "strings" 6 "strings"
7 7
8 "gitbay.org/gitbay/internal/protocol"
8 "gitbay.org/gitbay/internal/store" 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 return 40 return
40 } 41 }
41 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) } 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 // The CLI's --yes guards against a mistyped tag; here the tag comes 60 // The CLI's --yes guards against a mistyped tag; here the tag comes
43 // from the page, so the browser's own confirm field stands in. 61 // from the page, so the browser's own confirm field stands in.
44 if r.FormValue("action") == "delete" { 62 if r.FormValue("action") == "delete" {
internal/store/mergequeue_test.go +1 −1
@@ -62,7 +62,7 @@ func TestMergeQueueLeftOnMergeOrClose(t *testing.T) {
62 s, repoID, uid := mrFixture(t) 62 s, repoID, uid := mrFixture(t)
63 mr, _ := s.MRByNumber(repoID, 1) 63 mr, _ := s.MRByNumber(repoID, 1)
64 for _, mark := range []func() error{ 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 func() error { return s.MarkClosed(mr.ID, uid, "") }, 66 func() error { return s.MarkClosed(mr.ID, uid, "") },
67 } { 67 } {
68 if err := s.SetMRState(mr.ID, "open"); err != nil { 68 if err := s.SetMRState(mr.ID, "open"); err != nil {
internal/store/migrations/0079_mr_merged_sha.down.sql added +1
@@ -0,0 +1 @@
1ALTER 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.
5ALTER 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 Milestone string 25 Milestone string
26 HeadSHA string 26 HeadSHA string
27 MergedBase string // target tip at merge time; base for historical diffs 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 MergedAt string // "" unless merged 29 MergedAt string // "" unless merged
29 MergedBy string // "" when unknown (imports) or the account is gone 30 MergedBy string // "" when unknown (imports) or the account is gone
30 ClosedAt string // "" unless closed without merging 31 ClosedAt string // "" unless closed without merging
@@ -102,7 +103,7 @@ const mrSelect = `
102 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''), 103 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
103 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft, 104 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
104 COALESCE(ms.title, ''), m.head_sha, 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 m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at, 107 m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at,
107 COALESCE(q.user_id, 0), COALESCE(qu.username, ''), COALESCE(q.strategy, ''), 108 COALESCE(q.user_id, 0), COALESCE(qu.username, ''), COALESCE(q.strategy, ''),
108 COALESCE(q.reason, ''), COALESCE(q.queued_at, '') 109 COALESCE(q.reason, ''), COALESCE(q.queued_at, '')
@@ -120,7 +121,7 @@ const mrSelect = `
120func scanMR(row interface{ Scan(...any) error }) (MR, error) { 121func scanMR(row interface{ Scan(...any) error }) (MR, error) {
121 var m MR 122 var m MR
122 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, 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 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt, 125 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt,
125 &m.QueuedByID, &m.QueuedBy, &m.QueueStrategy, &m.QueueReason, &m.QueuedAt) 126 &m.QueuedByID, &m.QueuedBy, &m.QueueStrategy, &m.QueueReason, &m.QueuedAt)
126 return m, err 127 return m, err
@@ -282,16 +283,18 @@ func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, err
282} 283}
283 284
284// MarkMerged records the merge along with the target tip it landed on, so 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// the MR's diff stays reconstructable after fast-forwards, and the commit
286// empty at leave the merger unknown and stamp the current time, which is 287// it landed as, which release notes match against a range. actorID 0 and
287// what an import that carries neither can say. 288// an empty at leave the merger unknown and stamp the current time, and an
288func (s *Store) MarkMerged(mrID int64, baseSHA string, actorID int64, at string) error { 289// empty mergedSHA leaves the landed commit unknown, which is what an
290// import that carries none of them can say.
291func (s *Store) MarkMerged(mrID int64, baseSHA, mergedSHA string, actorID int64, at string) error {
289 _, err := s.DB.Exec( 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 merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), 294 merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
292 merged_by = NULLIF(?, 0), 295 merged_by = NULLIF(?, 0),
293 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, 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 return err 298 return err
296} 299}
297 300
@@ -343,7 +346,7 @@ func (s *Store) MRsSuperseding(repoID, number int64) ([]MR, error) {
343func (s *Store) SetMRState(mrID int64, state string) error { 346func (s *Store) SetMRState(mrID int64, state string) error {
344 stamp := "" 347 stamp := ""
345 if state == "open" || state == "source_gone" { 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 res, err := s.DB.Exec( 351 res, err := s.DB.Exec(
349 "UPDATE merge_requests SET state = ?"+stamp+", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", 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 if mr.MergedAt != "" || mr.ClosedAt != "" { 33 if mr.MergedAt != "" || mr.ClosedAt != "" {
34 t.Fatalf("open MR carries a stamp: %+v", mr) 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 t.Fatal(err) 37 t.Fatal(err)
38 } 38 }
39 mr, _ = s.MRByNumber(repoID, 1) 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 t.Fatalf("merge stamp: %+v", mr) 41 t.Fatalf("merge stamp: %+v", mr)
42 } 42 }
43 43
@@ -46,7 +46,7 @@ func TestResolutionStamps(t *testing.T) {
46 t.Fatal(err) 46 t.Fatal(err)
47 } 47 }
48 mr, _ = s.MRByNumber(repoID, 1) 48 mr, _ = s.MRByNumber(repoID, 1)
49 if mr.MergedAt != "" || mr.MergedBy != "" { 49 if mr.MergedAt != "" || mr.MergedBy != "" || mr.MergedSHA != "" {
50 t.Fatalf("reopen kept the merge stamp: %+v", mr) 50 t.Fatalf("reopen kept the merge stamp: %+v", mr)
51 } 51 }
52 52
@@ -105,11 +105,11 @@ func TestSupersededBy(t *testing.T) {
105func TestResolutionStampImported(t *testing.T) { 105func TestResolutionStampImported(t *testing.T) {
106 s, repoID, _ := mrFixture(t) 106 s, repoID, _ := mrFixture(t)
107 mr, _ := s.MRByNumber(repoID, 1) 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 t.Fatal(err) 109 t.Fatal(err)
110 } 110 }
111 mr, _ = s.MRByNumber(repoID, 1) 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 t.Fatalf("imported merge stamp: %+v", mr) 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 t.Fatal(err) 153 t.Fatal(err)
154 } 154 }
155 merged := f.mr(t, f.pub, f.alice, "merged mr") 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 t.Fatal(err) 157 t.Fatal(err)
158 } 158 }
159 f.mr(t, f.pub, f.bob, "open mr") 159 f.mr(t, f.pub, f.bob, "open mr")
internal/web/templates/releases.html +1 −1
@@ -15,7 +15,7 @@
15 </p> 15 </p>
16 <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)" value="{{.Draft.Or "release" "title" ""}}"></p> 16 <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)" value="{{.Draft.Or "release" "title" ""}}"></p>
17 <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed">{{.Draft.Or "release" "notes" ""}}</textarea></p> 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</form> 19</form>
20</details> 20</details>
21{{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}} 21{{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}}