release edit: update a release's title and notes !57

merged merged by cmc on 2026-08-25 20:02 UTC · krz/gitbay:release-edit into main

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
153func 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
150func runReleaseList(c *Ctx, args []string) int { 216func 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.
64func (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
63func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) { 77func (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).