release edit: update a release's title and notes !57
4 files changed, +108 −4
Layout: unified · split
cmd/gitbay/main.go +2
| @@ -317,6 +317,8 @@ func releaseCmd() *cobra.Command { | |||
| 317 | return group("release", "tag-anchored releases with notes and assets", | 317 | return group("release", "tag-anchored releases with notes and assets", |
| 318 | pass("create", "create a release on a pushed tag: <tag> [--title <t>] [--notes|--file -|$EDITOR]", | 318 | pass("create", "create a release on a pushed tag: <tag> [--title <t>] [--notes|--file -|$EDITOR]", |
| 319 | passOpts{server: []string{"release", "create"}, needsRepo: true, stdinOK: true, editor: "release"}), | 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 | pass("list", "list releases", passOpts{server: []string{"release", "list"}, needsRepo: true}), | 322 | pass("list", "list releases", passOpts{server: []string{"release", "list"}, needsRepo: true}), |
| 321 | pass("show", "show a release with assets: <tag>", passOpts{server: []string{"release", "show"}, needsRepo: true}), | 323 | pass("show", "show a release with assets: <tag>", passOpts{server: []string{"release", "show"}, needsRepo: true}), |
| 322 | pass("delete", "delete a release and its assets: <tag> --yes", passOpts{server: []string{"release", "delete"}, needsRepo: true}), | 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 | t.Fatal("duplicate release accepted") | 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 | // Assets: upload from stdin, validation, dedup, round-trip. | 65 | // Assets: upload from stdin, validation, dedup, round-trip. |
| 44 | payload := "BINARY\x01\x02payload for the release asset\n" | 66 | payload := "BINARY\x01\x02payload for the release asset\n" |
| 45 | if _, errOut, code := inst.ssh(t, aliceKey, payload, "release", "asset", "add", "alice/app", "v1.0", "tool-linux-amd64"); code != 0 { | 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 | if code != 0 || got != payload { | 80 | if code != 0 || got != payload { |
| 59 | t.Fatalf("asset round-trip: exit %d, %q", code, got) | 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 | if !strings.Contains(out, `"name":"tool-linux-amd64"`) || | 84 | if !strings.Contains(out, `"name":"tool-linux-amd64"`) || |
| 63 | !strings.Contains(out, fmt.Sprintf(`"size":%d`, len(payload))) || | 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 | t.Fatalf("release show: %s", out) | 87 | t.Fatalf("release show: %s", out) |
| 66 | } | 88 | } |
| 67 | 89 | ||
| 68 | // Web: page renders notes and assets; download streams exact bytes. | 90 | // Web: page renders notes and assets; download streams exact bytes. |
| 69 | status, body := inst.get(t, "/alice/app/releases") | 91 | status, body := inst.get(t, "/alice/app/releases") |
| 70 | if status != 200 || !strings.Contains(body, "First light") || | 92 | if status != 200 || !strings.Contains(body, "Second light") || |
| 71 | !strings.Contains(body, "<strong>first</strong>") || | 93 | !strings.Contains(body, "<strong>rebuilt</strong>") || |
| 72 | !strings.Contains(body, "tool-linux-amd64") { | 94 | !strings.Contains(body, "tool-linux-amd64") { |
| 73 | t.Fatalf("releases page: %d\n%s", status, body) | 95 | t.Fatalf("releases page: %d\n%s", status, body) |
| 74 | } | 96 | } |
internal/control/release.go +66
| @@ -21,6 +21,9 @@ func init() { | |||
| 21 | register(Command{Path: []string{"release", "create"}, | 21 | register(Command{Path: []string{"release", "create"}, |
| 22 | Summary: "create a release on a tag: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]", | 22 | Summary: "create a release on a tag: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]", |
| 23 | ReadsStdin: true, Run: runReleaseCreate}) | 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 | register(Command{Path: []string{"release", "list"}, | 27 | register(Command{Path: []string{"release", "list"}, |
| 25 | Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList}) | 28 | Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList}) |
| 26 | register(Command{Path: []string{"release", "show"}, | 29 | register(Command{Path: []string{"release", "show"}, |
| @@ -147,6 +150,69 @@ func releaseToOut(r store.Release, withNotes bool) releaseOut { | |||
| 147 | return o | 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 | func runReleaseList(c *Ctx, args []string) int { | 216 | func runReleaseList(c *Ctx, args []string) int { |
| 151 | if len(args) != 1 { | 217 | if len(args) != 1 { |
| 152 | return c.fail(protocol.ExitUsage, "usage: release list <owner/name>") | 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 | return rows.Err() | 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 | func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) { | 77 | func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) { |
| 64 | var r Release | 78 | var r Release |
| 65 | err := s.DB.QueryRow(releaseSelect+" WHERE r.repo_id = ? AND r.tag = ?", repoID, tag). | 79 | err := s.DB.QueryRow(releaseSelect+" WHERE r.repo_id = ? AND r.tag = ?", repoID, tag). |