✨ feat(spx): 按 git origin 支持 GitHub 仓库,state 评论改成隐藏注释格式,新增 issue view / pr view / pr create
This commit is contained in:
1 parent
64d5961b62
commit
c90d84d7f2
17 files changed
+2683
-400
No files matched your search
+297
-176
@@ -1,35 +1,35 @@
|
||||
// Command spx is a small Gitea wrapper for the superpowers-vscode workflow.
|
||||
// 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 tea-config-based credential discovery.
|
||||
// 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/gitea"
|
||||
"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"
|
||||
"github.com/cruldra/superpowers-vscode/cli/internal/tea"
|
||||
)
|
||||
|
||||
// runtimeContext is the bag of resolved configuration we hand to subcommands
|
||||
// via cobra's context. Filled in by the root PersistentPreRunE.
|
||||
type runtimeContext struct {
|
||||
Client *gitea.Client
|
||||
Owner string
|
||||
Repo string
|
||||
Host string
|
||||
JSON bool
|
||||
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).
|
||||
@@ -70,6 +70,12 @@ var (
|
||||
ismStateFile string
|
||||
)
|
||||
|
||||
// issue view flags.
|
||||
var (
|
||||
ivIssue int
|
||||
ivComments bool
|
||||
)
|
||||
|
||||
// pr review-comment flags.
|
||||
var (
|
||||
prcPR int
|
||||
@@ -77,32 +83,49 @@ var (
|
||||
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()
|
||||
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() *cobra.Command {
|
||||
func buildRootCmd(open forgeOpener) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "spx",
|
||||
Short: "superpowers-vscode 工作流 CLI(Gitea 薄包装)",
|
||||
Long: "spx 为 cc/codex agent 在 superpowers-vscode 工作流里提供确定性的 Gitea 操作。\n复用 ~/.config/tea/config.yml 拿 host + token。",
|
||||
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 {
|
||||
rc, err := resolveRuntime()
|
||||
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", "", "Gitea host URL,缺省从 tea config 默认 login 取")
|
||||
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 探测的工作目录")
|
||||
|
||||
@@ -128,7 +151,7 @@ const markerTypesHint = "spec、plan 或 prototype"
|
||||
func buildIssueCmd() *cobra.Command {
|
||||
issue := &cobra.Command{
|
||||
Use: "issue",
|
||||
Short: "Gitea issue 操作",
|
||||
Short: "工单操作",
|
||||
}
|
||||
|
||||
create := &cobra.Command{
|
||||
@@ -181,12 +204,12 @@ func buildIssueCmd() *cobra.Command {
|
||||
// 创建即认领:assignee=创建人,是插件 webhook 门禁的路由依据。
|
||||
// 身份解析失败不阻塞创建(门禁侧有本地终端兜底)。
|
||||
var assignees []string
|
||||
if login, loginErr := rc.Client.CurrentUserLogin(); loginErr == nil && login != "" {
|
||||
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.Client.CreateIssue(rc.Owner, rc.Repo, icTitle, body, assignees)
|
||||
issue, err := rc.Forge.CreateIssue(icTitle, body, assignees)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -198,17 +221,17 @@ func buildIssueCmd() *cobra.Command {
|
||||
}
|
||||
var statePayload any
|
||||
if hasState {
|
||||
mergedBytes, err := json.Marshal(mergedState)
|
||||
comment, err := state.FormatComment(mergedState)
|
||||
if err != nil {
|
||||
return fmt.Errorf("工单已建 (#%d),但序列化 state JSON 失败: %w", issue.Number, err)
|
||||
return fmt.Errorf("工单已建 (#%d),但%w", issue.Number, err)
|
||||
}
|
||||
if _, postErr := rc.Client.CreateIssueComment(rc.Owner, rc.Repo, issue.Number, string(mergedBytes)); postErr != nil {
|
||||
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(map[string]any{
|
||||
return emitJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"number": issue.Number,
|
||||
"html_url": issue.HTMLURL,
|
||||
"state": statePayload,
|
||||
@@ -223,7 +246,7 @@ func buildIssueCmd() *cobra.Command {
|
||||
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 字符串(建工单后写入最后一条 comment;与 --state-file 二选一)")
|
||||
create.Flags().StringVar(&icStateJSON, "state-json", "", "state JSON 字符串(建工单后写成一条 state 评论;与 --state-file 二选一)")
|
||||
create.Flags().StringVar(&icStateFile, "state-file", "", "从文件读取 state JSON")
|
||||
_ = create.MarkFlagRequired("title")
|
||||
|
||||
@@ -245,13 +268,13 @@ func buildIssueCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
rc := fromCtx(cmd)
|
||||
cur, err := rc.Client.GetIssue(rc.Owner, rc.Repo, imIssue)
|
||||
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.Client.UpdateIssueBody(rc.Owner, rc.Repo, imIssue, newBody); err != nil {
|
||||
if err := rc.Forge.UpdateIssueBody(imIssue, newBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -265,7 +288,7 @@ func buildIssueCmd() *cobra.Command {
|
||||
return mergeErr
|
||||
}
|
||||
if rc.JSON {
|
||||
return emitJSON(map[string]any{
|
||||
return emitJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"number": imIssue,
|
||||
"type": imType,
|
||||
"value": imValue,
|
||||
@@ -283,42 +306,124 @@ func buildIssueCmd() *cobra.Command {
|
||||
_ = markerCmd.MarkFlagRequired("type")
|
||||
_ = markerCmd.MarkFlagRequired("value")
|
||||
|
||||
issue.AddCommand(create, markerCmd, buildIssueStateCmd())
|
||||
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 state JSON convention: the most recent state-JSON comment on an issue
|
||||
// holds a single JSON blob (see schemas/state-json.schema.json), located by
|
||||
// scanning comments from the tail for the first one carrying a known state
|
||||
// field. `get` reads it, `merge` does a shallow-merge write of new keys on top.
|
||||
// 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(持久化在最后一条 comment)",
|
||||
Short: "读取/合并工单的 state JSON(持久化在最新一条 state 评论)",
|
||||
}
|
||||
|
||||
get := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "读取工单最后一条 comment 里的 state JSON",
|
||||
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.Client, rc.Owner, rc.Repo, isgIssue)
|
||||
cur, err := lastStateMap(rc.Forge, isgIssue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// `cur` may be an empty map; print `{}` in that case.
|
||||
out := os.Stdout
|
||||
enc := json.NewEncoder(out)
|
||||
enc.SetEscapeHTML(false)
|
||||
if rc.JSON {
|
||||
enc.SetIndent("", " ")
|
||||
}
|
||||
return enc.Encode(cur)
|
||||
return emitState(cmd.OutOrStdout(), cur, rc.JSON)
|
||||
},
|
||||
}
|
||||
get.Flags().IntVar(&isgIssue, "issue", 0, "工单编号(必填)")
|
||||
@@ -326,7 +431,7 @@ func buildIssueStateCmd() *cobra.Command {
|
||||
|
||||
merge := &cobra.Command{
|
||||
Use: "merge",
|
||||
Short: "浅合并新 state JSON 到现有 state(最后一条 comment)",
|
||||
Short: "浅合并新 state JSON 到现有 state,并追加一条新的 state 评论",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if ismIssue <= 0 {
|
||||
return fmt.Errorf("--issue 必须是正整数")
|
||||
@@ -340,7 +445,7 @@ func buildIssueStateCmd() *cobra.Command {
|
||||
}
|
||||
// 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 Gitea first.
|
||||
// confusing "issue not found" errors from the forge first.
|
||||
if err := state.Validate(incomingBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -353,12 +458,7 @@ func buildIssueStateCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetEscapeHTML(false)
|
||||
if rc.JSON {
|
||||
enc.SetIndent("", " ")
|
||||
}
|
||||
return enc.Encode(merged)
|
||||
return emitState(cmd.OutOrStdout(), merged, rc.JSON)
|
||||
},
|
||||
}
|
||||
merge.Flags().IntVar(&ismIssue, "issue", 0, "工单编号(必填)")
|
||||
@@ -371,12 +471,12 @@ func buildIssueStateCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
// mergeAndPostState shallow-merges patch into the issue's current state JSON
|
||||
// (last comment), re-validates the result against the schema, and posts a new
|
||||
// comment with the merged blob. Returns the merged map for callers that want
|
||||
// to echo it back. Shared by `issue create --spec/--plan`, `issue marker`,
|
||||
// and `issue state merge` so the three paths can't drift.
|
||||
// (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.Client, rc.Owner, rc.Repo, issueNumber)
|
||||
cur, err := lastStateMap(rc.Forge, issueNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -388,86 +488,41 @@ func mergeAndPostState(rc *runtimeContext, issueNumber int, patch map[string]any
|
||||
if err := state.ValidateValue(cur); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mergedBytes, err := json.Marshal(cur)
|
||||
comment, err := state.FormatComment(cur)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化合并后的 state 失败: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
if _, err := rc.Client.CreateIssueComment(rc.Owner, rc.Repo, issueNumber, string(mergedBytes)); err != nil {
|
||||
if _, err := rc.Forge.CreateIssueComment(issueNumber, comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
// knownStateFields is the set of keys that mark a comment as a state JSON blob
|
||||
// rather than ordinary chatter. A comment counts as state only if it parses to
|
||||
// an object carrying at least one of these — mirrors the TypeScript loader so
|
||||
// the two stay in lockstep.
|
||||
var knownStateFields = map[string]struct{}{
|
||||
"column": {},
|
||||
"sessionId": {},
|
||||
"implementSessionId": {},
|
||||
"reviewSessionId": {},
|
||||
"testSessionId": {},
|
||||
"prototypeSessionId": {},
|
||||
"profilePath": {},
|
||||
"brainstormProfilePath": {},
|
||||
"testProfilePath": {},
|
||||
"prototypeProfilePath": {},
|
||||
"specFile": {},
|
||||
"planFile": {},
|
||||
"prototypeFile": {},
|
||||
"prDiffFile": {},
|
||||
"pr": {},
|
||||
"prMerged": {},
|
||||
"prMergedAt": {},
|
||||
"branch": {},
|
||||
"worktreePath": {},
|
||||
"implementStatus": {},
|
||||
"color": {},
|
||||
"autoReview": {},
|
||||
"reviewPassed": {},
|
||||
"handoffBundleKey": {},
|
||||
"handoffFrom": {},
|
||||
"handoffRequestFrom": {},
|
||||
"handoffRequestError": {},
|
||||
}
|
||||
|
||||
// parseStateComment returns the parsed map when body is a JSON object carrying
|
||||
// at least one known state field, or (nil, false) otherwise.
|
||||
func parseStateComment(body string) (map[string]any, bool) {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if trimmed == "" {
|
||||
return nil, false
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &m); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
for k := range m {
|
||||
if _, ok := knownStateFields[k]; ok {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// lastStateMap fetches all comments on an issue and returns the most recent
|
||||
// state JSON blob, found by scanning from the tail for the first comment that
|
||||
// parses as an object carrying a known state field. Ordinary text/JSON comments
|
||||
// inserted afterwards (cc chatter, review notes, Gitea's PR auto-link) are
|
||||
// skipped so they can't shadow real state and wipe history on the next merge.
|
||||
// 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(c *gitea.Client, owner, repo string, number int) (map[string]any, error) {
|
||||
comments, err := c.ListIssueComments(owner, repo, number)
|
||||
func lastStateMap(f forge.Forge, number int) (map[string]any, error) {
|
||||
comments, err := f.ListIssueComments(number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := len(comments) - 1; i >= 0; i-- {
|
||||
if m, ok := parseStateComment(comments[i].Body); ok {
|
||||
return m, nil
|
||||
}
|
||||
bodies := make([]string, len(comments))
|
||||
for i, c := range comments {
|
||||
bodies[i] = c.Body
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
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 /
|
||||
@@ -493,7 +548,7 @@ func resolveStatePayload(inline, file string) ([]byte, error) {
|
||||
func buildPRCmd() *cobra.Command {
|
||||
pr := &cobra.Command{
|
||||
Use: "pr",
|
||||
Short: "Gitea PR 操作",
|
||||
Short: "PR 操作",
|
||||
}
|
||||
rc := &cobra.Command{
|
||||
Use: "review-comment",
|
||||
@@ -508,12 +563,12 @@ func buildPRCmd() *cobra.Command {
|
||||
}
|
||||
body = marker.PrependReviewMarker(body)
|
||||
rt := fromCtx(cmd)
|
||||
cm, err := rt.Client.CreateIssueComment(rt.Owner, rt.Repo, prcPR, body)
|
||||
cm, err := rt.Forge.CreateIssueComment(prcPR, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.JSON {
|
||||
return emitJSON(map[string]any{
|
||||
return emitJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"id": cm.ID,
|
||||
"html_url": cm.HTMLURL,
|
||||
})
|
||||
@@ -527,10 +582,92 @@ func buildPRCmd() *cobra.Command {
|
||||
rc.Flags().StringVar(&prcBodyFile, "body-file", "", "从文件读取评论正文")
|
||||
_ = rc.MarkFlagRequired("pr")
|
||||
|
||||
pr.AddCommand(rc)
|
||||
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) {
|
||||
@@ -547,57 +684,41 @@ func resolveBody(body, file string) (string, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// resolveRuntime turns the global flags + tea config + git remote into a
|
||||
// ready-to-use Client and owner/repo pair.
|
||||
func resolveRuntime() (*runtimeContext, error) {
|
||||
host := flagHost
|
||||
var token string
|
||||
if host == "" {
|
||||
h, t, err := tea.LoadDefault()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, token = h, t
|
||||
} else {
|
||||
t, err := tea.LoadByHost(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token = t
|
||||
}
|
||||
// Fallback: token can come from env when tea stores it in a keyring
|
||||
// (newer tea releases) rather than in config.yml.
|
||||
if token == "" {
|
||||
if env := os.Getenv("GITEA_TOKEN"); env != "" {
|
||||
token = env
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("没有可用的 Gitea token:tea config 里 token 字段为空,且 GITEA_TOKEN 环境变量也没设置")
|
||||
// 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())
|
||||
}
|
||||
|
||||
owner, repoName := "", ""
|
||||
// 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 != "" {
|
||||
parts := strings.SplitN(flagRepo, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, fmt.Errorf("--repo 必须是 OWNER/REPO 形式,得到 %q", flagRepo)
|
||||
owner, name, ok := strings.Cut(flagRepo, "/")
|
||||
if !ok || owner == "" || name == "" {
|
||||
return repo.Origin{}, fmt.Errorf("--repo 必须是 OWNER/REPO 形式,得到 %q", flagRepo)
|
||||
}
|
||||
owner, repoName = parts[0], parts[1]
|
||||
} else {
|
||||
o, r, err := repo.DetectOwnerRepo(flagCwd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("自动推断 owner/repo 失败,请用 --repo 显式指定: %w", err)
|
||||
}
|
||||
owner, repoName = o, r
|
||||
o.Owner, o.Repo = owner, name
|
||||
}
|
||||
|
||||
return &runtimeContext{
|
||||
Client: gitea.New(host, token),
|
||||
Owner: owner,
|
||||
Repo: repoName,
|
||||
Host: host,
|
||||
JSON: flagJSON,
|
||||
}, nil
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// fromCtx extracts the runtimeContext set up by PersistentPreRunE.
|
||||
@@ -610,9 +731,9 @@ func fromCtx(cmd *cobra.Command) *runtimeContext {
|
||||
return v.(*runtimeContext)
|
||||
}
|
||||
|
||||
// emitJSON writes v as a single-line JSON object to stdout.
|
||||
func emitJSON(v any) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user