rickub/clipublic Fork
⑂ 72b5c57
Commits
⬇ Clone ▾
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

pr: open ready merge requests by default, add --draft and pr readyUnverified

The API opens a draft unless told otherwise, and a draft cannot be
merged. pr create now sends draft:false (or true with --draft), pr ready
<n> [--undo] flips the state, and list/view render an open draft as
"draft".

Fixes #2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgkcT3UPxwxukBKojB5eg4
Olivier Girardot committed 2026-09-03T07:26:56+02:00 Browse files
72b5c57 parent: d68b124
modified cmd/pr.go +59 -3
@@ -19,6 +19,8 @@ var (
1919 prHeadOwner string
2020 prHeadRepo string
2121 prMergeMethod string
22+ prDraft bool
23+ prReadyUndo bool
2224 )
2325
2426 func init() {
@@ -58,6 +60,15 @@ func init() {
5860 createCmd.Flags().StringVarP(&prBody, "body", "b", "", "description body")
5961 createCmd.Flags().StringVar(&prHeadOwner, "head-owner", "", "fork owner for a cross-repo MR")
6062 createCmd.Flags().StringVar(&prHeadRepo, "head-repo", "", "fork name for a cross-repo MR")
63+ createCmd.Flags().BoolVar(&prDraft, "draft", false, "open as a draft (not mergeable until `pr ready`)")
64+
65+ readyCmd := &cobra.Command{
66+ Use: "ready <number>",
67+ Short: "Mark a draft merge request ready for review (--undo puts it back in draft)",
68+ Args: cobra.ExactArgs(1),
69+ RunE: runPRReady,
70+ }
71+ readyCmd.Flags().BoolVar(&prReadyUndo, "undo", false, "convert back to a draft")
6172
6273 mergeCmd := &cobra.Command{
6374 Use: "merge <number>",
@@ -89,7 +100,7 @@ func init() {
89100 }
90101 commentCmd.Flags().StringVarP(&prBody, "body", "b", "", "comment body (required)")
91102
92- prCmd.AddCommand(listCmd, viewCmd, createCmd, mergeCmd, closeCmd, reopenCmd, commentCmd)
103+ prCmd.AddCommand(listCmd, viewCmd, createCmd, readyCmd, mergeCmd, closeCmd, reopenCmd, commentCmd)
93104 rootCmd.AddCommand(prCmd)
94105 }
95106
@@ -124,7 +135,7 @@ func runPRList(cmd *cobra.Command, _ []string) error {
124135 tw := newTabw(cmd.OutOrStdout())
125136 fmt.Fprintln(tw, "#\tSTATE\tTITLE\tHEAD→BASE\tAUTHOR")
126137 for _, mr := range page.Items {
127- fmt.Fprintf(tw, "%d\t%s\t%s\t%s→%s\t%s\n", mr.Number, mr.State, mr.Title, mr.HeadBranch, mr.BaseBranch, mr.Author)
138+ fmt.Fprintf(tw, "%d\t%s\t%s\t%s→%s\t%s\n", mr.Number, prStateLabel(mr), mr.Title, mr.HeadBranch, mr.BaseBranch, mr.Author)
128139 }
129140 tw.Flush()
130141 printPageFooter(cmd, page.Page)
@@ -152,7 +163,7 @@ func runPRView(cmd *cobra.Command, args []string) error {
152163 return printJSON(cmd.OutOrStdout(), mr)
153164 }
154165 out := cmd.OutOrStdout()
155- fmt.Fprintf(out, "#%d %s [%s]\n", mr.Number, mr.Title, mr.State)
166+ fmt.Fprintf(out, "#%d %s [%s]\n", mr.Number, mr.Title, prStateLabel(mr.MergeRequest))
156167 fmt.Fprintf(out, "%s wants to merge %s → %s\n", mr.Author, mr.HeadBranch, mr.BaseBranch)
157168 if mr.CrossRepo {
158169 fmt.Fprintf(out, "cross-repo from %s/%s\n", mr.HeadOwner, mr.HeadRepo)
@@ -181,6 +192,15 @@ func runPRView(cmd *cobra.Command, args []string) error {
181192 return nil
182193 }
183194
195+// prStateLabel renders the state column: an open draft says so, because a draft
196+// is refused by `pr merge` and that is the first thing a reader wants to know.
197+func prStateLabel(mr api.MergeRequest) string {
198+ if mr.Draft && mr.State == "open" {
199+ return "draft"
200+ }
201+ return mr.State
202+}
203+
184204 func joinSubjects(subs []api.Subject) string {
185205 out := ""
186206 for i, s := range subs {
@@ -208,6 +228,9 @@ func runPRCreate(cmd *cobra.Command, _ []string) error {
208228 if prBase == "" || prHead == "" || prTitle == "" {
209229 return fmt.Errorf("--base, --head, and --title are required")
210230 }
231+ // The API opens a draft unless told otherwise; like gh, the CLI opens a
232+ // ready merge request by default and --draft opts in.
233+ draft := prDraft
211234 mr, err := client.CreateMergeRequest(cmd.Context(), owner, repo, api.MergeRequestCreate{
212235 Base: prBase,
213236 Head: prHead,
@@ -215,6 +238,7 @@ func runPRCreate(cmd *cobra.Command, _ []string) error {
215238 Body: prBody,
216239 HeadOwner: prHeadOwner,
217240 HeadRepo: prHeadRepo,
241+ Draft: &draft,
218242 })
219243 if err != nil {
220244 return err
@@ -222,10 +246,42 @@ func runPRCreate(cmd *cobra.Command, _ []string) error {
222246 if flagJSON {
223247 return printJSON(cmd.OutOrStdout(), mr)
224248 }
249+ if mr.Draft {
250+ fmt.Fprintf(cmd.OutOrStdout(), "Opened draft merge request #%d: %s (mark it ready with `rickub pr ready %d`)\n", mr.Number, mr.Title, mr.Number)
251+ return nil
252+ }
225253 fmt.Fprintf(cmd.OutOrStdout(), "Opened merge request #%d: %s\n", mr.Number, mr.Title)
226254 return nil
227255 }
228256
257+func runPRReady(cmd *cobra.Command, args []string) error {
258+ client, err := newClient()
259+ if err != nil {
260+ return err
261+ }
262+ owner, repo, err := resolveRepo(prRepo)
263+ if err != nil {
264+ return err
265+ }
266+ n, err := prNumber(args[0])
267+ if err != nil {
268+ return err
269+ }
270+ mr, err := client.SetMergeRequestDraft(cmd.Context(), owner, repo, n, prReadyUndo)
271+ if err != nil {
272+ return err
273+ }
274+ if flagJSON {
275+ return printJSON(cmd.OutOrStdout(), mr)
276+ }
277+ if prReadyUndo {
278+ fmt.Fprintf(cmd.OutOrStdout(), "#%d is a draft again\n", mr.Number)
279+ return nil
280+ }
281+ fmt.Fprintf(cmd.OutOrStdout(), "#%d is ready for review\n", mr.Number)
282+ return nil
283+}
284+
229285 func runPRMerge(cmd *cobra.Command, args []string) error {
230286 client, err := newClient()
231287 if err != nil {
@@ -19,6 +19,8 @@ var (
19 prHeadOwner string19 prHeadOwner string
20 prHeadRepo string20 prHeadRepo string
21 prMergeMethod string21 prMergeMethod string
22+ prDraft bool
23+ prReadyUndo bool
22 )24 )
23 25
24 func init() {26 func init() {
@@ -58,6 +60,15 @@ func init() {
58 createCmd.Flags().StringVarP(&prBody, "body", "b", "", "description body")60 createCmd.Flags().StringVarP(&prBody, "body", "b", "", "description body")
59 createCmd.Flags().StringVar(&prHeadOwner, "head-owner", "", "fork owner for a cross-repo MR")61 createCmd.Flags().StringVar(&prHeadOwner, "head-owner", "", "fork owner for a cross-repo MR")
60 createCmd.Flags().StringVar(&prHeadRepo, "head-repo", "", "fork name for a cross-repo MR")62 createCmd.Flags().StringVar(&prHeadRepo, "head-repo", "", "fork name for a cross-repo MR")
63+ createCmd.Flags().BoolVar(&prDraft, "draft", false, "open as a draft (not mergeable until `pr ready`)")
64+
65+ readyCmd := &cobra.Command{
66+ Use: "ready <number>",
67+ Short: "Mark a draft merge request ready for review (--undo puts it back in draft)",
68+ Args: cobra.ExactArgs(1),
69+ RunE: runPRReady,
70+ }
71+ readyCmd.Flags().BoolVar(&prReadyUndo, "undo", false, "convert back to a draft")
61 72
62 mergeCmd := &cobra.Command{73 mergeCmd := &cobra.Command{
63 Use: "merge <number>",74 Use: "merge <number>",
@@ -89,7 +100,7 @@ func init() {
89 }100 }
90 commentCmd.Flags().StringVarP(&prBody, "body", "b", "", "comment body (required)")101 commentCmd.Flags().StringVarP(&prBody, "body", "b", "", "comment body (required)")
91 102
92- prCmd.AddCommand(listCmd, viewCmd, createCmd, mergeCmd, closeCmd, reopenCmd, commentCmd)103+ prCmd.AddCommand(listCmd, viewCmd, createCmd, readyCmd, mergeCmd, closeCmd, reopenCmd, commentCmd)
93 rootCmd.AddCommand(prCmd)104 rootCmd.AddCommand(prCmd)
94 }105 }
95 106
@@ -124,7 +135,7 @@ func runPRList(cmd *cobra.Command, _ []string) error {
124 tw := newTabw(cmd.OutOrStdout())135 tw := newTabw(cmd.OutOrStdout())
125 fmt.Fprintln(tw, "#\tSTATE\tTITLE\tHEAD→BASE\tAUTHOR")136 fmt.Fprintln(tw, "#\tSTATE\tTITLE\tHEAD→BASE\tAUTHOR")
126 for _, mr := range page.Items {137 for _, mr := range page.Items {
127- fmt.Fprintf(tw, "%d\t%s\t%s\t%s→%s\t%s\n", mr.Number, mr.State, mr.Title, mr.HeadBranch, mr.BaseBranch, mr.Author)138+ fmt.Fprintf(tw, "%d\t%s\t%s\t%s→%s\t%s\n", mr.Number, prStateLabel(mr), mr.Title, mr.HeadBranch, mr.BaseBranch, mr.Author)
128 }139 }
129 tw.Flush()140 tw.Flush()
130 printPageFooter(cmd, page.Page)141 printPageFooter(cmd, page.Page)
@@ -152,7 +163,7 @@ func runPRView(cmd *cobra.Command, args []string) error {
152 return printJSON(cmd.OutOrStdout(), mr)163 return printJSON(cmd.OutOrStdout(), mr)
153 }164 }
154 out := cmd.OutOrStdout()165 out := cmd.OutOrStdout()
155- fmt.Fprintf(out, "#%d %s [%s]\n", mr.Number, mr.Title, mr.State)166+ fmt.Fprintf(out, "#%d %s [%s]\n", mr.Number, mr.Title, prStateLabel(mr.MergeRequest))
156 fmt.Fprintf(out, "%s wants to merge %s → %s\n", mr.Author, mr.HeadBranch, mr.BaseBranch)167 fmt.Fprintf(out, "%s wants to merge %s → %s\n", mr.Author, mr.HeadBranch, mr.BaseBranch)
157 if mr.CrossRepo {168 if mr.CrossRepo {
158 fmt.Fprintf(out, "cross-repo from %s/%s\n", mr.HeadOwner, mr.HeadRepo)169 fmt.Fprintf(out, "cross-repo from %s/%s\n", mr.HeadOwner, mr.HeadRepo)
@@ -181,6 +192,15 @@ func runPRView(cmd *cobra.Command, args []string) error {
181 return nil192 return nil
182 }193 }
183 194
195+// prStateLabel renders the state column: an open draft says so, because a draft
196+// is refused by `pr merge` and that is the first thing a reader wants to know.
197+func prStateLabel(mr api.MergeRequest) string {
198+ if mr.Draft && mr.State == "open" {
199+ return "draft"
200+ }
201+ return mr.State
202+}
203+
184 func joinSubjects(subs []api.Subject) string {204 func joinSubjects(subs []api.Subject) string {
185 out := ""205 out := ""
186 for i, s := range subs {206 for i, s := range subs {
@@ -208,6 +228,9 @@ func runPRCreate(cmd *cobra.Command, _ []string) error {
208 if prBase == "" || prHead == "" || prTitle == "" {228 if prBase == "" || prHead == "" || prTitle == "" {
209 return fmt.Errorf("--base, --head, and --title are required")229 return fmt.Errorf("--base, --head, and --title are required")
210 }230 }
231+ // The API opens a draft unless told otherwise; like gh, the CLI opens a
232+ // ready merge request by default and --draft opts in.
233+ draft := prDraft
211 mr, err := client.CreateMergeRequest(cmd.Context(), owner, repo, api.MergeRequestCreate{234 mr, err := client.CreateMergeRequest(cmd.Context(), owner, repo, api.MergeRequestCreate{
212 Base: prBase,235 Base: prBase,
213 Head: prHead,236 Head: prHead,
@@ -215,6 +238,7 @@ func runPRCreate(cmd *cobra.Command, _ []string) error {
215 Body: prBody,238 Body: prBody,
216 HeadOwner: prHeadOwner,239 HeadOwner: prHeadOwner,
217 HeadRepo: prHeadRepo,240 HeadRepo: prHeadRepo,
241+ Draft: &draft,
218 })242 })
219 if err != nil {243 if err != nil {
220 return err244 return err
@@ -222,10 +246,42 @@ func runPRCreate(cmd *cobra.Command, _ []string) error {
222 if flagJSON {246 if flagJSON {
223 return printJSON(cmd.OutOrStdout(), mr)247 return printJSON(cmd.OutOrStdout(), mr)
224 }248 }
249+ if mr.Draft {
250+ fmt.Fprintf(cmd.OutOrStdout(), "Opened draft merge request #%d: %s (mark it ready with `rickub pr ready %d`)\n", mr.Number, mr.Title, mr.Number)
251+ return nil
252+ }
225 fmt.Fprintf(cmd.OutOrStdout(), "Opened merge request #%d: %s\n", mr.Number, mr.Title)253 fmt.Fprintf(cmd.OutOrStdout(), "Opened merge request #%d: %s\n", mr.Number, mr.Title)
226 return nil254 return nil
227 }255 }
228 256
257+func runPRReady(cmd *cobra.Command, args []string) error {
258+ client, err := newClient()
259+ if err != nil {
260+ return err
261+ }
262+ owner, repo, err := resolveRepo(prRepo)
263+ if err != nil {
264+ return err
265+ }
266+ n, err := prNumber(args[0])
267+ if err != nil {
268+ return err
269+ }
270+ mr, err := client.SetMergeRequestDraft(cmd.Context(), owner, repo, n, prReadyUndo)
271+ if err != nil {
272+ return err
273+ }
274+ if flagJSON {
275+ return printJSON(cmd.OutOrStdout(), mr)
276+ }
277+ if prReadyUndo {
278+ fmt.Fprintf(cmd.OutOrStdout(), "#%d is a draft again\n", mr.Number)
279+ return nil
280+ }
281+ fmt.Fprintf(cmd.OutOrStdout(), "#%d is ready for review\n", mr.Number)
282+ return nil
283+}
284+
229 func runPRMerge(cmd *cobra.Command, args []string) error {285 func runPRMerge(cmd *cobra.Command, args []string) error {
230 client, err := newClient()286 client, err := newClient()
231 if err != nil {287 if err != nil {
modified internal/api/endpoints.go +31 -0
@@ -162,6 +162,14 @@ func (c *Client) ReopenMergeRequest(ctx context.Context, owner, repo string, num
162162 return &mr, err
163163 }
164164
165+// SetMergeRequestDraft marks a merge request ready for review (draft=false)
166+// or back to draft (draft=true).
167+func (c *Client) SetMergeRequestDraft(ctx context.Context, owner, repo string, number int, draft bool) (*MergeRequest, error) {
168+ var mr MergeRequest
169+ err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/draft", Body: map[string]bool{"draft": draft}}, &mr)
170+ return &mr, err
171+}
172+
165173 // CommentMergeRequest adds a comment to a merge request.
166174 func (c *Client) CommentMergeRequest(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) {
167175 var cm Comment
@@ -314,6 +322,29 @@ func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, e
314322 return l, err
315323 }
316324
325+// CreateLabel creates a label; an empty color lets the server pick its default.
326+func (c *Client) CreateLabel(ctx context.Context, owner, repo, name, color string) (*Label, error) {
327+ body := map[string]any{"name": name}
328+ if color != "" {
329+ body["color"] = color
330+ }
331+ var l Label
332+ err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/labels", Body: body}, &l)
333+ return &l, err
334+}
335+
336+// UpdateLabel patches a label by id; absent fields keep their values.
337+func (c *Client) UpdateLabel(ctx context.Context, owner, repo, id string, in map[string]any) (*Label, error) {
338+ var l Label
339+ err := c.do(ctx, Request{Method: http.MethodPatch, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/labels/" + seg(id), Body: in}, &l)
340+ return &l, err
341+}
342+
343+// DeleteLabel deletes a label by id.
344+func (c *Client) DeleteLabel(ctx context.Context, owner, repo, id string) error {
345+ return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/labels/" + seg(id)}, nil)
346+}
347+
317348 // ListMilestones lists a repo's milestones (state: open default, closed, all).
318349 func (c *Client) ListMilestones(ctx context.Context, owner, repo, state string) ([]Milestone, error) {
319350 extra := url.Values{}
@@ -162,6 +162,14 @@ func (c *Client) ReopenMergeRequest(ctx context.Context, owner, repo string, num
162 return &mr, err162 return &mr, err
163 }163 }
164 164
165+// SetMergeRequestDraft marks a merge request ready for review (draft=false)
166+// or back to draft (draft=true).
167+func (c *Client) SetMergeRequestDraft(ctx context.Context, owner, repo string, number int, draft bool) (*MergeRequest, error) {
168+ var mr MergeRequest
169+ err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/merge-requests/" + itoa(number) + "/draft", Body: map[string]bool{"draft": draft}}, &mr)
170+ return &mr, err
171+}
172+
165 // CommentMergeRequest adds a comment to a merge request.173 // CommentMergeRequest adds a comment to a merge request.
166 func (c *Client) CommentMergeRequest(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) {174 func (c *Client) CommentMergeRequest(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) {
167 var cm Comment175 var cm Comment
@@ -314,6 +322,29 @@ func (c *Client) ListLabels(ctx context.Context, owner, repo string) ([]Label, e
314 return l, err322 return l, err
315 }323 }
316 324
325+// CreateLabel creates a label; an empty color lets the server pick its default.
326+func (c *Client) CreateLabel(ctx context.Context, owner, repo, name, color string) (*Label, error) {
327+ body := map[string]any{"name": name}
328+ if color != "" {
329+ body["color"] = color
330+ }
331+ var l Label
332+ err := c.do(ctx, Request{Method: http.MethodPost, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/labels", Body: body}, &l)
333+ return &l, err
334+}
335+
336+// UpdateLabel patches a label by id; absent fields keep their values.
337+func (c *Client) UpdateLabel(ctx context.Context, owner, repo, id string, in map[string]any) (*Label, error) {
338+ var l Label
339+ err := c.do(ctx, Request{Method: http.MethodPatch, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/labels/" + seg(id), Body: in}, &l)
340+ return &l, err
341+}
342+
343+// DeleteLabel deletes a label by id.
344+func (c *Client) DeleteLabel(ctx context.Context, owner, repo, id string) error {
345+ return c.do(ctx, Request{Method: http.MethodDelete, Path: "/repos/" + seg(owner) + "/" + seg(repo) + "/labels/" + seg(id)}, nil)
346+}
347+
317 // ListMilestones lists a repo's milestones (state: open default, closed, all).348 // ListMilestones lists a repo's milestones (state: open default, closed, all).
318 func (c *Client) ListMilestones(ctx context.Context, owner, repo, state string) ([]Milestone, error) {349 func (c *Client) ListMilestones(ctx context.Context, owner, repo, state string) ([]Milestone, error) {
319 extra := url.Values{}350 extra := url.Values{}
modified internal/api/models.go +4 -0
@@ -56,6 +56,7 @@ type MergeRequest struct {
5656 Title string `json:"title"`
5757 Body string `json:"body"`
5858 State string `json:"state"`
59+ Draft bool `json:"draft"`
5960 Author string `json:"author"`
6061 BaseBranch string `json:"base_branch"`
6162 HeadBranch string `json:"head_branch"`
@@ -85,6 +86,9 @@ type MergeRequestCreate struct {
8586 Body string `json:"body,omitempty"`
8687 HeadOwner string `json:"head_owner,omitempty"`
8788 HeadRepo string `json:"head_repo,omitempty"`
89+ // Draft is sent explicitly: the API opens a merge request as a draft when
90+ // the field is absent, and a draft cannot be merged until marked ready.
91+ Draft *bool `json:"draft,omitempty"`
8892 }
8993
9094 // Comment is an MR comment.
@@ -56,6 +56,7 @@ type MergeRequest struct {
56 Title string `json:"title"`56 Title string `json:"title"`
57 Body string `json:"body"`57 Body string `json:"body"`
58 State string `json:"state"`58 State string `json:"state"`
59+ Draft bool `json:"draft"`
59 Author string `json:"author"`60 Author string `json:"author"`
60 BaseBranch string `json:"base_branch"`61 BaseBranch string `json:"base_branch"`
61 HeadBranch string `json:"head_branch"`62 HeadBranch string `json:"head_branch"`
@@ -85,6 +86,9 @@ type MergeRequestCreate struct {
85 Body string `json:"body,omitempty"`86 Body string `json:"body,omitempty"`
86 HeadOwner string `json:"head_owner,omitempty"`87 HeadOwner string `json:"head_owner,omitempty"`
87 HeadRepo string `json:"head_repo,omitempty"`88 HeadRepo string `json:"head_repo,omitempty"`
89+ // Draft is sent explicitly: the API opens a merge request as a draft when
90+ // the field is absent, and a draft cannot be merged until marked ready.
91+ Draft *bool `json:"draft,omitempty"`
88 }92 }
89 93
90 // Comment is an MR comment.94 // Comment is an MR comment.