diff --git a/.changes/unreleased/Added-20260314-155204.yaml b/.changes/unreleased/Added-20260314-155204.yaml new file mode 100644 index 000000000..ffd94f42f --- /dev/null +++ b/.changes/unreleased/Added-20260314-155204.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'branch comment: Add commands for managing change request review comments from the CLI' +time: 2026-03-14T15:52:04.220487-04:00 diff --git a/.changes/unreleased/Added-20260502-102600.yaml b/.changes/unreleased/Added-20260502-102600.yaml new file mode 100644 index 000000000..af000b4cb --- /dev/null +++ b/.changes/unreleased/Added-20260502-102600.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'branch comment list: Add --json flag to write comments as a stream of JSON objects' +time: 2026-05-02T10:26:00.668941-04:00 diff --git a/branch.go b/branch.go index 160587832..e359605ba 100644 --- a/branch.go +++ b/branch.go @@ -38,6 +38,9 @@ type branchCmd struct { // Pull request management Merge branchMergeCmd `cmd:"" aliases:"m" experiment:"merge" help:"Merge a branch into trunk"` Submit branchSubmitCmd `cmd:"" aliases:"s" help:"Submit a branch"` + + // Comment management + Comment branchCommentCmd `cmd:"" aliases:"cmt" help:"Manage change request comments"` } // BranchPromptConfig defines configuration for the branch tree prompt diff --git a/branch_comment.go b/branch_comment.go new file mode 100644 index 000000000..a1ec02b5d --- /dev/null +++ b/branch_comment.go @@ -0,0 +1,10 @@ +package main + +type branchCommentCmd struct { + List branchCommentListCmd `cmd:"" aliases:"ls" help:"List comments on a change request"` + Stage branchCommentStageCmd `cmd:"" help:"Stage an inline comment for batch submission"` + Add branchCommentAddCmd `cmd:"" help:"Post an inline comment immediately"` + SubmitStaged branchCommentSubmitStagedCmd `cmd:"" aliases:"ss" help:"Submit all staged comments as a review"` + Resolve branchCommentResolveCmd `cmd:"" help:"Resolve or unresolve a review thread"` + Edit branchCommentEditCmd `cmd:"" help:"Edit a comment"` +} diff --git a/branch_comment_add.go b/branch_comment_add.go new file mode 100644 index 000000000..e53c32bbf --- /dev/null +++ b/branch_comment_add.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.abhg.dev/gs/internal/diffmap" + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentAddCmd struct { + FileAndLine string `arg:"" optional:"" help:"File and line in the form file.go:42."` + Message string `short:"m" placeholder:"MSG" help:"Comment body. Opens editor if not provided."` + Respond string `placeholder:"THREAD_ID" help:"Thread ID to reply to instead of starting a new thread."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to add comment for. Defaults to current branch."` +} + +func (*branchCommentAddCmd) Help() string { + return text.Dedent(` + Posts an inline comment immediately + on the change request for the current branch. + Provide the file and line number as file.go:42. + + If no message is given with -m, an editor is opened. + + Use --respond to reply to an existing thread + instead of starting a new one. + `) +} + +func (cmd *branchCommentAddCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + repo *git.Repository, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + var file string + var line int + if cmd.Respond == "" { + if cmd.FileAndLine == "" { + return errors.New( + "file:line argument is required " + + "unless --respond is used", + ) + } + var err error + file, line, err = parseFileAndLine(cmd.FileAndLine) + if err != nil { + return err + } + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, "" /* initial */) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + b, err := svc.LookupBranch(ctx, branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf( + "branch not tracked: %s", branch, + ) + } + return fmt.Errorf("get branch: %w", err) + } + + if b.Change == nil { + return fmt.Errorf( + "no change request for %s; "+ + "submit the branch first with "+ + "'gs branch submit'", + branch, + ) + } + + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + return errors.New( + "forge does not support inline comments", + ) + } + + req := forge.InlineCommentRequest{ + Body: body, + ThreadID: cmd.Respond, + } + + // Map file:line to diff coordinates + // if this is a new comment (not a reply). + if cmd.Respond == "" { + diff, err := wt.DiffBranchBytes(ctx, b.Base, branch) + if err != nil { + return fmt.Errorf("get diff: %w", err) + } + + mapper, err := diffmap.New(diff) + if err != nil { + return fmt.Errorf("parse diff: %w", err) + } + + path, diffLine, side, err := mapper.Map(file, line) + if err != nil { + return fmt.Errorf( + "map %s:%d to diff: %w", + file, line, err, + ) + } + + req.Path = path + req.Line = diffLine + req.Side = side + } + + posted, err := inline.PostInlineComment( + ctx, b.Change.ChangeID(), req, + ) + if err != nil { + return fmt.Errorf("post inline comment: %w", err) + } + + log.Infof( + "Posted comment %s on %s.", + posted.ID, b.Change.ChangeID(), + ) + return nil +} diff --git a/branch_comment_edit.go b/branch_comment_edit.go new file mode 100644 index 000000000..be7d9fa7d --- /dev/null +++ b/branch_comment_edit.go @@ -0,0 +1,220 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentEditCmd struct { + ID string `arg:"" help:"Comment ID to edit. Use 'sc-N' for staged comments or a forge comment ID."` + Message string `short:"m" placeholder:"MSG" help:"New comment body. Opens editor if not provided."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch whose comments to edit. Defaults to current branch."` +} + +func (*branchCommentEditCmd) Help() string { + return text.Dedent(` + Edits the body of a comment. + + For staged comments (sc-N prefix), + the comment is updated in the local staging area. + + For forge comments, the comment is updated + on the remote forge. + + If no message is given with -m, an editor is opened + with the current comment body pre-filled. + `) +} + +func (cmd *branchCommentEditCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + store *state.Store, + repo *git.Repository, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + // Handle staged comment edits. + if scID, ok := parseStagedCommentID(cmd.ID); ok { + return cmd.editStaged( + ctx, log, store, repo, branch, scID, + ) + } + + // Handle forge comment edits. + return cmd.editForge( + ctx, log, wt, svc, repo, forgeRepo, branch, + ) +} + +func (cmd *branchCommentEditCmd) editStaged( + ctx context.Context, + log *silog.Logger, + store *state.Store, + repo *git.Repository, + branch string, + scID int, +) error { + staged, err := store.LoadStagedComments(ctx, branch) + if err != nil { + return fmt.Errorf("load staged comments: %w", err) + } + if staged == nil { + staged = &state.StagedComments{} + } + + idx := -1 + for i, c := range staged.Comments { + if c.ID == scID { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf( + "staged comment sc-%d not found", scID, + ) + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, staged.Comments[idx].Body, + ) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + staged.Comments[idx].Body = body + if err := store.SaveStagedComments( + ctx, branch, staged, + ); err != nil { + return fmt.Errorf("save staged comments: %w", err) + } + + log.Infof("Updated staged comment sc-%d.", scID) + return nil +} + +func (cmd *branchCommentEditCmd) editForge( + ctx context.Context, + log *silog.Logger, + _ *git.Worktree, + svc *spice.Service, + repo *git.Repository, + forgeRepo forge.Repository, + branch string, +) error { + b, err := svc.LookupBranch(ctx, branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf( + "branch not tracked: %s", branch, + ) + } + return fmt.Errorf("get branch: %w", err) + } + + if b.Change == nil { + return fmt.Errorf( + "no change request for %s", branch, + ) + } + + editor, ok := forgeRepo.(forge.WithCommentEdit) + if !ok { + return errors.New( + "forge does not support comment editing", + ) + } + + // Find the comment by ID to get its current body. + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + return errors.New( + "forge does not support inline comments", + ) + } + + comments, err := inline.ListInlineComments( + ctx, b.Change.ChangeID(), + ) + if err != nil { + return fmt.Errorf("list comments: %w", err) + } + + var target *forge.InlineComment + for _, c := range comments { + if c.ID.String() == cmd.ID { + target = c + break + } + } + if target == nil { + return fmt.Errorf( + "comment %s not found", cmd.ID, + ) + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, target.Body, + ) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + if err := editor.EditComment( + ctx, target.ID, body, + ); err != nil { + return fmt.Errorf("edit comment: %w", err) + } + + log.Infof("Updated comment %s.", cmd.ID) + return nil +} + +// parseStagedCommentID parses "sc-N" into integer N. +// Returns (N, true) on success, (0, false) otherwise. +func parseStagedCommentID(s string) (int, bool) { + after, found := strings.CutPrefix(s, "sc-") + if !found { + return 0, false + } + id, err := strconv.Atoi(after) + if err != nil { + return 0, false + } + return id, true +} diff --git a/branch_comment_list.go b/branch_comment_list.go new file mode 100644 index 000000000..c0ef9f57b --- /dev/null +++ b/branch_comment_list.go @@ -0,0 +1,361 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/alecthomas/kong" + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentListCmd struct { + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to list comments for. Defaults to current branch."` + Staged bool `help:"Show only staged comments."` + Unresolved bool `help:"Show only unresolved comments."` + JSON bool `name:"json" released:"unreleased" help:"Write to stdout as a stream of JSON objects."` +} + +func (*branchCommentListCmd) Help() string { + return text.Dedent(` + Lists comments on the change request + associated with the current branch. + Use --branch to target a different branch. + + Staged comments that have not yet been submitted + are shown with an 'sc-N' prefix. + + Use --staged to show only staged comments. + Use --unresolved to show only unresolved comments. + + With --json, prints output to stdout + as a stream of JSON objects. + `) +} + +func (cmd *branchCommentListCmd) Run( + ctx context.Context, + kctx *kong.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + store *state.Store, + forgeRepo forge.Repository, +) error { + branch, err := cmd.resolveBranch(ctx, wt) + if err != nil { + return err + } + + staged, forgeComments, err := cmd.loadComments( + ctx, log, svc, store, forgeRepo, branch, + ) + if err != nil { + return err + } + + if cmd.JSON { + return cmd.writeJSON( + kctx.Stdout, staged, forgeComments, + ) + } + return cmd.writeText(log, branch, staged, forgeComments) +} + +func (cmd *branchCommentListCmd) resolveBranch( + ctx context.Context, wt *git.Worktree, +) (string, error) { + if cmd.Branch != "" { + return cmd.Branch, nil + } + branch, err := wt.CurrentBranch(ctx) + if err != nil { + return "", fmt.Errorf("get current branch: %w", err) + } + return branch, nil +} + +func (cmd *branchCommentListCmd) loadComments( + ctx context.Context, + log *silog.Logger, + svc *spice.Service, + store *state.Store, + forgeRepo forge.Repository, + branch string, +) ([]*state.StagedComment, []*forge.InlineComment, error) { + staged, err := loadStagedComments(ctx, store, branch) + if err != nil { + return nil, nil, err + } + + if cmd.Staged { + return staged, nil, nil + } + + forgeComments, err := loadForgeComments( + ctx, log, svc, forgeRepo, branch, + ) + if err != nil { + return nil, nil, err + } + + return staged, cmd.filterForge(forgeComments), nil +} + +func loadStagedComments( + ctx context.Context, + store *state.Store, + branch string, +) ([]*state.StagedComment, error) { + staged, err := store.LoadStagedComments(ctx, branch) + if err != nil { + return nil, fmt.Errorf( + "load staged comments: %w", err, + ) + } + if staged == nil { + return nil, nil + } + + refs := make( + []*state.StagedComment, len(staged.Comments), + ) + for i := range staged.Comments { + refs[i] = &staged.Comments[i] + } + return refs, nil +} + +func loadForgeComments( + ctx context.Context, + log *silog.Logger, + svc *spice.Service, + forgeRepo forge.Repository, + branch string, +) ([]*forge.InlineComment, error) { + b, err := svc.LookupBranch(ctx, branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return nil, fmt.Errorf( + "branch not tracked: %s", branch, + ) + } + return nil, fmt.Errorf("get branch: %w", err) + } + + if b.Change == nil { + log.Infof( + "No change request found for %s.", branch, + ) + return nil, nil + } + + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + log.Infof( + "Forge does not support inline comments.", + ) + return nil, nil + } + + comments, err := inline.ListInlineComments( + ctx, b.Change.ChangeID(), + ) + if err != nil { + return nil, fmt.Errorf( + "list inline comments: %w", err, + ) + } + return comments, nil +} + +func (cmd *branchCommentListCmd) filterForge( + comments []*forge.InlineComment, +) []*forge.InlineComment { + if !cmd.Unresolved { + return comments + } + + var filtered []*forge.InlineComment + for _, c := range comments { + if !c.Resolved { + filtered = append(filtered, c) + } + } + return filtered +} + +// writeText prints comments in human-readable format. +func (cmd *branchCommentListCmd) writeText( + log *silog.Logger, + branch string, + staged []*state.StagedComment, + forgeComments []*forge.InlineComment, +) error { + if len(staged) > 0 { + log.Infof("Staged comments:") + for _, c := range staged { + writeStagedText(log, c) + } + } + + if cmd.Staged && len(staged) == 0 { + log.Infof("No staged comments for %s.", branch) + return nil + } + + if len(forgeComments) > 0 { + log.Infof("Comments:") + for _, c := range forgeComments { + writeForgeText(log, c) + } + } + + if len(forgeComments) == 0 && len(staged) == 0 { + log.Infof("No comments on %s.", branch) + } + return nil +} + +func writeStagedText( + log *silog.Logger, c *state.StagedComment, +) { + location := fmt.Sprintf("%s:%d", c.File, c.Line) + if c.ThreadID != "" { + location = "reply:" + c.ThreadID + } + log.Infof(" sc-%-4d %s", c.ID, location) + writeBodyIndented(log, c.Body) +} + +func writeForgeText( + log *silog.Logger, c *forge.InlineComment, +) { + location := fmt.Sprintf("%s:%d", c.Path, c.Line) + threadInfo := "" + if c.ThreadID != "" { + threadInfo = " [" + c.ThreadID + "]" + } + log.Infof( + " %-12s %s %s %s%s", + c.ID, location, c.Author, + commentStatus(c), threadInfo, + ) + writeBodyIndented(log, c.Body) +} + +func writeBodyIndented(log *silog.Logger, body string) { + for line := range strings.SplitSeq(body, "\n") { + log.Infof(" %s", line) + } +} + +func commentStatus(c *forge.InlineComment) string { + if c.Outdated { + return "outdated" + } + if c.Resolved { + return "resolved" + } + return "open" +} + +// writeJSON encodes comments as NDJSON to stdout. +func (cmd *branchCommentListCmd) writeJSON( + w io.Writer, + staged []*state.StagedComment, + forgeComments []*forge.InlineComment, +) (retErr error) { + bufw := bufio.NewWriter(w) + defer func() { + retErr = errors.Join(retErr, bufw.Flush()) + }() + + enc := json.NewEncoder(bufw) + for _, c := range staged { + if err := enc.Encode(stagedToJSON(c)); err != nil { + return fmt.Errorf("encode staged: %w", err) + } + } + for _, c := range forgeComments { + if err := enc.Encode(forgeToJSON(c)); err != nil { + return fmt.Errorf("encode forge: %w", err) + } + } + return nil +} + +func stagedToJSON(c *state.StagedComment) jsonComment { + return jsonComment{ + Kind: "staged", + ID: fmt.Sprintf("sc-%d", c.ID), + Path: c.File, + Line: c.Line, + Body: c.Body, + ThreadID: c.ThreadID, + } +} + +func forgeToJSON(c *forge.InlineComment) jsonComment { + var createdAt *time.Time + if !c.CreatedAt.IsZero() { + createdAt = &c.CreatedAt + } + return jsonComment{ + Kind: "forge", + ID: c.ID.String(), + Path: c.Path, + Line: c.Line, + Body: c.Body, + ThreadID: c.ThreadID, + Author: c.Author, + Status: commentStatus(c), + CreatedAt: createdAt, + } +} + +// jsonComment is the JSON representation +// of a comment for --json output. +type jsonComment struct { + // Kind is "staged" or "forge". + Kind string `json:"kind"` + + // ID is the comment identifier. + // For staged comments: "sc-N". + // For forge comments: forge-specific ID. + ID string `json:"id"` + + // Path is the file path relative to the repo root. + Path string `json:"path,omitempty"` + + // Line is the line number in the file. + Line int `json:"line,omitempty"` + + // Body is the full markdown body of the comment. + Body string `json:"body"` + + // ThreadID is the thread identifier, if any. + ThreadID string `json:"threadID,omitempty"` + + // Author is the username of the comment author. + // Only set for forge comments. + Author string `json:"author,omitempty"` + + // Status is "open", "resolved", or "outdated". + // Only set for forge comments. + Status string `json:"status,omitempty"` + + // CreatedAt is the time the comment was created. + // Only set for forge comments. + CreatedAt *time.Time `json:"createdAt,omitempty"` +} diff --git a/branch_comment_resolve.go b/branch_comment_resolve.go new file mode 100644 index 000000000..60bf544c3 --- /dev/null +++ b/branch_comment_resolve.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentResolveCmd struct { + ThreadID string `arg:"" help:"Thread ID to resolve."` + Unresolve bool `help:"Unresolve the thread instead of resolving it."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch whose change request contains the thread. Defaults to current branch."` +} + +func (*branchCommentResolveCmd) Help() string { + return text.Dedent(` + Resolves a review thread on the change request + for the current branch. + + Use --unresolve to mark the thread as unresolved. + + The thread ID is shown in 'gs branch comment list'. + `) +} + +func (cmd *branchCommentResolveCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + // Verify branch has a change request. + b, err := svc.LookupBranch(ctx, branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf( + "branch not tracked: %s", branch, + ) + } + return fmt.Errorf("get branch: %w", err) + } + + if b.Change == nil { + return fmt.Errorf( + "no change request for %s", branch, + ) + } + + resolver, ok := forgeRepo.(forge.WithThreadResolution) + if !ok { + return errors.New( + "forge does not support thread resolution", + ) + } + + if cmd.Unresolve { + if err := resolver.UnresolveThread( + ctx, cmd.ThreadID, + ); err != nil { + return fmt.Errorf( + "unresolve thread: %w", err, + ) + } + log.Infof("Unresolved thread %s.", cmd.ThreadID) + } else { + if err := resolver.ResolveThread( + ctx, cmd.ThreadID, + ); err != nil { + return fmt.Errorf("resolve thread: %w", err) + } + log.Infof("Resolved thread %s.", cmd.ThreadID) + } + + return nil +} diff --git a/branch_comment_stage.go b/branch_comment_stage.go new file mode 100644 index 000000000..b5eb87eec --- /dev/null +++ b/branch_comment_stage.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" + "go.abhg.dev/gs/internal/xec" +) + +type branchCommentStageCmd struct { + FileAndLine string `arg:"" optional:"" help:"File and line in the form file.go:42."` + Message string `short:"m" placeholder:"MSG" help:"Comment body. Opens editor if not provided."` + Respond string `placeholder:"THREAD_ID" help:"Thread ID to reply to instead of starting a new thread."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to stage comment for. Defaults to current branch."` +} + +func (*branchCommentStageCmd) Help() string { + return text.Dedent(` + Stages an inline comment for later batch submission. + Provide the file and line number as file.go:42. + + If no message is given with -m, an editor is opened. + + Use --respond to reply to an existing thread + instead of starting a new one. + + Staged comments are submitted together with + 'gs branch comment submit-staged'. + `) +} + +func (cmd *branchCommentStageCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + store *state.Store, + repo *git.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + var file string + var line int + if cmd.Respond == "" { + if cmd.FileAndLine == "" { + return errors.New( + "file:line argument is required " + + "unless --respond is used", + ) + } + var err error + file, line, err = parseFileAndLine(cmd.FileAndLine) + if err != nil { + return err + } + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, "" /* initial */) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + staged, err := store.LoadStagedComments(ctx, branch) + if err != nil { + return fmt.Errorf("load staged comments: %w", err) + } + if staged == nil { + staged = &state.StagedComments{NextID: 1} + } + + comment := state.StagedComment{ + ID: staged.NextID, + File: file, + Line: line, + Body: body, + ThreadID: cmd.Respond, + } + staged.Comments = append(staged.Comments, comment) + staged.NextID++ + + if err := store.SaveStagedComments( + ctx, branch, staged, + ); err != nil { + return fmt.Errorf("save staged comments: %w", err) + } + + if cmd.Respond != "" { + log.Infof( + "Staged reply sc-%d to thread %s.", + comment.ID, cmd.Respond, + ) + } else { + log.Infof( + "Staged comment sc-%d on %s:%d.", + comment.ID, file, line, + ) + } + return nil +} + +// parseFileAndLine parses a "file.go:42" argument +// into file and line components. +func parseFileAndLine(s string) (string, int, error) { + idx := strings.LastIndex(s, ":") + if idx < 0 { + return "", 0, fmt.Errorf( + "expected file:line format, got %q", s, + ) + } + file := s[:idx] + line, err := strconv.Atoi(s[idx+1:]) + if err != nil { + return "", 0, fmt.Errorf( + "invalid line number in %q: %w", s, err, + ) + } + if line <= 0 { + return "", 0, fmt.Errorf( + "line number must be positive, got %d", line, + ) + } + return file, line, nil +} + +// editCommentBody opens an editor for the user +// to write a comment body. +// initial is pre-filled text (may be empty). +func editCommentBody( + ctx context.Context, + repo *git.Repository, + initial string, +) (string, error) { + tmpFile := filepath.Join( + os.TempDir(), "GS_COMMENT_EDITMSG", + ) + if err := os.WriteFile( + tmpFile, []byte(initial), 0o644, + ); err != nil { + return "", fmt.Errorf("write temp file: %w", err) + } + defer func() { _ = os.Remove(tmpFile) }() + + editor := gitEditor(ctx, repo) + cmd := xec.EditCommand(editor, tmpFile) + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("run editor: %w", err) + } + + content, err := os.ReadFile(tmpFile) + if err != nil { + return "", fmt.Errorf("read temp file: %w", err) + } + return string(content), nil +} diff --git a/branch_comment_submit_staged.go b/branch_comment_submit_staged.go new file mode 100644 index 000000000..4194c22ce --- /dev/null +++ b/branch_comment_submit_staged.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/diffmap" + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentSubmitStagedCmd struct { + Body string `placeholder:"BODY" help:"Overall review body."` + Approve bool `help:"Mark the review as approved."` + RequestChanges bool `name:"request-changes" help:"Mark the review as requesting changes."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to submit staged comments for. Defaults to current branch."` +} + +func (*branchCommentSubmitStagedCmd) Help() string { + return text.Dedent(` + Submits all staged comments for the current branch + as a single review on the change request. + + Use --approve or --request-changes + to set the review event type. + Defaults to a comment-only review. + + Use --body to add an overall review body. + `) +} + +func (cmd *branchCommentSubmitStagedCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + store *state.Store, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + staged, err := store.LoadStagedComments(ctx, branch) + if err != nil { + return fmt.Errorf("load staged comments: %w", err) + } + if staged == nil { + staged = &state.StagedComments{} + } + + if len(staged.Comments) == 0 { + log.Infof("No staged comments to submit.") + return nil + } + + b, err := svc.LookupBranch(ctx, branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf( + "branch not tracked: %s", branch, + ) + } + return fmt.Errorf("get branch: %w", err) + } + + if b.Change == nil { + return fmt.Errorf( + "no change request for %s; "+ + "submit the branch first with "+ + "'gs branch submit'", + branch, + ) + } + + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + return errors.New( + "forge does not support inline comments", + ) + } + + // Build diff map for coordinate translation. + diff, err := wt.DiffBranchBytes(ctx, b.Base, branch) + if err != nil { + return fmt.Errorf("get diff: %w", err) + } + + mapper, err := diffmap.New(diff) + if err != nil { + return fmt.Errorf("parse diff: %w", err) + } + + // Map staged comments to forge requests. + var comments []forge.InlineCommentRequest + for _, sc := range staged.Comments { + if sc.ThreadID != "" { + // Thread reply: no coordinate mapping needed. + comments = append(comments, + forge.InlineCommentRequest{ + Body: sc.Body, + ThreadID: sc.ThreadID, + }, + ) + continue + } + + path, diffLine, side, err := mapper.Map( + sc.File, sc.Line, + ) + if err != nil { + return fmt.Errorf( + "sc-%d: map %s:%d to diff: %w", + sc.ID, sc.File, sc.Line, err, + ) + } + + comments = append(comments, + forge.InlineCommentRequest{ + Path: path, + Line: diffLine, + Body: sc.Body, + Side: side, + }, + ) + } + + event := forge.ReviewComment + if cmd.Approve { + event = forge.ReviewApprove + } else if cmd.RequestChanges { + event = forge.ReviewRequestChanges + } + + if err := inline.SubmitReview( + ctx, + b.Change.ChangeID(), + forge.ReviewRequest{ + Body: cmd.Body, + Comments: comments, + Event: event, + }, + ); err != nil { + return fmt.Errorf("submit review: %w", err) + } + + if err := store.ClearStagedComments( + ctx, branch, + ); err != nil { + return fmt.Errorf("clear staged comments: %w", err) + } + + log.Infof( + "Submitted %d comment(s) as review on %s.", + len(comments), + b.Change.ChangeID(), + ) + return nil +} diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 507c9d1c6..f99e35ecd 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -310,11 +310,9 @@ Before merging, the stack is checked for branches whose base PR was already merged on the forge. Use --no-branch-check to skip this validation. -Before each merge, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait -before failing if merge readiness is not reached. +Before each merge, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait +before failing if checks are not ready. By default, a branch failure skips that branch's upstack descendants, but independent sibling branches continue. @@ -323,12 +321,12 @@ Use --fail-fast to stop the queue after the first branch failure. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. * `--no-branch-check`: Skip stale base validation before merging. * `--fail-fast`: Stop the merge queue after the first branch failure. * `--branch=NAME`: Branch whose stack to merge -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) ### git-spice stack restack {#gs-stack-restack} @@ -626,7 +624,7 @@ This command acts as a local merge queue: it merges one Change Request, waits for that merge to finish, restacks and updates the next Change Request, -waits for merge readiness on the updated Change Request, +waits for its CI checks to pass, and then repeats the process. For a stack like this: @@ -644,15 +642,13 @@ Before merging, the downstack is checked for branches whose base PR was already merged on the forge. Use --no-branch-check to skip this validation. -Before each merge, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait +Before each merge, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait (default: 30m, 0 means fail immediately if not ready). Between merges, the command waits for each merge to complete, restacks and updates the next PR, -waits for merge readiness on the updated PR, +waits for CI checks on the updated PR, and syncs merged branch cleanup. Use --no-wait for single branch merging @@ -662,12 +658,12 @@ when you don't want to wait for the merge to propagate. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. * `--no-wait`: Skip polling for a single branch merge to propagate. * `--no-branch-check`: Skip stale base validation before merging. * `--branch=NAME`: Branch to start merging from -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) ### git-spice downstack edit {#gs-downstack-edit} @@ -1137,18 +1133,16 @@ Use --branch to merge a different branch. The branch must be based directly on trunk. To merge a stacked branch, use 'gs downstack merge'. -Before merging, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait. +Before merging, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. * `--branch=NAME`: Branch to merge -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) ### git-spice branch submit {#gs-branch-submit} @@ -1205,6 +1199,166 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) +### git-spice branch comment list {#gs-branch-comment-list} + +``` +gs branch (b) comment (cmt) list (ls) [flags] +``` + +List comments on a change request + +Lists comments on the change request +associated with the current branch. +Use --branch to target a different branch. + +Staged comments that have not yet been submitted +are shown with an 'sc-N' prefix. + +Use --staged to show only staged comments. +Use --unresolved to show only unresolved comments. + +With --json, prints output to stdout +as a stream of JSON objects. + +**Flags** + +* `-b`, `--branch=BRANCH`: Branch to list comments for. Defaults to current branch. +* `--staged`: Show only staged comments. +* `--unresolved`: Show only unresolved comments. +* `--json`: Write to stdout as a stream of JSON objects. :material-tag-hidden:{ title="Released in version" }Unreleased + +### git-spice branch comment stage {#gs-branch-comment-stage} + +``` +gs branch (b) comment (cmt) stage [] [flags] +``` + +Stage an inline comment for batch submission + +Stages an inline comment for later batch submission. +Provide the file and line number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread +instead of starting a new one. + +Staged comments are submitted together with +'gs branch comment submit-staged'. + +**Arguments** + +* `file-and-line`: File and line in the form file.go:42. + +**Flags** + +* `-m`, `--message=MSG`: Comment body. Opens editor if not provided. +* `--respond=THREAD_ID`: Thread ID to reply to instead of starting a new thread. +* `-b`, `--branch=BRANCH`: Branch to stage comment for. Defaults to current branch. + +### git-spice branch comment add {#gs-branch-comment-add} + +``` +gs branch (b) comment (cmt) add [] [flags] +``` + +Post an inline comment immediately + +Posts an inline comment immediately +on the change request for the current branch. +Provide the file and line number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread +instead of starting a new one. + +**Arguments** + +* `file-and-line`: File and line in the form file.go:42. + +**Flags** + +* `-m`, `--message=MSG`: Comment body. Opens editor if not provided. +* `--respond=THREAD_ID`: Thread ID to reply to instead of starting a new thread. +* `-b`, `--branch=BRANCH`: Branch to add comment for. Defaults to current branch. + +### git-spice branch comment submit-staged {#gs-branch-comment-submit-staged} + +``` +gs branch (b) comment (cmt) submit-staged (ss) [flags] +``` + +Submit all staged comments as a review + +Submits all staged comments for the current branch +as a single review on the change request. + +Use --approve or --request-changes +to set the review event type. +Defaults to a comment-only review. + +Use --body to add an overall review body. + +**Flags** + +* `--body=BODY`: Overall review body. +* `--approve`: Mark the review as approved. +* `--request-changes`: Mark the review as requesting changes. +* `-b`, `--branch=BRANCH`: Branch to submit staged comments for. Defaults to current branch. + +### git-spice branch comment resolve {#gs-branch-comment-resolve} + +``` +gs branch (b) comment (cmt) resolve [flags] +``` + +Resolve or unresolve a review thread + +Resolves a review thread on the change request +for the current branch. + +Use --unresolve to mark the thread as unresolved. + +The thread ID is shown in 'gs branch comment list'. + +**Arguments** + +* `thread-id`: Thread ID to resolve. + +**Flags** + +* `--unresolve`: Unresolve the thread instead of resolving it. +* `-b`, `--branch=BRANCH`: Branch whose change request contains the thread. Defaults to current branch. + +### git-spice branch comment edit {#gs-branch-comment-edit} + +``` +gs branch (b) comment (cmt) edit [flags] +``` + +Edit a comment + +Edits the body of a comment. + +For staged comments (sc-N prefix), +the comment is updated in the local staging area. + +For forge comments, the comment is updated +on the remote forge. + +If no message is given with -m, an editor is opened +with the current comment body pre-filled. + +**Arguments** + +* `id`: Comment ID to edit. Use 'sc-N' for staged comments or a forge comment ID. + +**Flags** + +* `-m`, `--message=MSG`: New comment body. Opens editor if not provided. +* `-b`, `--branch=BRANCH`: Branch whose comments to edit. Defaults to current branch. + ## Commit ### git-spice commit create {#gs-commit-create} diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index 064cad722..b3cb86a0e 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -1,6 +1,8 @@ | **Shorthand** | **Long form** | | --- | --- | | gs bc | [gs branch create](/cli/reference.md#gs-branch-create) | +| gs bcmtls | [gs branch comment list](/cli/reference.md#gs-branch-comment-list) | +| gs bcmtss | [gs branch comment submit-staged](/cli/reference.md#gs-branch-comment-submit-staged) | | gs bco | [gs branch checkout](/cli/reference.md#gs-branch-checkout) | | gs bd | [gs branch delete](/cli/reference.md#gs-branch-delete) | | gs bdi | [gs branch diff](/cli/reference.md#gs-branch-diff) | diff --git a/internal/git/diff_wt.go b/internal/git/diff_wt.go index a3a805de9..f1686410b 100644 --- a/internal/git/diff_wt.go +++ b/internal/git/diff_wt.go @@ -19,3 +19,18 @@ func (w *Worktree) DiffBranch(ctx context.Context, base, head string) error { } return nil } + +// DiffBranchBytes returns the unified diff output +// between base and head using triple-dot syntax. +func (w *Worktree) DiffBranchBytes( + ctx context.Context, + base, head string, +) ([]byte, error) { + out, err := w.gitCmd( + ctx, "diff", base+"..."+head, + ).Output() + if err != nil { + return nil, fmt.Errorf("diff: %w", err) + } + return out, nil +} diff --git a/testdata/help/branch_comment_add.txt b/testdata/help/branch_comment_add.txt new file mode 100644 index 000000000..a3a596440 --- /dev/null +++ b/testdata/help/branch_comment_add.txt @@ -0,0 +1,27 @@ +Usage: gs branch (b) comment (cmt) add [] [flags] + +Post an inline comment immediately + +Posts an inline comment immediately on the change request for the current +branch. Provide the file and line number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread instead of starting a new one. + +Arguments: + [] File and line in the form file.go:42. + +Flags: + -m, --message=MSG Comment body. Opens editor if not provided. + --respond=THREAD_ID Thread ID to reply to instead of starting a new + thread. + -b, --branch=BRANCH Branch to add comment for. Defaults to current + branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_edit.txt b/testdata/help/branch_comment_edit.txt new file mode 100644 index 000000000..26ef35744 --- /dev/null +++ b/testdata/help/branch_comment_edit.txt @@ -0,0 +1,29 @@ +Usage: gs branch (b) comment (cmt) edit [flags] + +Edit a comment + +Edits the body of a comment. + +For staged comments (sc-N prefix), the comment is updated in the local staging +area. + +For forge comments, the comment is updated on the remote forge. + +If no message is given with -m, an editor is opened with the current comment +body pre-filled. + +Arguments: + Comment ID to edit. Use 'sc-N' for staged comments or a forge comment + ID. + +Flags: + -m, --message=MSG New comment body. Opens editor if not provided. + -b, --branch=BRANCH Branch whose comments to edit. Defaults to current + branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_list.txt b/testdata/help/branch_comment_list.txt new file mode 100644 index 000000000..2ad4dabf5 --- /dev/null +++ b/testdata/help/branch_comment_list.txt @@ -0,0 +1,28 @@ +Usage: gs branch (b) comment (cmt) list (ls) [flags] + +List comments on a change request + +Lists comments on the change request associated with the current branch. +Use --branch to target a different branch. + +Staged comments that have not yet been submitted are shown with an 'sc-N' +prefix. + +Use --staged to show only staged comments. Use --unresolved to show only +unresolved comments. + +With --json, prints output to stdout as a stream of JSON objects. + +Flags: + -b, --branch=BRANCH Branch to list comments for. Defaults to current + branch. + --staged Show only staged comments. + --unresolved Show only unresolved comments. + --json Write to stdout as a stream of JSON objects. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_resolve.txt b/testdata/help/branch_comment_resolve.txt new file mode 100644 index 000000000..c4ee13472 --- /dev/null +++ b/testdata/help/branch_comment_resolve.txt @@ -0,0 +1,24 @@ +Usage: gs branch (b) comment (cmt) resolve [flags] + +Resolve or unresolve a review thread + +Resolves a review thread on the change request for the current branch. + +Use --unresolve to mark the thread as unresolved. + +The thread ID is shown in 'gs branch comment list'. + +Arguments: + Thread ID to resolve. + +Flags: + --unresolve Unresolve the thread instead of resolving it. + -b, --branch=BRANCH Branch whose change request contains the thread. + Defaults to current branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_stage.txt b/testdata/help/branch_comment_stage.txt new file mode 100644 index 000000000..d8d4e7d2b --- /dev/null +++ b/testdata/help/branch_comment_stage.txt @@ -0,0 +1,29 @@ +Usage: gs branch (b) comment (cmt) stage [] [flags] + +Stage an inline comment for batch submission + +Stages an inline comment for later batch submission. Provide the file and line +number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread instead of starting a new one. + +Staged comments are submitted together with 'gs branch comment submit-staged'. + +Arguments: + [] File and line in the form file.go:42. + +Flags: + -m, --message=MSG Comment body. Opens editor if not provided. + --respond=THREAD_ID Thread ID to reply to instead of starting a new + thread. + -b, --branch=BRANCH Branch to stage comment for. Defaults to current + branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_submit-staged.txt b/testdata/help/branch_comment_submit-staged.txt new file mode 100644 index 000000000..6d6b6c339 --- /dev/null +++ b/testdata/help/branch_comment_submit-staged.txt @@ -0,0 +1,25 @@ +Usage: gs branch (b) comment (cmt) submit-staged (ss) [flags] + +Submit all staged comments as a review + +Submits all staged comments for the current branch as a single review on the +change request. + +Use --approve or --request-changes to set the review event type. Defaults to a +comment-only review. + +Use --body to add an overall review body. + +Flags: + --body=BODY Overall review body. + --approve Mark the review as approved. + --request-changes Mark the review as requesting changes. + -b, --branch=BRANCH Branch to submit staged comments for. Defaults to + current branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 43406fde2..68df72df1 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -46,21 +46,31 @@ Stack downstack (ds) restack (r) Restack a branch and its downstack Branch - branch (b) track (tr) Track a branch - branch (b) untrack (untr) Forget a tracked branch - branch (b) checkout (co) Switch to a branch - branch (b) create (c) Create a new branch - branch (b) delete (d,rm) Delete branches - branch (b) fold (fo) Merge a branch into its base - branch (b) split (sp) Split a branch on commits - branch (b) squash (sq) Squash a branch into one commit - branch (b) edit (e) Edit the commits in a branch - branch (b) rename (rn,mv) Rename a branch - branch (b) restack (r) Restack a branch - branch (b) onto (on) Move a branch onto another branch - branch (b) diff (di) Show diff between a branch and its base - branch (b) merge (m) Merge a branch into trunk - branch (b) submit (s) Submit a branch + branch (b) track (tr) Track a branch + branch (b) untrack (untr) Forget a tracked branch + branch (b) checkout (co) Switch to a branch + branch (b) create (c) Create a new branch + branch (b) delete (d,rm) Delete branches + branch (b) fold (fo) Merge a branch into its base + branch (b) split (sp) Split a branch on commits + branch (b) squash (sq) Squash a branch into one commit + branch (b) edit (e) Edit the commits in a branch + branch (b) rename (rn,mv) Rename a branch + branch (b) restack (r) Restack a branch + branch (b) onto (on) Move a branch onto another branch + branch (b) diff (di) Show diff between a branch and its base + branch (b) merge (m) Merge a branch into trunk + branch (b) submit (s) Submit a branch + branch (b) comment (cmt) list (ls) + List comments on a change request + branch (b) comment (cmt) stage + Stage an inline comment for batch submission + branch (b) comment (cmt) add Post an inline comment immediately + branch (b) comment (cmt) submit-staged (ss) + Submit all staged comments as a review + branch (b) comment (cmt) resolve + Resolve or unresolve a review thread + branch (b) comment (cmt) edit Edit a comment Commit commit (c) create (c) Create a new commit