Files
superwork/vscode/cli/cmd/spx/main.go
T

740 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(token 取自 ~/.config/tea/config.yml 里同 host 的 login,回退 GITEA_TOKEN)。",
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)
}