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.
222222| atom feeds | n/a | yes | n/a |
223223| release create, edit | yes | yes | yes |
224224| preview release notes | n/a | yes | no |
225| summarize changes since the previous release | yes | yes | no |
225226| build list | yes | yes | yes |
226227| build list paging (limit, cursor) | yes | yes | no |
227228| 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]
477477gitbay release asset add v1.0 tool-linux-amd64 < dist/tool-linux-amd64
478478gitbay release asset get v1.0 tool-linux-amd64 > tool # or the web download link
479479gitbay 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
480481#+end_src
481482
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
482492The web shows them under the repository's =releases= tab with rendered
483493notes, sha256 sums, and download links; writers add and remove assets
484494there (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 {
676676 pass("edit", passOpts{server: []string{"release", "edit"}, needsRepo: true, stdinOK: true}),
677677 pass("list", passOpts{server: []string{"release", "list"}, needsRepo: true}),
678678 pass("show", passOpts{server: []string{"release", "show"}, needsRepo: true}),
679 pass("notes", passOpts{server: []string{"release", "notes"}, needsRepo: true}),
679680 pass("delete", passOpts{server: []string{"release", "delete"}, needsRepo: true}),
680681 group("asset", "binary assets on a release",
681682 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{
147147 "release delete": "delete a release and its assets",
148148 "release edit": "update a release's title and notes",
149149 "release list": "list releases",
150 "release notes": "draft notes: one line per merge request since the previous release",
150151 "release show": "show a release with assets",
151152 "repo access grant": "grant access",
152153 "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) {
157157 "build log": {"alice/app", "1"},
158158 "release list": {"alice/app"},
159159 "release show": {"alice/app", "v1"},
160 "release notes": {"alice/app", "main"},
160161 "release asset get": {"alice/app", "v1", "a.txt"},
161162 "snippet show": {snippetID},
162163 "snippet list": {},
e2e/releaseweb_test.go +28
@@ -63,6 +63,34 @@ func TestReleaseAndBuildWeb(t *testing.T) {
6363 if !strings.Contains(out, "Second light") || !strings.Contains(out, "revised") {
6464 t.Fatalf("release not edited:\n%s", out)
6565 }
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
6694 // A released tag is no longer offered for creation.
6795 if _, page = browserGet(t, alice, base+"/releases"); strings.Contains(page, `<option value="v1.0"`) {
6896 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 {
266266 return c.fail(protocol.ExitFailure, "%v", err)
267267 }
268268 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)
270270 } else {
271271 c.Store.MarkClosed(mr.ID, 0, it.ClosedAt)
272272 }
internal/control/migrate.go +1 −1
@@ -292,7 +292,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
292292 // MarkMerged, not SetMRState: a merged MR's diff is
293293 // measured from the base recorded at merge time, and
294294 // 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)
296296 case bm.State != "open":
297297 state := bm.State
298298 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 {
14201420 // a merge whose ref update landed and whose record did not. Record
14211421 // it rather than refuse, so a merge request cannot be stuck open
14221422 // 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 {
14241424 return c.fail(protocol.ExitFailure, "%v", err)
14251425 }
14261426 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 {
16361636 if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
16371637 return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
16381638 }
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 {
16401640 return c.fail(protocol.ExitFailure, "%v", err)
16411641 }
16421642 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) {
4848 if err != nil {
4949 t.Fatal(err)
5050 }
51 if err := st.MarkMerged(mr.ID, headSHA, uid, ""); err != nil {
51 if err := st.MarkMerged(mr.ID, headSHA, headSHA, uid, ""); err != nil {
5252 t.Fatal(err)
5353 }
5454 return st, repo, root, headSHA
internal/control/release.go +106
@@ -58,6 +58,17 @@ func init() {
5858 Usage: "release show <owner/name> <tag>",
5959 Examples: []string{"release show krz/gitbay v1.30.0"},
6060 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})
6172 register(Command{Path: []string{"release", "delete"},
6273 Summary: "delete a release and its assets",
6374 Usage: "release delete <owner/name> <tag> --yes",
@@ -485,3 +496,98 @@ func runAssetRemove(c *Ctx, args []string) int {
485496 fmt.Fprintf(w, "removed %s\n", name)
486497 })
487498}
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
22
33import (
44 "bytes"
5 "encoding/json"
6 "strconv"
57 "strings"
68 "testing"
79
@@ -84,3 +86,120 @@ func TestReleaseListPageSurvivesTheCursorReleaseBeingDeleted(t *testing.T) {
8486 t.Fatalf("page 2 after deleting the cursor release: %q", got)
8587 }
8688}
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 (
55 "net/http"
66 "strings"
77
8 "gitbay.org/gitbay/internal/protocol"
89 "gitbay.org/gitbay/internal/store"
910)
1011
@@ -39,6 +40,23 @@ func (s *Server) releaseSubmit(w http.ResponseWriter, r *http.Request, u store.U
3940 return
4041 }
4142 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 }
4260 // The CLI's --yes guards against a mistyped tag; here the tag comes
4361 // from the page, so the browser's own confirm field stands in.
4462 if r.FormValue("action") == "delete" {
internal/store/mergequeue_test.go +1 −1
@@ -62,7 +62,7 @@ func TestMergeQueueLeftOnMergeOrClose(t *testing.T) {
6262 s, repoID, uid := mrFixture(t)
6363 mr, _ := s.MRByNumber(repoID, 1)
6464 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, "") },
6666 func() error { return s.MarkClosed(mr.ID, uid, "") },
6767 } {
6868 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 {
2525 Milestone string
2626 HeadSHA string
2727 MergedBase string // target tip at merge time; base for historical diffs
28 MergedSHA string // the commit it landed as; "" when merged before 0079
2829 MergedAt string // "" unless merged
2930 MergedBy string // "" when unknown (imports) or the account is gone
3031 ClosedAt string // "" unless closed without merging
@@ -102,7 +103,7 @@ const mrSelect = `
102103 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
103104 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
104105 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, ''),
106107 m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at,
107108 COALESCE(q.user_id, 0), COALESCE(qu.username, ''), COALESCE(q.strategy, ''),
108109 COALESCE(q.reason, ''), COALESCE(q.queued_at, '')
@@ -120,7 +121,7 @@ const mrSelect = `
120121func scanMR(row interface{ Scan(...any) error }) (MR, error) {
121122 var m MR
122123 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,
124125 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt,
125126 &m.QueuedByID, &m.QueuedBy, &m.QueueStrategy, &m.QueueReason, &m.QueuedAt)
126127 return m, err
@@ -282,16 +283,18 @@ func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, err
282283}
283284
284285// 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.
288func (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.
291func (s *Store) MarkMerged(mrID int64, baseSHA, mergedSHA string, actorID int64, at string) error {
289292 _, err := s.DB.Exec(
290 `UPDATE merge_requests SET state = 'merged', merged_base = ?,
293 `UPDATE merge_requests SET state = 'merged', merged_base = ?, merged_sha = ?,
291294 merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
292295 merged_by = NULLIF(?, 0),
293296 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
294 baseSHA, at, actorID, mrID)
297 baseSHA, mergedSHA, at, actorID, mrID)
295298 return err
296299}
297300
@@ -343,7 +346,7 @@ func (s *Store) MRsSuperseding(repoID, number int64) ([]MR, error) {
343346func (s *Store) SetMRState(mrID int64, state string) error {
344347 stamp := ""
345348 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"
347350 }
348351 res, err := s.DB.Exec(
349352 "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) {
3333 if mr.MergedAt != "" || mr.ClosedAt != "" {
3434 t.Fatalf("open MR carries a stamp: %+v", mr)
3535 }
36 if err := s.MarkMerged(mr.ID, "base1", uid, ""); err != nil {
36 if err := s.MarkMerged(mr.ID, "base1", "landed1", uid, ""); err != nil {
3737 t.Fatal(err)
3838 }
3939 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" {
4141 t.Fatalf("merge stamp: %+v", mr)
4242 }
4343
@@ -46,7 +46,7 @@ func TestResolutionStamps(t *testing.T) {
4646 t.Fatal(err)
4747 }
4848 mr, _ = s.MRByNumber(repoID, 1)
49 if mr.MergedAt != "" || mr.MergedBy != "" {
49 if mr.MergedAt != "" || mr.MergedBy != "" || mr.MergedSHA != "" {
5050 t.Fatalf("reopen kept the merge stamp: %+v", mr)
5151 }
5252
@@ -105,11 +105,11 @@ func TestSupersededBy(t *testing.T) {
105105func TestResolutionStampImported(t *testing.T) {
106106 s, repoID, _ := mrFixture(t)
107107 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 {
109109 t.Fatal(err)
110110 }
111111 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 != "" {
113113 t.Fatalf("imported merge stamp: %+v", mr)
114114 }
115115}
internal/store/savedqueries_test.go +1 −1
@@ -153,7 +153,7 @@ func TestQueryItemsFilters(t *testing.T) {
153153 t.Fatal(err)
154154 }
155155 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 {
157157 t.Fatal(err)
158158 }
159159 f.mr(t, f.pub, f.bob, "open mr")
internal/web/templates/releases.html +1 −1
@@ -15,7 +15,7 @@
1515 </p>
1616 <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)" value="{{.Draft.Or "release" "title" ""}}"></p>
1717 <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>
1919</form>
2020</details>
2121{{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}}