741 lines
24 KiB
Go
741 lines
24 KiB
Go
// Command spx is a small Gitea/GitHub wrapper for the superpowers-vscode workflow.
|
||
//
|
||
// It targets agent (cc/codex) usage: deterministic flags, JSON output mode,
|
||
// and the same commands whichever forge the repo's git origin points at.
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/spf13/cobra"
|
||
|
||
"github.com/cruldra/superpowers-vscode/cli/internal/forge"
|
||
"github.com/cruldra/superpowers-vscode/cli/internal/marker"
|
||
"github.com/cruldra/superpowers-vscode/cli/internal/repo"
|
||
"github.com/cruldra/superpowers-vscode/cli/internal/state"
|
||
)
|
||
|
||
// runtimeContext is the bag of resolved configuration we hand to subcommands
|
||
// via cobra's context. Filled in by the root PersistentPreRunE.
|
||
type runtimeContext struct {
|
||
Forge forge.Forge
|
||
JSON bool
|
||
}
|
||
|
||
// forgeOpener connects to the repo spx should act on; main resolves it from flags and git origin, tests from a fake server.
|
||
type forgeOpener func() (forge.Forge, error)
|
||
|
||
type ctxKey struct{}
|
||
|
||
// global flag values (bound by Cobra).
|
||
var (
|
||
flagRepo string
|
||
flagHost string
|
||
flagJSON bool
|
||
flagCwd string
|
||
)
|
||
|
||
// issue create flags.
|
||
var (
|
||
icTitle string
|
||
icBody string
|
||
icBodyFile string
|
||
icSpec string
|
||
icPlan string
|
||
icStateJSON string
|
||
icStateFile string
|
||
)
|
||
|
||
// issue marker flags.
|
||
var (
|
||
imIssue int
|
||
imType string
|
||
imValue string
|
||
)
|
||
|
||
// issue state get flags.
|
||
var (
|
||
isgIssue int
|
||
)
|
||
|
||
// issue state merge flags.
|
||
var (
|
||
ismIssue int
|
||
ismStateJSON string
|
||
ismStateFile string
|
||
)
|
||
|
||
// issue view flags.
|
||
var (
|
||
ivIssue int
|
||
ivComments bool
|
||
)
|
||
|
||
// pr review-comment flags.
|
||
var (
|
||
prcPR int
|
||
prcBody string
|
||
prcBodyFile string
|
||
)
|
||
|
||
// pr view flags.
|
||
var (
|
||
pvPR int
|
||
)
|
||
|
||
// pr create flags.
|
||
var (
|
||
pcTitle string
|
||
pcBody string
|
||
pcBodyFile string
|
||
pcHead string
|
||
pcBase string
|
||
)
|
||
|
||
func main() {
|
||
root := buildRootCmd(openForge)
|
||
if err := root.Execute(); err != nil {
|
||
// cobra already printed the error to stderr; ensure non-zero exit.
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func buildRootCmd(open forgeOpener) *cobra.Command {
|
||
root := &cobra.Command{
|
||
Use: "spx",
|
||
Short: "superpowers-vscode 工作流 CLI(Gitea / GitHub 薄包装)",
|
||
Long: "spx 为 cc/codex agent 在 superpowers-vscode 工作流里提供确定性的工单 / PR 操作。\n" +
|
||
"按 git origin 的 host 选仓库:github.com 走 GitHub(token 取自 `gh auth token`),\n" +
|
||
"其它 host 走 Gitea(必须先 `tea login add` 过这个 host,token 取自 ~/.config/tea/config.yml 里同 host 的 login,\n" +
|
||
"该 login 没存 token 时才用 GITEA_TOKEN 补上;GITEA_TOKEN 不会发给 tea 没登录过的 host)。",
|
||
SilenceUsage: true,
|
||
SilenceErrors: false,
|
||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||
f, err := open()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rc := &runtimeContext{Forge: f, JSON: flagJSON}
|
||
cmd.SetContext(context.WithValue(cmd.Context(), ctxKey{}, rc))
|
||
return nil
|
||
},
|
||
}
|
||
root.PersistentFlags().StringVar(&flagRepo, "repo", "", "OWNER/REPO,缺省从当前 git origin 推断")
|
||
root.PersistentFlags().StringVar(&flagHost, "host", "", "仓库 host(如 github.com 或 https://gitea.example.com),缺省从当前 git origin 推断")
|
||
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "JSON 输出(默认人类可读)")
|
||
root.PersistentFlags().StringVar(&flagCwd, "cwd", ".", "repo 探测的工作目录")
|
||
|
||
root.AddCommand(buildIssueCmd())
|
||
root.AddCommand(buildPRCmd())
|
||
return root
|
||
}
|
||
|
||
// markerTypeToStateField maps a `spx issue marker --type` value to the state
|
||
// JSON field it mirrors. All three types share one incremental-update path;
|
||
// this table is the single source of truth — adding a type only touches it
|
||
// (plus markerTypesHint below, which is what the user sees).
|
||
var markerTypeToStateField = map[string]string{
|
||
"spec": "specFile",
|
||
"plan": "planFile",
|
||
"prototype": "prototypeFile",
|
||
}
|
||
|
||
// markerTypesHint is the human-readable enumeration used in help text and the
|
||
// "bad --type" error, kept next to the table so the two can't drift.
|
||
const markerTypesHint = "spec、plan 或 prototype"
|
||
|
||
func buildIssueCmd() *cobra.Command {
|
||
issue := &cobra.Command{
|
||
Use: "issue",
|
||
Short: "工单操作",
|
||
}
|
||
|
||
create := &cobra.Command{
|
||
Use: "create",
|
||
Short: "创建一个新工单并可选附加 spec/plan marker 与 state JSON",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if strings.TrimSpace(icTitle) == "" {
|
||
return fmt.Errorf("--title 不能为空")
|
||
}
|
||
body, err := resolveBody(icBody, icBodyFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if icSpec != "" {
|
||
body = marker.UpsertMarker(body, "spec", icSpec)
|
||
}
|
||
if icPlan != "" {
|
||
body = marker.UpsertMarker(body, "plan", icPlan)
|
||
}
|
||
// Build the merged state map (state-json/state-file as base, then
|
||
// fold in --spec / --plan as specFile / planFile so the kanban can
|
||
// read them from state JSON, which is the loader's source of truth
|
||
// — see superpowers-vscode/src/gitea/issueLoader.ts).
|
||
stateBytes, err := resolveStatePayload(icStateJSON, icStateFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
mergedState := map[string]any{}
|
||
if stateBytes != nil {
|
||
if err := json.Unmarshal(stateBytes, &mergedState); err != nil {
|
||
return fmt.Errorf("解析 --state-json/--state-file 内容失败: %w", err)
|
||
}
|
||
}
|
||
if icSpec != "" {
|
||
mergedState["specFile"] = icSpec
|
||
}
|
||
if icPlan != "" {
|
||
mergedState["planFile"] = icPlan
|
||
}
|
||
// Validate the merged blob BEFORE hitting the API so e.g. a bad
|
||
// --spec path fails fast instead of leaving an issue half-built.
|
||
// schema's specFile/planFile pattern doubles as input validation.
|
||
hasState := len(mergedState) > 0
|
||
if hasState {
|
||
if err := state.ValidateValue(mergedState); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
rc := fromCtx(cmd)
|
||
// 创建即认领:assignee=创建人,是插件 webhook 门禁的路由依据。
|
||
// 身份解析失败不阻塞创建(门禁侧有本地终端兜底)。
|
||
var assignees []string
|
||
if login, loginErr := rc.Forge.CurrentUserLogin(); loginErr == nil && login != "" {
|
||
assignees = []string{login}
|
||
} else if loginErr != nil {
|
||
fmt.Fprintf(os.Stderr, "警告: 解析当前用户失败,工单将无 assignee: %v\n", loginErr)
|
||
}
|
||
issue, err := rc.Forge.CreateIssue(icTitle, body, assignees)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// --body-file is a one-shot handoff. Remove it now that the issue
|
||
// exists, so a stale file can't mislead a later brainstorm session
|
||
// into the wrong topic.
|
||
if icBodyFile != "" {
|
||
_ = os.Remove(icBodyFile)
|
||
}
|
||
var statePayload any
|
||
if hasState {
|
||
comment, err := state.FormatComment(mergedState)
|
||
if err != nil {
|
||
return fmt.Errorf("工单已建 (#%d),但%w", issue.Number, err)
|
||
}
|
||
if _, postErr := rc.Forge.CreateIssueComment(issue.Number, comment); postErr != nil {
|
||
return fmt.Errorf("工单已建 (#%d),但写入 state JSON comment 失败: %w", issue.Number, postErr)
|
||
}
|
||
statePayload = mergedState
|
||
}
|
||
if rc.JSON {
|
||
return emitJSON(cmd.OutOrStdout(), map[string]any{
|
||
"number": issue.Number,
|
||
"html_url": issue.HTMLURL,
|
||
"state": statePayload,
|
||
})
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "#%d\n", issue.Number)
|
||
return nil
|
||
},
|
||
}
|
||
create.Flags().StringVar(&icTitle, "title", "", "工单标题(必填)")
|
||
create.Flags().StringVar(&icBody, "body", "", "工单正文(与 --body-file 二选一)")
|
||
create.Flags().StringVar(&icBodyFile, "body-file", "", "从文件读取工单正文")
|
||
create.Flags().StringVar(&icSpec, "spec", "", "在 body 追加 spx:spec marker,并把 specFile 同步合并进 state JSON comment")
|
||
create.Flags().StringVar(&icPlan, "plan", "", "在 body 追加 spx:plan marker,并把 planFile 同步合并进 state JSON comment")
|
||
create.Flags().StringVar(&icStateJSON, "state-json", "", "state JSON 字符串(建工单后写成一条 state 评论;与 --state-file 二选一)")
|
||
create.Flags().StringVar(&icStateFile, "state-file", "", "从文件读取 state JSON")
|
||
_ = create.MarkFlagRequired("title")
|
||
|
||
markerCmd := &cobra.Command{
|
||
Use: "marker",
|
||
Short: "增量更新现有工单的 spec/plan/prototype marker,并同步合并 specFile/planFile/prototypeFile 进 state JSON",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if imIssue <= 0 {
|
||
return fmt.Errorf("--issue 必须是正整数")
|
||
}
|
||
fieldName, ok := markerTypeToStateField[imType]
|
||
if !ok {
|
||
return fmt.Errorf("--type 必须是 %s,得到 %q", markerTypesHint, imType)
|
||
}
|
||
// Fail fast on a bad path before we touch the network — the
|
||
// schema's pattern for this field doubles as input check.
|
||
patch := map[string]any{fieldName: imValue}
|
||
if err := state.ValidateValue(patch); err != nil {
|
||
return err
|
||
}
|
||
rc := fromCtx(cmd)
|
||
cur, err := rc.Forge.GetIssue(imIssue)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
newBody := marker.UpsertMarker(cur.Body, imType, imValue)
|
||
if newBody != cur.Body {
|
||
if err := rc.Forge.UpdateIssueBody(imIssue, newBody); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// Now merge specFile/planFile into the state JSON comment so the
|
||
// kanban (which reads state JSON, not body markers) sees the same
|
||
// value. If this fails the marker is already written, so we warn
|
||
// and exit non-zero rather than panic — re-running spx fixes it.
|
||
merged, mergeErr := mergeAndPostState(rc, imIssue, patch)
|
||
if mergeErr != nil {
|
||
fmt.Fprintf(os.Stderr, "warning: marker 已写入 #%d body,但合并到 state JSON 失败: %v\n", imIssue, mergeErr)
|
||
return mergeErr
|
||
}
|
||
if rc.JSON {
|
||
return emitJSON(cmd.OutOrStdout(), map[string]any{
|
||
"number": imIssue,
|
||
"type": imType,
|
||
"value": imValue,
|
||
"state": merged,
|
||
})
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "已更新 #%d 的 %s marker → %s(state JSON 已同步合并 %s)\n", imIssue, imType, imValue, fieldName)
|
||
return nil
|
||
},
|
||
}
|
||
markerCmd.Flags().IntVar(&imIssue, "issue", 0, "工单编号(必填)")
|
||
markerCmd.Flags().StringVar(&imType, "type", "", "marker 类型:"+markerTypesHint+"(必填)")
|
||
markerCmd.Flags().StringVar(&imValue, "value", "", "marker 值(路径,必填,同步写入 state JSON 的 specFile/planFile/prototypeFile)")
|
||
_ = markerCmd.MarkFlagRequired("issue")
|
||
_ = markerCmd.MarkFlagRequired("type")
|
||
_ = markerCmd.MarkFlagRequired("value")
|
||
|
||
issue.AddCommand(create, markerCmd, buildIssueViewCmd(), buildIssueStateCmd())
|
||
return issue
|
||
}
|
||
|
||
// buildIssueViewCmd assembles `spx issue view`, which shows an issue and, with `--comments`, its discussion.
|
||
//
|
||
// State comments are machine bookkeeping, so they are left out of the comment list; `spx issue state get` reads them.
|
||
func buildIssueViewCmd() *cobra.Command {
|
||
view := &cobra.Command{
|
||
Use: "view",
|
||
Short: "查看工单:标题、正文、状态、作者、指派人、链接;--comments 附带评论(不含 state 评论)",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if ivIssue <= 0 {
|
||
return fmt.Errorf("--issue 必须是正整数")
|
||
}
|
||
rc := fromCtx(cmd)
|
||
is, err := rc.Forge.GetIssue(ivIssue)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var comments []forge.Comment
|
||
if ivComments {
|
||
all, err := rc.Forge.ListIssueComments(ivIssue)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
comments = withoutStateComments(all)
|
||
}
|
||
if rc.JSON {
|
||
if !ivComments {
|
||
return emitJSON(cmd.OutOrStdout(), is)
|
||
}
|
||
return emitJSON(cmd.OutOrStdout(), struct {
|
||
*forge.Issue
|
||
Comments []forge.Comment `json:"comments"`
|
||
}{is, comments})
|
||
}
|
||
printIssue(cmd.OutOrStdout(), is)
|
||
if ivComments {
|
||
printComments(cmd.OutOrStdout(), comments)
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
view.Flags().IntVar(&ivIssue, "issue", 0, "工单编号(必填)")
|
||
view.Flags().BoolVar(&ivComments, "comments", false, "附带评论(不含 state 评论)")
|
||
_ = view.MarkFlagRequired("issue")
|
||
return view
|
||
}
|
||
|
||
// withoutStateComments drops the comments that carry state JSON, keeping the human discussion in order.
|
||
//
|
||
// @return a non-nil slice, so `--json` prints `[]` rather than `null` when nothing is left
|
||
func withoutStateComments(all []forge.Comment) []forge.Comment {
|
||
out := make([]forge.Comment, 0, len(all))
|
||
for _, c := range all {
|
||
if _, isState := state.ParseComment(c.Body); !isState {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// printIssue writes the human-readable form of `spx issue view`.
|
||
func printIssue(w io.Writer, is *forge.Issue) {
|
||
assignees := "无"
|
||
if len(is.Assignees) > 0 {
|
||
assignees = strings.Join(is.Assignees, ", ")
|
||
}
|
||
fmt.Fprintf(w, "#%d %s\n", is.Number, is.Title)
|
||
fmt.Fprintf(w, "状态: %s\n", is.State)
|
||
fmt.Fprintf(w, "作者: %s\n", is.Author)
|
||
fmt.Fprintf(w, "指派: %s\n", assignees)
|
||
fmt.Fprintf(w, "链接: %s\n", is.HTMLURL)
|
||
fmt.Fprintf(w, "\n%s\n", bodyOrPlaceholder(is.Body))
|
||
}
|
||
|
||
// printComments writes the comment list of `spx issue view --comments`, one header line per comment.
|
||
func printComments(w io.Writer, comments []forge.Comment) {
|
||
fmt.Fprintf(w, "\n评论 (%d):\n", len(comments))
|
||
for _, c := range comments {
|
||
fmt.Fprintf(w, "\n--- %s · %s ---\n%s\n", c.Author, c.CreatedAt, bodyOrPlaceholder(c.Body))
|
||
}
|
||
}
|
||
|
||
// bodyOrPlaceholder returns `body`, or a visible placeholder when it is blank, so empty text is not mistaken for missing output.
|
||
func bodyOrPlaceholder(body string) string {
|
||
if strings.TrimSpace(body) == "" {
|
||
return "(无正文)"
|
||
}
|
||
return strings.TrimRight(body, "\n")
|
||
}
|
||
|
||
// buildIssueStateCmd assembles the `spx issue state` subtree (get + merge).
|
||
//
|
||
// The newest state comment on an issue holds its state JSON (see the [state schema](vscode/schemas/state-json.schema.json)); [state.Latest]
|
||
// finds it from the tail. `get` reads it, `merge` shallow-merges new keys on top and posts the result as a new
|
||
// state comment in the hidden format of [state.FormatComment].
|
||
func buildIssueStateCmd() *cobra.Command {
|
||
stateCmd := &cobra.Command{
|
||
Use: "state",
|
||
Short: "读取/合并工单的 state JSON(持久化在最新一条 state 评论)",
|
||
}
|
||
|
||
get := &cobra.Command{
|
||
Use: "get",
|
||
Short: "读取工单最新一条 state 评论里的 state JSON",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if isgIssue <= 0 {
|
||
return fmt.Errorf("--issue 必须是正整数")
|
||
}
|
||
rc := fromCtx(cmd)
|
||
cur, err := lastStateMap(rc.Forge, isgIssue)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// `cur` may be an empty map; print `{}` in that case.
|
||
return emitState(cmd.OutOrStdout(), cur, rc.JSON)
|
||
},
|
||
}
|
||
get.Flags().IntVar(&isgIssue, "issue", 0, "工单编号(必填)")
|
||
_ = get.MarkFlagRequired("issue")
|
||
|
||
merge := &cobra.Command{
|
||
Use: "merge",
|
||
Short: "浅合并新 state JSON 到现有 state,并追加一条新的 state 评论",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if ismIssue <= 0 {
|
||
return fmt.Errorf("--issue 必须是正整数")
|
||
}
|
||
incomingBytes, err := resolveStatePayload(ismStateJSON, ismStateFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if incomingBytes == nil {
|
||
return fmt.Errorf("必须提供 --state-json 或 --state-file 之一")
|
||
}
|
||
// Validate the incoming payload BEFORE any network call so a
|
||
// malformed input fails fast (e.g. wrong enum) without surfacing
|
||
// confusing "issue not found" errors from the forge first.
|
||
if err := state.Validate(incomingBytes); err != nil {
|
||
return err
|
||
}
|
||
var incoming map[string]any
|
||
if err := json.Unmarshal(incomingBytes, &incoming); err != nil {
|
||
return fmt.Errorf("解析 --state-json/--state-file 内容失败: %w", err)
|
||
}
|
||
rc := fromCtx(cmd)
|
||
merged, err := mergeAndPostState(rc, ismIssue, incoming)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return emitState(cmd.OutOrStdout(), merged, rc.JSON)
|
||
},
|
||
}
|
||
merge.Flags().IntVar(&ismIssue, "issue", 0, "工单编号(必填)")
|
||
merge.Flags().StringVar(&ismStateJSON, "state-json", "", "state JSON 字符串(与 --state-file 二选一)")
|
||
merge.Flags().StringVar(&ismStateFile, "state-file", "", "从文件读取 state JSON")
|
||
_ = merge.MarkFlagRequired("issue")
|
||
|
||
stateCmd.AddCommand(get, merge)
|
||
return stateCmd
|
||
}
|
||
|
||
// mergeAndPostState shallow-merges patch into the issue's current state JSON
|
||
// (newest state comment), re-validates the result against the schema, and
|
||
// posts the merged blob as a new state comment. Returns the merged map for
|
||
// callers that want to echo it back. Shared by `issue marker` and
|
||
// `issue state merge` so the two paths can't drift.
|
||
func mergeAndPostState(rc *runtimeContext, issueNumber int, patch map[string]any) (map[string]any, error) {
|
||
cur, err := lastStateMap(rc.Forge, issueNumber)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for k, v := range patch {
|
||
cur[k] = v
|
||
}
|
||
// Re-validate the merged blob before persisting so we never poison the
|
||
// issue's comment trail with malformed state.
|
||
if err := state.ValidateValue(cur); err != nil {
|
||
return nil, err
|
||
}
|
||
comment, err := state.FormatComment(cur)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := rc.Forge.CreateIssueComment(issueNumber, comment); err != nil {
|
||
return nil, err
|
||
}
|
||
return cur, nil
|
||
}
|
||
|
||
// lastStateMap reads all comments on an issue and returns the state of the
|
||
// newest state comment, via [state.Latest]. Ordinary comments posted
|
||
// afterwards (cc chatter, review notes, the forge's PR auto-link) are skipped
|
||
// so they can't shadow real state and wipe history on the next merge.
|
||
// Empty issue or no state comment yields an empty (non-nil) map — never an error.
|
||
func lastStateMap(f forge.Forge, number int) (map[string]any, error) {
|
||
comments, err := f.ListIssueComments(number)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
bodies := make([]string, len(comments))
|
||
for i, c := range comments {
|
||
bodies[i] = c.Body
|
||
}
|
||
return state.Latest(bodies), nil
|
||
}
|
||
|
||
// emitState prints a state map for `spx issue state`: one line by default, indented when `pretty` is set.
|
||
func emitState(w io.Writer, s map[string]any, pretty bool) error {
|
||
enc := json.NewEncoder(w)
|
||
enc.SetEscapeHTML(false)
|
||
if pretty {
|
||
enc.SetIndent("", " ")
|
||
}
|
||
return enc.Encode(s)
|
||
}
|
||
|
||
// resolveStatePayload picks the state JSON bytes from --state-json /
|
||
// --state-file. Returns (nil, nil) when neither is supplied so callers can
|
||
// branch on "no state requested". Both set is an error.
|
||
func resolveStatePayload(inline, file string) ([]byte, error) {
|
||
if inline != "" && file != "" {
|
||
return nil, fmt.Errorf("--state-json 和 --state-file 不能同时指定")
|
||
}
|
||
if inline != "" {
|
||
return []byte(inline), nil
|
||
}
|
||
if file != "" {
|
||
data, err := os.ReadFile(file)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 state 文件 %s 失败: %w", file, err)
|
||
}
|
||
return data, nil
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func buildPRCmd() *cobra.Command {
|
||
pr := &cobra.Command{
|
||
Use: "pr",
|
||
Short: "PR 操作",
|
||
}
|
||
rc := &cobra.Command{
|
||
Use: "review-comment",
|
||
Short: "给 PR 发一条带 review marker 的评论",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if prcPR <= 0 {
|
||
return fmt.Errorf("--pr 必须是正整数")
|
||
}
|
||
body, err := resolveBody(prcBody, prcBodyFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
body = marker.PrependReviewMarker(body)
|
||
rt := fromCtx(cmd)
|
||
cm, err := rt.Forge.CreateIssueComment(prcPR, body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if rt.JSON {
|
||
return emitJSON(cmd.OutOrStdout(), map[string]any{
|
||
"id": cm.ID,
|
||
"html_url": cm.HTMLURL,
|
||
})
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "已发评论到 PR #%d\n", prcPR)
|
||
return nil
|
||
},
|
||
}
|
||
rc.Flags().IntVar(&prcPR, "pr", 0, "PR 编号(必填)")
|
||
rc.Flags().StringVar(&prcBody, "body", "", "评论正文(与 --body-file 二选一)")
|
||
rc.Flags().StringVar(&prcBodyFile, "body-file", "", "从文件读取评论正文")
|
||
_ = rc.MarkFlagRequired("pr")
|
||
|
||
view := &cobra.Command{
|
||
Use: "view",
|
||
Short: "查看 PR:标题、正文、状态、是否已合并、head/base 分支与 SHA、作者、链接",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if pvPR <= 0 {
|
||
return fmt.Errorf("--pr 必须是正整数")
|
||
}
|
||
rt := fromCtx(cmd)
|
||
p, err := rt.Forge.GetPullRequest(pvPR)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if rt.JSON {
|
||
return emitJSON(cmd.OutOrStdout(), p)
|
||
}
|
||
printPullRequest(cmd.OutOrStdout(), p)
|
||
return nil
|
||
},
|
||
}
|
||
view.Flags().IntVar(&pvPR, "pr", 0, "PR 编号(必填)")
|
||
_ = view.MarkFlagRequired("pr")
|
||
|
||
create := &cobra.Command{
|
||
Use: "create",
|
||
Short: "从 --head 分支向 --base 分支(缺省为仓库默认分支)发起 PR",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if strings.TrimSpace(pcTitle) == "" {
|
||
return fmt.Errorf("--title 不能为空")
|
||
}
|
||
if strings.TrimSpace(pcHead) == "" {
|
||
return fmt.Errorf("--head 不能为空")
|
||
}
|
||
if pcBody == "" && pcBodyFile == "" {
|
||
return fmt.Errorf("必须提供 --body 或 --body-file 之一")
|
||
}
|
||
body, err := resolveBody(pcBody, pcBodyFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rt := fromCtx(cmd)
|
||
base := pcBase
|
||
if base == "" {
|
||
if base, err = rt.Forge.DefaultBranch(); err != nil {
|
||
return fmt.Errorf("没给 --base,查仓库默认分支失败: %w", err)
|
||
}
|
||
}
|
||
p, err := rt.Forge.CreatePullRequest(forge.NewPullRequest{Title: pcTitle, Body: body, Head: pcHead, Base: base})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if rt.JSON {
|
||
return emitJSON(cmd.OutOrStdout(), map[string]any{
|
||
"number": p.Number,
|
||
"html_url": p.HTMLURL,
|
||
})
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "#%d %s\n", p.Number, p.HTMLURL)
|
||
return nil
|
||
},
|
||
}
|
||
create.Flags().StringVar(&pcTitle, "title", "", "PR 标题(必填)")
|
||
create.Flags().StringVar(&pcBody, "body", "", "PR 正文(与 --body-file 二选一,必须给一个)")
|
||
create.Flags().StringVar(&pcBodyFile, "body-file", "", "从文件读取 PR 正文")
|
||
create.Flags().StringVar(&pcHead, "head", "", "要合并进来的分支(必填)")
|
||
create.Flags().StringVar(&pcBase, "base", "", "合并到的目标分支,缺省取仓库默认分支")
|
||
_ = create.MarkFlagRequired("title")
|
||
_ = create.MarkFlagRequired("head")
|
||
|
||
pr.AddCommand(rc, view, create)
|
||
return pr
|
||
}
|
||
|
||
// printPullRequest writes the human-readable form of `spx pr view`.
|
||
func printPullRequest(w io.Writer, p *forge.PullRequest) {
|
||
merged := "未合并"
|
||
if p.Merged {
|
||
merged = "已合并"
|
||
}
|
||
fmt.Fprintf(w, "#%d %s\n", p.Number, p.Title)
|
||
fmt.Fprintf(w, "状态: %s(%s)\n", p.State, merged)
|
||
fmt.Fprintf(w, "作者: %s\n", p.Author)
|
||
fmt.Fprintf(w, "分支: %s (%s) → %s (%s)\n", p.Head.Branch, p.Head.SHA, p.Base.Branch, p.Base.SHA)
|
||
fmt.Fprintf(w, "链接: %s\n", p.HTMLURL)
|
||
fmt.Fprintf(w, "\n%s\n", bodyOrPlaceholder(p.Body))
|
||
}
|
||
|
||
// resolveBody picks the body text from --body / --body-file. Both empty is OK
|
||
// (callers may then prepend markers); both set is an error.
|
||
func resolveBody(body, file string) (string, error) {
|
||
if body != "" && file != "" {
|
||
return "", fmt.Errorf("--body 和 --body-file 不能同时指定")
|
||
}
|
||
if file != "" {
|
||
data, err := os.ReadFile(file)
|
||
if err != nil {
|
||
return "", fmt.Errorf("读取 body 文件 %s 失败: %w", file, err)
|
||
}
|
||
return string(data), nil
|
||
}
|
||
return body, nil
|
||
}
|
||
|
||
// openForge connects to the repo the global flags and git origin point at, on the forge its host names.
|
||
func openForge() (forge.Forge, error) {
|
||
o, err := resolveOrigin()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return forge.Open(o, forge.SystemEnv())
|
||
}
|
||
|
||
// resolveOrigin works out the repo to act on from `git remote get-url origin`, with `--host` and `--repo` overriding
|
||
// its parts; git is not consulted when both flags are given.
|
||
func resolveOrigin() (repo.Origin, error) {
|
||
var o repo.Origin
|
||
if flagHost == "" || flagRepo == "" {
|
||
detected, err := repo.DetectOrigin(flagCwd)
|
||
if err != nil {
|
||
return repo.Origin{}, fmt.Errorf("从 git origin 推断仓库失败,请用 --host 和 --repo 显式指定: %w", err)
|
||
}
|
||
o = detected
|
||
}
|
||
if flagHost != "" {
|
||
host, webURL, err := repo.ParseHost(flagHost)
|
||
if err != nil {
|
||
return repo.Origin{}, err
|
||
}
|
||
o.Host, o.WebURL = host, webURL
|
||
}
|
||
if flagRepo != "" {
|
||
owner, name, ok := strings.Cut(flagRepo, "/")
|
||
if !ok || owner == "" || name == "" {
|
||
return repo.Origin{}, fmt.Errorf("--repo 必须是 OWNER/REPO 形式,得到 %q", flagRepo)
|
||
}
|
||
o.Owner, o.Repo = owner, name
|
||
}
|
||
return o, nil
|
||
}
|
||
|
||
// fromCtx extracts the runtimeContext set up by PersistentPreRunE.
|
||
func fromCtx(cmd *cobra.Command) *runtimeContext {
|
||
v := cmd.Context().Value(ctxKey{})
|
||
if v == nil {
|
||
// Should be unreachable: PersistentPreRunE always populates it.
|
||
panic("runtimeContext missing from cobra context")
|
||
}
|
||
return v.(*runtimeContext)
|
||
}
|
||
|
||
// emitJSON writes v as a single-line JSON object to w.
|
||
func emitJSON(w io.Writer, v any) error {
|
||
enc := json.NewEncoder(w)
|
||
enc.SetEscapeHTML(false)
|
||
return enc.Encode(v)
|
||
}
|