✨ 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)
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cruldra/superpowers-vscode/cli/internal/forge"
|
||||
"github.com/cruldra/superpowers-vscode/cli/internal/forge/forgetest"
|
||||
"github.com/cruldra/superpowers-vscode/cli/internal/repo"
|
||||
)
|
||||
|
||||
// flavors runs fn once against a fake Gitea and once against a fake GitHub.
|
||||
func flavors(t *testing.T, fn func(t *testing.T, srv *forgetest.Server)) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
flavor forgetest.Flavor
|
||||
}{{"gitea", forgetest.Gitea}, {"github", forgetest.GitHub}} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
fn(t, forgetest.NewServer(t, c.flavor))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// runSpx executes the spx command tree with args against srv and returns what it printed to stdout.
|
||||
func runSpx(t *testing.T, srv *forgetest.Server, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
root := buildRootCmd(func() (forge.Forge, error) {
|
||||
if srv.Flavor == forgetest.GitHub {
|
||||
return forge.NewGitHub(srv.URL, srv.Token, forgetest.Owner, forgetest.Repo), nil
|
||||
}
|
||||
return forge.NewGitea(srv.URL, srv.Token, forgetest.Owner, forgetest.Repo), nil
|
||||
})
|
||||
var out, errOut bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&errOut)
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, srv *forgetest.Server, args ...string) string {
|
||||
t.Helper()
|
||||
out, err := runSpx(t, srv, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("spx %s 失败: %v", strings.Join(args, " "), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, s string) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
t.Fatalf("输出不是 JSON 对象: %v\n%s", err, s)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func lastComment(t *testing.T, srv *forgetest.Server, n int) string {
|
||||
t.Helper()
|
||||
cs := srv.Comments(n)
|
||||
if len(cs) == 0 {
|
||||
t.Fatalf("#%d 没有评论", n)
|
||||
}
|
||||
return cs[len(cs)-1].Body
|
||||
}
|
||||
|
||||
func TestIssueStateMergeWritesHiddenStateComment(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
||||
srv.AddComment(n, "bob", `{"column":"todo","branch":"b1"}`)
|
||||
srv.AddComment(n, "bob", "LGTM")
|
||||
out := mustRun(t, srv, "issue", "state", "merge", "--issue", fmt.Sprint(n), "--state-json", `{"column":"review","handoffRequestError":"a --> b"}`)
|
||||
want := "<!-- spx-state\n{\"branch\":\"b1\",\"column\":\"review\",\"handoffRequestError\":\"a --\\u003e b\"}\n-->"
|
||||
if got := lastComment(t, srv, n); got != want {
|
||||
t.Fatalf("写出的 state 评论不对:\ngot %q\nwant %q", got, want)
|
||||
}
|
||||
m := decodeJSON(t, out)
|
||||
if m["column"] != "review" || m["branch"] != "b1" || m["handoffRequestError"] != "a --> b" {
|
||||
t.Fatalf("merge 输出不对: %v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueStateGetReadsEveryFormat(t *testing.T) {
|
||||
bodies := map[string]string{
|
||||
"隐藏注释": "<!-- spx-state\n{\"column\":\"review\"}\n-->",
|
||||
"旧 YouTrack 格式": "<!--spx-state-->\n{\"column\":\"review\"}",
|
||||
"旧裸 JSON": `{"column":"review"}`,
|
||||
}
|
||||
for name, body := range bodies {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
||||
srv.AddComment(n, "bob", `{"column":"todo"}`)
|
||||
srv.AddComment(n, "bob", body)
|
||||
srv.AddComment(n, "bob", "普通评论")
|
||||
out := mustRun(t, srv, "issue", "state", "get", "--issue", fmt.Sprint(n))
|
||||
if strings.TrimSpace(out) != `{"column":"review"}` {
|
||||
t.Fatalf("state get 输出不对: %q", out)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueStateGetFindsStateOnLaterGitHubPage(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.GitHub)
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
||||
srv.AddComment(n, "bob", `{"column":"todo"}`)
|
||||
for i := 0; i < 220; i++ {
|
||||
srv.AddComment(n, "bob", fmt.Sprintf("聊天 %d", i))
|
||||
}
|
||||
srv.AddComment(n, "bob", "<!-- spx-state\n{\"column\":\"done\"}\n-->")
|
||||
srv.AddComment(n, "bob", "收尾")
|
||||
out := mustRun(t, srv, "issue", "state", "get", "--issue", fmt.Sprint(n))
|
||||
if strings.TrimSpace(out) != `{"column":"done"}` {
|
||||
t.Fatalf("应读到第 3 页上的最新 state,得到 %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueStateGetEmptyIssuePrintsEmptyObject(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.GitHub)
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
||||
out := mustRun(t, srv, "issue", "state", "get", "--issue", fmt.Sprint(n))
|
||||
if strings.TrimSpace(out) != `{}` {
|
||||
t.Fatalf("没有 state 时应输出 {},得到 %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCreateAssignsSelfAndWritesHiddenState(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
out := mustRun(t, srv, "issue", "create", "--title", "新功能", "--body", "正文",
|
||||
"--spec", "docs/superpowers/specs/x.md", "--state-json", `{"column":"todo"}`, "--json")
|
||||
m := decodeJSON(t, out)
|
||||
n := int(m["number"].(float64))
|
||||
is, ok := srv.Issue(n)
|
||||
if !ok || is.Title != "新功能" || !reflect.DeepEqual(is.Assignees, []string{"alice"}) {
|
||||
t.Fatalf("建出的工单不对: %+v", is)
|
||||
}
|
||||
if !strings.Contains(is.Body, "<!-- spx:spec=docs/superpowers/specs/x.md -->") {
|
||||
t.Fatalf("正文应带 spec marker: %q", is.Body)
|
||||
}
|
||||
want := "<!-- spx-state\n{\"column\":\"todo\",\"specFile\":\"docs/superpowers/specs/x.md\"}\n-->"
|
||||
if got := lastComment(t, srv, n); got != want {
|
||||
t.Fatalf("state 评论不对:\ngot %q\nwant %q", got, want)
|
||||
}
|
||||
if m["html_url"] == "" || m["state"] == nil {
|
||||
t.Fatalf("--json 输出缺字段: %v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueMarkerUpdatesBodyAndMergesState(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "t", Body: "正文", Author: "bob"})
|
||||
srv.AddComment(n, "bob", "<!--spx-state-->\n{\"column\":\"in-progress\"}")
|
||||
mustRun(t, srv, "issue", "marker", "--issue", fmt.Sprint(n), "--type", "plan", "--value", "docs/superpowers/plans/p.md")
|
||||
is, _ := srv.Issue(n)
|
||||
if is.Body != "正文\n<!-- spx:plan=docs/superpowers/plans/p.md -->" {
|
||||
t.Fatalf("正文 marker 不对: %q", is.Body)
|
||||
}
|
||||
want := "<!-- spx-state\n{\"column\":\"in-progress\",\"planFile\":\"docs/superpowers/plans/p.md\"}\n-->"
|
||||
if got := lastComment(t, srv, n); got != want {
|
||||
t.Fatalf("state 评论不对:\ngot %q\nwant %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func seedViewIssue(srv *forgetest.Server) int {
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "登录失败", Body: "复现步骤", Author: "bob", Assignees: []string{"alice", "carol"}})
|
||||
srv.AddComment(n, "bob", "第一条")
|
||||
srv.AddComment(n, "alice", "<!-- spx-state\n{\"column\":\"review\"}\n-->")
|
||||
srv.AddComment(n, "alice", `{"column":"todo"}`)
|
||||
srv.AddComment(n, "alice", "<!--spx-state-->\n{\"column\":\"done\"}")
|
||||
srv.AddComment(n, "carol", "第二条")
|
||||
return n
|
||||
}
|
||||
|
||||
func TestIssueViewJSONExcludesStateComments(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := seedViewIssue(srv)
|
||||
m := decodeJSON(t, mustRun(t, srv, "issue", "view", "--issue", fmt.Sprint(n), "--comments", "--json"))
|
||||
if m["number"] != float64(n) || m["title"] != "登录失败" || m["body"] != "复现步骤" || m["state"] != "open" || m["author"] != "bob" {
|
||||
t.Fatalf("工单字段不对: %v", m)
|
||||
}
|
||||
if !reflect.DeepEqual(m["assignees"], []any{"alice", "carol"}) {
|
||||
t.Fatalf("assignees 不对: %v", m["assignees"])
|
||||
}
|
||||
if !strings.HasSuffix(fmt.Sprint(m["html_url"]), fmt.Sprintf("/issues/%d", n)) {
|
||||
t.Fatalf("html_url 不对: %v", m["html_url"])
|
||||
}
|
||||
comments, _ := m["comments"].([]any)
|
||||
var got []string
|
||||
for _, c := range comments {
|
||||
cm := c.(map[string]any)
|
||||
got = append(got, fmt.Sprint(cm["author"], ":", cm["body"]))
|
||||
if cm["created_at"] == "" || cm["html_url"] == "" {
|
||||
t.Fatalf("评论缺字段: %v", cm)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{"bob:第一条", "carol:第二条"}) {
|
||||
t.Fatalf("应排除 3 条 state 评论,得到 %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueViewWithoutCommentsFlagOmitsComments(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.Gitea)
|
||||
n := seedViewIssue(srv)
|
||||
m := decodeJSON(t, mustRun(t, srv, "issue", "view", "--issue", fmt.Sprint(n), "--json"))
|
||||
if _, ok := m["comments"]; ok {
|
||||
t.Fatalf("不带 --comments 时不应输出 comments: %v", m)
|
||||
}
|
||||
for _, r := range srv.Requests() {
|
||||
if strings.Contains(r, "/comments") {
|
||||
t.Fatalf("不带 --comments 时不应拉评论: %s", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueViewHumanOutput(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.GitHub)
|
||||
n := seedViewIssue(srv)
|
||||
out := mustRun(t, srv, "issue", "view", "--issue", fmt.Sprint(n), "--comments")
|
||||
for _, want := range []string{fmt.Sprintf("#%d 登录失败", n), "open", "bob", "alice, carol", "复现步骤", "第一条", "第二条"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("输出应包含 %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "spx-state") || strings.Contains(out, `"column"`) {
|
||||
t.Fatalf("输出不应包含 state 评论:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRViewJSON(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := srv.AddPull(forgetest.Pull{
|
||||
Title: "加功能", Body: "说明", State: "closed", Merged: true, Author: "bob",
|
||||
HeadBranch: "feat/x", HeadSHA: "aaa111", BaseBranch: "main", BaseSHA: "bbb222",
|
||||
})
|
||||
m := decodeJSON(t, mustRun(t, srv, "pr", "view", "--pr", fmt.Sprint(n), "--json"))
|
||||
want := map[string]any{
|
||||
"number": float64(n), "title": "加功能", "body": "说明", "state": "closed", "merged": true, "author": "bob",
|
||||
"head": map[string]any{"branch": "feat/x", "sha": "aaa111"},
|
||||
"base": map[string]any{"branch": "main", "sha": "bbb222"},
|
||||
}
|
||||
for k, v := range want {
|
||||
if !reflect.DeepEqual(m[k], v) {
|
||||
t.Fatalf("pr view 字段 %s = %v, want %v", k, m[k], v)
|
||||
}
|
||||
}
|
||||
if m["html_url"] == "" || m["html_url"] == nil {
|
||||
t.Fatalf("pr view 缺 html_url: %v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPRViewHumanOutput(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.Gitea)
|
||||
n := srv.AddPull(forgetest.Pull{Title: "加功能", Body: "说明", Author: "bob", HeadBranch: "feat/x", HeadSHA: "aaa111", BaseBranch: "main", BaseSHA: "bbb222"})
|
||||
out := mustRun(t, srv, "pr", "view", "--pr", fmt.Sprint(n))
|
||||
for _, want := range []string{fmt.Sprintf("#%d 加功能", n), "feat/x", "aaa111", "main", "bbb222", "未合并", "说明"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("输出应包含 %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCreateDefaultsBaseToRepoDefaultBranch(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
srv.DefaultBranch = "trunk"
|
||||
m := decodeJSON(t, mustRun(t, srv, "pr", "create", "--title", "T", "--body", "B", "--head", "feat/y", "--json"))
|
||||
n := int(m["number"].(float64))
|
||||
p, ok := srv.Pull(n)
|
||||
if !ok || p.Title != "T" || p.Body != "B" || p.HeadBranch != "feat/y" || p.BaseBranch != "trunk" {
|
||||
t.Fatalf("建出的 PR 不对: %+v", p)
|
||||
}
|
||||
if !strings.HasSuffix(fmt.Sprint(m["html_url"]), fmt.Sprint(n)) {
|
||||
t.Fatalf("输出应带 PR 链接: %v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPRCreateHonorsBaseAndBodyFile(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.GitHub)
|
||||
bodyFile := filepath.Join(t.TempDir(), "body.md")
|
||||
if err := os.WriteFile(bodyFile, []byte("文件正文"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := mustRun(t, srv, "pr", "create", "--title", "T", "--body-file", bodyFile, "--head", "feat/z", "--base", "release")
|
||||
p, ok := srv.Pull(1)
|
||||
if !ok || p.Body != "文件正文" || p.BaseBranch != "release" {
|
||||
t.Fatalf("建出的 PR 不对: %+v", p)
|
||||
}
|
||||
if strings.TrimSpace(out) != fmt.Sprintf("#1 %s/acme/widgets/pull/1", srv.URL) {
|
||||
t.Fatalf("人类可读输出应是编号加链接,得到 %q", out)
|
||||
}
|
||||
if _, err := os.Stat(bodyFile); err != nil {
|
||||
t.Fatalf("pr create 不应删除 --body-file: %v", err)
|
||||
}
|
||||
for _, r := range srv.Requests() {
|
||||
if strings.HasPrefix(r, "GET /repos/acme/widgets ") || r == "GET /repos/acme/widgets" {
|
||||
t.Fatalf("显式 --base 时不应再查默认分支: %v", srv.Requests())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCreateRequiresBody(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.GitHub)
|
||||
if _, err := runSpx(t, srv, "pr", "create", "--title", "T", "--head", "feat/z"); err == nil {
|
||||
t.Fatal("没有 --body/--body-file 时应报错")
|
||||
}
|
||||
if len(srv.Requests()) != 0 {
|
||||
t.Fatalf("参数不全时不应发请求: %v", srv.Requests())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewCommentUsesIssueCommentsEndpoint(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := srv.AddPull(forgetest.Pull{Title: "t", Author: "bob", HeadBranch: "h", BaseBranch: "main"})
|
||||
mustRun(t, srv, "pr", "review-comment", "--pr", fmt.Sprint(n), "--body", "审查意见")
|
||||
if got := lastComment(t, srv, n); got != "<!-- spx:review=1 -->\n\n审查意见" {
|
||||
t.Fatalf("评论正文不对: %q", got)
|
||||
}
|
||||
prefix := ""
|
||||
if srv.Flavor == forgetest.Gitea {
|
||||
prefix = "/api/v1"
|
||||
}
|
||||
reqs := srv.Requests()
|
||||
want := fmt.Sprintf("POST %s/repos/acme/widgets/issues/%d/comments", prefix, n)
|
||||
if reqs[len(reqs)-1] != want {
|
||||
t.Fatalf("应走 issues comments 接口,实际 %s", reqs[len(reqs)-1])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func gitRepoWithOrigin(t *testing.T, remote string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", remote}} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v 失败: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestResolveOriginFromGitRemoteAndOverrides(t *testing.T) {
|
||||
dir := gitRepoWithOrigin(t, "git@github.com:acme/widgets.git")
|
||||
cases := []struct {
|
||||
name, host, repoFlag string
|
||||
want repo.Origin
|
||||
}{
|
||||
{"只看 origin", "", "", repo.Origin{Host: "github.com", WebURL: "https://github.com", Owner: "acme", Repo: "widgets"}},
|
||||
{"覆盖 host", "https://gitea.example.com", "", repo.Origin{Host: "gitea.example.com", WebURL: "https://gitea.example.com", Owner: "acme", Repo: "widgets"}},
|
||||
{"覆盖 repo", "", "other/thing", repo.Origin{Host: "github.com", WebURL: "https://github.com", Owner: "other", Repo: "thing"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
flagCwd, flagHost, flagRepo = dir, c.host, c.repoFlag
|
||||
got, err := resolveOrigin()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveOrigin 出错: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("got %+v want %+v", got, c.want)
|
||||
}
|
||||
if forge.KindForHost(got.Host) == forge.KindGitHub != (c.want.Host == "github.com") {
|
||||
t.Fatalf("forge 选择不对: host=%s", got.Host)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOriginWithHostAndRepoSkipsGit(t *testing.T) {
|
||||
flagCwd, flagHost, flagRepo = t.TempDir(), "github.com", "acme/widgets"
|
||||
got, err := resolveOrigin()
|
||||
if err != nil {
|
||||
t.Fatalf("同时给 --host 和 --repo 时不应依赖 git: %v", err)
|
||||
}
|
||||
if got.Host != "github.com" || got.Owner != "acme" || got.Repo != "widgets" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOriginRejectsBadRepoFlag(t *testing.T) {
|
||||
flagCwd, flagHost, flagRepo = t.TempDir(), "github.com", "no-slash"
|
||||
if _, err := resolveOrigin(); err == nil {
|
||||
t.Fatal("--repo 不是 OWNER/REPO 时应报错")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user