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 | | 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] | |||
| 477 | gitbay release asset add v1.0 tool-linux-amd64 < dist/tool-linux-amd64 | 477 | gitbay release asset add v1.0 tool-linux-amd64 < dist/tool-linux-amd64 |
| 478 | gitbay release asset get v1.0 tool-linux-amd64 > tool # or the web download link | 478 | gitbay release asset get v1.0 tool-linux-amd64 > tool # or the web download link |
| 479 | gitbay release list / show v1.0 / delete v1.0 --yes | 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 | #+end_src | 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 | The web shows them under the repository's =releases= tab with rendered | 492 | The web shows them under the repository's =releases= tab with rendered |
| 483 | notes, sha256 sums, and download links; writers add and remove assets | 493 | notes, sha256 sums, and download links; writers add and remove assets |
| 484 | there (a file upload, up to =max_asset_bytes=; removal asks for the file | 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 | 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 | |||
| 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 | import ( | 3 | import ( |
| 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). | ||
| 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 | "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 @@ | |||
| 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 | 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 = ` | |||
| 120 | func scanMR(row interface{ Scan(...any) error }) (MR, error) { | 121 | func 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 |
| 288 | func (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. | ||
| 291 | func (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) { | |||
| 343 | func (s *Store) SetMRState(mrID int64, state string) error { | 346 | func (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) { | |||
| 105 | func TestResolutionStampImported(t *testing.T) { | 105 | func 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}} |