Commit 14a47d9502
Verified · cmc ci/build: success
cmd/gitbay/main.go +2
| @@ -317,6 +317,8 @@ func releaseCmd() *cobra.Command { | ||
| 317 | 317 | return group("release", "tag-anchored releases with notes and assets", |
| 318 | 318 | pass("create", "create a release on a pushed tag: <tag> [--title <t>] [--notes|--file -|$EDITOR]", |
| 319 | 319 | passOpts{server: []string{"release", "create"}, needsRepo: true, stdinOK: true, editor: "release"}), |
| 320 | pass("edit", "update title and notes: <tag> [--title <t>] [--notes|--file -]", | |
| 321 | passOpts{server: []string{"release", "edit"}, needsRepo: true, stdinOK: true}), | |
| 320 | 322 | pass("list", "list releases", passOpts{server: []string{"release", "list"}, needsRepo: true}), |
| 321 | 323 | pass("show", "show a release with assets: <tag>", passOpts{server: []string{"release", "show"}, needsRepo: true}), |
| 322 | 324 | pass("delete", "delete a release and its assets: <tag> --yes", passOpts{server: []string{"release", "delete"}, needsRepo: true}), |
e2e/release_test.go +26 −4
| @@ -40,6 +40,28 @@ func TestReleases(t *testing.T) { | ||
| 40 | 40 | t.Fatal("duplicate release accepted") |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | // Edit: notes replace from stdin, absent flags keep their field. | |
| 44 | if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v1.0"); code != 2 { | |
| 45 | t.Fatal("edit with nothing to change accepted") | |
| 46 | } | |
| 47 | if _, errOut, code := inst.ssh(t, aliceKey, "the **rebuilt** notes\n", "release", "edit", "alice/app", "v1.0", "--file", "-"); code != 0 { | |
| 48 | t.Fatalf("release edit: %s", errOut) | |
| 49 | } | |
| 50 | out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0") | |
| 51 | if !strings.Contains(out, "the **rebuilt** notes") || !strings.Contains(out, "First light") { | |
| 52 | t.Fatalf("edit lost a field:\n%s", out) | |
| 53 | } | |
| 54 | if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v1.0", "--title", "'Second light'"); code != 0 { | |
| 55 | t.Fatal("title edit failed") | |
| 56 | } | |
| 57 | out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0") | |
| 58 | if !strings.Contains(out, "Second light") || !strings.Contains(out, "the **rebuilt** notes") { | |
| 59 | t.Fatalf("title edit lost notes:\n%s", out) | |
| 60 | } | |
| 61 | if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v9.9", "--title", "x"); code != 3 { | |
| 62 | t.Fatal("edit of missing release accepted") | |
| 63 | } | |
| 64 | ||
| 43 | 65 | // Assets: upload from stdin, validation, dedup, round-trip. |
| 44 | 66 | payload := "BINARY\x01\x02payload for the release asset\n" |
| 45 | 67 | if _, errOut, code := inst.ssh(t, aliceKey, payload, "release", "asset", "add", "alice/app", "v1.0", "tool-linux-amd64"); code != 0 { |
| @@ -58,17 +80,17 @@ func TestReleases(t *testing.T) { | ||
| 58 | 80 | if code != 0 || got != payload { |
| 59 | 81 | t.Fatalf("asset round-trip: exit %d, %q", code, got) |
| 60 | 82 | } |
| 61 | out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json") | |
| 83 | out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json") | |
| 62 | 84 | if !strings.Contains(out, `"name":"tool-linux-amd64"`) || |
| 63 | 85 | !strings.Contains(out, fmt.Sprintf(`"size":%d`, len(payload))) || |
| 64 | !strings.Contains(out, `"sha256":"`) || !strings.Contains(out, "First light") { | |
| 86 | !strings.Contains(out, `"sha256":"`) || !strings.Contains(out, "Second light") { | |
| 65 | 87 | t.Fatalf("release show: %s", out) |
| 66 | 88 | } |
| 67 | 89 | |
| 68 | 90 | // Web: page renders notes and assets; download streams exact bytes. |
| 69 | 91 | status, body := inst.get(t, "/alice/app/releases") |
| 70 | if status != 200 || !strings.Contains(body, "First light") || | |
| 71 | !strings.Contains(body, "<strong>first</strong>") || | |
| 92 | if status != 200 || !strings.Contains(body, "Second light") || | |
| 93 | !strings.Contains(body, "<strong>rebuilt</strong>") || | |
| 72 | 94 | !strings.Contains(body, "tool-linux-amd64") { |
| 73 | 95 | t.Fatalf("releases page: %d\n%s", status, body) |
| 74 | 96 | } |
internal/control/release.go +66
| @@ -21,6 +21,9 @@ func init() { | ||
| 21 | 21 | register(Command{Path: []string{"release", "create"}, |
| 22 | 22 | Summary: "create a release on a tag: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]", |
| 23 | 23 | ReadsStdin: true, Run: runReleaseCreate}) |
| 24 | register(Command{Path: []string{"release", "edit"}, | |
| 25 | Summary: "update a release's title and notes: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -]", | |
| 26 | ReadsStdin: true, Run: runReleaseEdit}) | |
| 24 | 27 | register(Command{Path: []string{"release", "list"}, |
| 25 | 28 | Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList}) |
| 26 | 29 | register(Command{Path: []string{"release", "show"}, |
| @@ -147,6 +150,69 @@ func releaseToOut(r store.Release, withNotes bool) releaseOut { | ||
| 147 | 150 | return o |
| 148 | 151 | } |
| 149 | 152 | |
| 153 | func runReleaseEdit(c *Ctx, args []string) int { | |
| 154 | const usage = "usage: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -]" | |
| 155 | var path, tag, title, notes, file string | |
| 156 | var setTitle, setNotes bool | |
| 157 | for i := 0; i < len(args); i++ { | |
| 158 | switch args[i] { | |
| 159 | case "--title", "--notes", "--file": | |
| 160 | if i+1 >= len(args) { | |
| 161 | return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) | |
| 162 | } | |
| 163 | switch args[i] { | |
| 164 | case "--title": | |
| 165 | title, setTitle = args[i+1], true | |
| 166 | case "--notes": | |
| 167 | notes, setNotes = args[i+1], true | |
| 168 | case "--file": | |
| 169 | file, setNotes = args[i+1], true | |
| 170 | } | |
| 171 | i++ | |
| 172 | default: | |
| 173 | if path == "" { | |
| 174 | path = args[i] | |
| 175 | } else if tag == "" { | |
| 176 | tag = args[i] | |
| 177 | } else { | |
| 178 | return c.fail(protocol.ExitUsage, usage) | |
| 179 | } | |
| 180 | } | |
| 181 | } | |
| 182 | if path == "" || tag == "" || (!setTitle && !setNotes) { | |
| 183 | return c.fail(protocol.ExitUsage, usage) | |
| 184 | } | |
| 185 | repo, code := resolveRepo(c, path, policy.CanWrite) | |
| 186 | if code >= 0 { | |
| 187 | return code | |
| 188 | } | |
| 189 | if code := refuseArchived(c, repo); code >= 0 { | |
| 190 | return code | |
| 191 | } | |
| 192 | rel, err := c.Store.ReleaseByTag(repo.ID, tag) | |
| 193 | if err != nil { | |
| 194 | return c.fail(protocol.ExitNotFound, "no release %q in %s", tag, repo.Path()) | |
| 195 | } | |
| 196 | // Absent flags keep what the release already says. | |
| 197 | if !setTitle { | |
| 198 | title = rel.Title | |
| 199 | } else if title == "" { | |
| 200 | title = tag | |
| 201 | } | |
| 202 | body := rel.Notes | |
| 203 | if setNotes { | |
| 204 | if body, err = bodyFrom(c, notes, file); err != nil { | |
| 205 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 206 | } | |
| 207 | } | |
| 208 | if err := c.Store.UpdateRelease(repo.ID, tag, title, body); err != nil { | |
| 209 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 210 | } | |
| 211 | return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) { | |
| 212 | fmt.Fprintf(w, "updated release %s\n", tag) | |
| 213 | }) | |
| 214 | } | |
| 215 | ||
| 150 | 216 | func runReleaseList(c *Ctx, args []string) int { |
| 151 | 217 | if len(args) != 1 { |
| 152 | 218 | return c.fail(protocol.ExitUsage, "usage: release list <owner/name>") |
internal/store/releases.go +14
| @@ -60,6 +60,20 @@ func (s *Store) releaseAssets(rel *Release) error { | ||
| 60 | 60 | return rows.Err() |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | // UpdateRelease replaces a release's title and notes. | |
| 64 | func (s *Store) UpdateRelease(repoID int64, tag, title, notes string) error { | |
| 65 | res, err := s.DB.Exec( | |
| 66 | "UPDATE releases SET title = ?, notes = ? WHERE repo_id = ? AND tag = ?", | |
| 67 | title, notes, repoID, tag) | |
| 68 | if err != nil { | |
| 69 | return err | |
| 70 | } | |
| 71 | if n, _ := res.RowsAffected(); n == 0 { | |
| 72 | return ErrNotFound | |
| 73 | } | |
| 74 | return nil | |
| 75 | } | |
| 76 | ||
| 63 | 77 | func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) { |
| 64 | 78 | var r Release |
| 65 | 79 | err := s.DB.QueryRow(releaseSelect+" WHERE r.repo_id = ? AND r.tag = ?", repoID, tag). |