diff --git a/vscode/cli/README.md b/vscode/cli/README.md index d221b34..f42f889 100644 --- a/vscode/cli/README.md +++ b/vscode/cli/README.md @@ -1,6 +1,6 @@ # spx -superpowers-vscode 工作流 CLI 工具,给 cc/codex agent 用的 Gitea 操作薄包装。 +superpowers-vscode 工作流 CLI 工具,给 cc/codex agent 用的工单 / PR 操作薄包装,Gitea 和 GitHub 仓库用同一套命令。 ## 安装 @@ -9,11 +9,16 @@ make install # 把 $HOME/.local/bin 加进 PATH ``` -## 前置 +## 仓库与凭据 -需要先用 [`tea`](https://gitea.com/gitea/tea) 登录过(`~/.config/tea/config.yml` 有默认 login)。 +spx 读 `git remote get-url origin`,按 host 决定连哪种仓库(`--host` / `--repo` 可覆盖): -如果 tea 用 keyring 存 token(v0.10+ 默认行为),可以另外设环境变量 `GITEA_TOKEN` 作为回退: +| origin host | 仓库 | token 来源 | +| --- | --- | --- | +| `github.com` | GitHub(API `https://api.github.com`) | `gh auth token --hostname github.com`;取不到就先运行 `gh auth login` | +| 其它任何 host | Gitea(API `/api/v1`) | [`tea`](https://gitea.com/gitea/tea) 配置 `~/.config/tea/config.yml` 里 URL 与 origin 同 host 的 login;没有该 login 或 token 为空时回退环境变量 `GITEA_TOKEN` | + +tea 用 keyring 存 token(v0.10+ 默认行为)时,可以设 `GITEA_TOKEN` 作为回退: ``` export GITEA_TOKEN=... @@ -24,13 +29,23 @@ export GITEA_TOKEN=... ``` spx issue create --title "新功能 X" --spec docs/specs/x.md --plan docs/plans/x.md spx issue marker --issue 79 --type plan --value docs/plans/issue-79/plan.md +spx issue view --issue 79 --comments +spx issue state get --issue 79 +spx issue state merge --issue 79 --state-json '{"column":"review"}' +spx pr view --pr 73 +spx pr create --title "修复 X" --body-file /tmp/pr.md --head feature/x [--base main] spx pr review-comment --pr 73 --body "审查意见: ..." ``` +- `issue view` 输出编号、标题、正文、状态、作者、指派人、链接;`--comments` 附带评论,但不含 state 评论。 +- `pr view` 输出编号、标题、正文、状态、是否已合并、head/base 的分支与 SHA、作者、链接。 +- `pr create` 的 `--body` / `--body-file` 必须给一个;不给 `--base` 时取仓库默认分支。输出新 PR 的编号和链接。 +- `pr review-comment` 在两种仓库上都走 issue 评论接口(PR 与工单共用编号)。 + 全局 flag: - `--repo OWNER/REPO` 默认从当前 git origin 推断 -- `--host URL` 默认从 tea config 默认 login 取 +- `--host HOST` 如 `github.com` 或 `https://gitea.example.com`,默认从当前 git origin 推断;同时给 `--host` 和 `--repo` 时不需要 git 仓库 - `--json` JSON 输出 - `--cwd PATH` repo 探测的工作目录,默认 `.` @@ -45,3 +60,15 @@ issue/PR body 里用 HTML 注释行携带 spx 元数据: ``` `spx issue marker` 会做增量更新(已存在则替换那一行,不存在则追加),其他类型的 marker 不会被动到。 + +## State 评论约定 + +工单的 state JSON(字段见 [`state-json.schema.json`](../schemas/state-json.schema.json))存在评论里,每次合并追加一条新评论。写出格式是网页上不可见的 HTML 注释,JSON 紧凑一行,`<` `>` `&` 转义成 `\u003c` `\u003e` `\u0026`,保证正文里不会出现 `-->`: + +``` + +``` + +读取时从最新一条评论往前找,第一条有效的胜出。兼容三种形式:上面的隐藏格式、旧 YouTrack 格式(`` 后接 JSON)、旧的裸 JSON 对象。内容必须是 JSON 对象,且至少带一个 schema 里声明的字段。 diff --git a/vscode/cli/cmd/spx/main.go b/vscode/cli/cmd/spx/main.go index 75e39d8..2601c7c 100644 --- a/vscode/cli/cmd/spx/main.go +++ b/vscode/cli/cmd/spx/main.go @@ -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) } diff --git a/vscode/cli/cmd/spx/main_test.go b/vscode/cli/cmd/spx/main_test.go new file mode 100644 index 0000000..d6347a0 --- /dev/null +++ b/vscode/cli/cmd/spx/main_test.go @@ -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 := "" + 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{ + "隐藏注释": "", + "旧 YouTrack 格式": "\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", "") + 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, "") { + t.Fatalf("正文应带 spec marker: %q", is.Body) + } + want := "" + 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", "\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" { + t.Fatalf("正文 marker 不对: %q", is.Body) + } + want := "" + 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", "") + srv.AddComment(n, "alice", `{"column":"todo"}`) + srv.AddComment(n, "alice", "\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 != "\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 时应报错") + } +} diff --git a/vscode/cli/internal/forge/forge.go b/vscode/cli/internal/forge/forge.go new file mode 100644 index 0000000..d04569f --- /dev/null +++ b/vscode/cli/internal/forge/forge.go @@ -0,0 +1,90 @@ +// Package forge talks to the code host a repo lives on, Gitea or GitHub, through one [Forge] interface, so spx +// commands work the same on either. +package forge + +import "strings" + +// Kind names a forge flavor. +type Kind string + +const ( + KindGitea Kind = "gitea" + KindGitHub Kind = "github" +) + +// KindForHost tells which forge serves `host`: only `github.com` is GitHub, every other host is taken as Gitea. +func KindForHost(host string) Kind { + if strings.EqualFold(host, "github.com") { + return KindGitHub + } + return KindGitea +} + +// Issue is the part of an issue spx reads; its JSON form is what `spx issue view --json` prints. +type Issue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Author string `json:"author"` + Assignees []string `json:"assignees"` + HTMLURL string `json:"html_url"` +} + +// Comment is one comment on an issue or pull request. +type Comment struct { + ID int64 `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + HTMLURL string `json:"html_url"` +} + +// BranchRef is one side of a pull request: the branch and the commit it pointed at. +type BranchRef struct { + Branch string `json:"branch"` + SHA string `json:"sha"` +} + +// PullRequest is the part of a pull request spx reads; its JSON form is what `spx pr view --json` prints. +type PullRequest struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Merged bool `json:"merged"` + Author string `json:"author"` + Head BranchRef `json:"head"` + Base BranchRef `json:"base"` + HTMLURL string `json:"html_url"` +} + +// NewPullRequest is what it takes to open a pull request from branch `Head` into branch `Base`. +type NewPullRequest struct { + Title string + Body string + Head string + Base string +} + +// Forge is one repo on a code host, with the few calls spx makes against it. +// +// Pull requests share numbering with issues on both forges, so the issue and comment calls also work on a pull +// request number; that is how pull request comments are read and posted. +type Forge interface { + // Kind tells which forge this is. + Kind() Kind + // CurrentUserLogin returns the login of the user the token belongs to. + CurrentUserLogin() (string, error) + // CreateIssue opens an issue assigned to `assignees`. + CreateIssue(title, body string, assignees []string) (*Issue, error) + GetIssue(number int) (*Issue, error) + UpdateIssueBody(number int, body string) error + // ListIssueComments returns every comment, oldest first, reading all pages. + ListIssueComments(number int) ([]Comment, error) + CreateIssueComment(number int, body string) (*Comment, error) + GetPullRequest(number int) (*PullRequest, error) + CreatePullRequest(pr NewPullRequest) (*PullRequest, error) + // DefaultBranch returns the repo's default branch, the usual base of a new pull request. + DefaultBranch() (string, error) +} diff --git a/vscode/cli/internal/forge/forgetest/server.go b/vscode/cli/internal/forge/forgetest/server.go new file mode 100644 index 0000000..4245fea --- /dev/null +++ b/vscode/cli/internal/forge/forgetest/server.go @@ -0,0 +1,448 @@ +// Package forgetest runs an in-memory Gitea or GitHub REST server for tests, the way [httptest] does for plain handlers. +// +// It serves only the endpoints spx calls, for the single repo [Owner]/[Repo], and mimics each forge's quirks that +// matter to spx: Gitea lives under `/api/v1` and returns every issue comment at once, while GitHub pages comments +// with `per_page` and a `Link` header. +package forgetest + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" +) + +// Flavor picks which forge's REST dialect the fake speaks. +type Flavor int + +const ( + Gitea Flavor = iota + GitHub +) + +// The only repo the fake serves; requests for any other repo get 404. +const ( + Owner = "acme" + Repo = "widgets" +) + +// Issue is an issue stored in the fake. +type Issue struct { + Number int + Title string + Body string + State string + Author string + Assignees []string +} + +// Comment is an issue or pull request comment stored in the fake. +type Comment struct { + ID int64 + Author string + Body string + CreatedAt string +} + +// Pull is a pull request stored in the fake; it shares numbering with [Issue] as on both real forges. +type Pull struct { + Number int + Title string + Body string + State string + Merged bool + Author string + HeadBranch string + HeadSHA string + BaseBranch string + BaseSHA string + // HeadDeleted marks a head branch deleted after merging, which changes what Gitea reports as its `ref`. + HeadDeleted bool +} + +// Server is a running fake forge; read its `URL` to point a client at it. +type Server struct { + *httptest.Server + Flavor Flavor + Token string + Login string + DefaultBranch string + + mu sync.Mutex + issues map[int]*Issue + pulls map[int]*Pull + comments map[int][]Comment + nextNumber int + nextCommentID int64 + requests []string +} + +// NewServer starts a fake forge of the given flavor that is shut down when the test ends. +func NewServer(t testing.TB, flavor Flavor) *Server { + s := &Server{ + Flavor: flavor, + Token: "test-token", + Login: "alice", + DefaultBranch: "main", + issues: map[int]*Issue{}, + pulls: map[int]*Pull{}, + comments: map[int][]Comment{}, + nextNumber: 1, + nextCommentID: 1, + } + s.Server = httptest.NewServer(http.HandlerFunc(s.serve)) + t.Cleanup(s.Close) + return s +} + +// AddIssue stores an issue under the next free number and returns that number; an empty `State` means `open`. +func (s *Server) AddIssue(is Issue) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.addIssueLocked(is) +} + +// AddPull stores a pull request under the next free number and returns that number; an empty `State` means `open`. +func (s *Server) AddPull(p Pull) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.addPullLocked(p) +} + +// AddComment appends a comment to issue or pull request `number`, as if `author` had posted it last. +func (s *Server) AddComment(number int, author, body string) Comment { + s.mu.Lock() + defer s.mu.Unlock() + return s.addCommentLocked(number, author, body) +} + +// Comments returns a copy of the comments on issue or pull request `number`, oldest first. +func (s *Server) Comments(number int) []Comment { + s.mu.Lock() + defer s.mu.Unlock() + return append([]Comment(nil), s.comments[number]...) +} + +// Issue returns a copy of issue `number` and whether it exists. +func (s *Server) Issue(number int) (Issue, bool) { + s.mu.Lock() + defer s.mu.Unlock() + is, ok := s.issues[number] + if !ok { + return Issue{}, false + } + return *is, true +} + +// Pull returns a copy of pull request `number` and whether it exists. +func (s *Server) Pull(number int) (Pull, bool) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.pulls[number] + if !ok { + return Pull{}, false + } + return *p, true +} + +// Requests returns every request served so far as `METHOD /path?query`, so tests can check which endpoints were hit. +func (s *Server) Requests() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.requests...) +} + +func (s *Server) addIssueLocked(is Issue) int { + is.Number = s.nextNumber + s.nextNumber++ + if is.State == "" { + is.State = "open" + } + s.issues[is.Number] = &is + return is.Number +} + +func (s *Server) addPullLocked(p Pull) int { + p.Number = s.nextNumber + s.nextNumber++ + if p.State == "" { + p.State = "open" + } + s.pulls[p.Number] = &p + return p.Number +} + +func (s *Server) addCommentLocked(number int, author, body string) Comment { + c := Comment{ + ID: s.nextCommentID, + Author: author, + Body: body, + CreatedAt: fmt.Sprintf("2026-10-19T10:%02d:%02dZ", (s.nextCommentID/60)%60, s.nextCommentID%60), + } + s.nextCommentID++ + s.comments[number] = append(s.comments[number], c) + return c +} + +// apiPrefix is the path every API route sits under: Gitea mounts its API at `/api/v1`, GitHub at the root. +func (s *Server) apiPrefix() string { + if s.Flavor == Gitea { + return "/api/v1" + } + return "" +} + +// authorized checks the headers each real forge expects, so a client that sends the wrong scheme fails here too. +func (s *Server) authorized(r *http.Request) bool { + if s.Flavor == Gitea { + return r.Header.Get("Authorization") == "token "+s.Token + } + return r.Header.Get("Authorization") == "Bearer "+s.Token && + r.Header.Get("Accept") == "application/vnd.github+json" && + r.Header.Get("X-GitHub-Api-Version") == "2022-11-28" +} + +func (s *Server) serve(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + s.requests = append(s.requests, r.Method+" "+r.URL.RequestURI()) + if !s.authorized(r) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"message": "Bad credentials"}) + return + } + prefix := s.apiPrefix() + if !strings.HasPrefix(r.URL.Path, prefix+"/") { + notFound(w) + return + } + path := strings.TrimPrefix(r.URL.Path, prefix) + if path == "/user" && r.Method == http.MethodGet { + writeJSON(w, http.StatusOK, map[string]any{"login": s.Login}) + return + } + repoPath := "/repos/" + Owner + "/" + Repo + if path != repoPath && !strings.HasPrefix(path, repoPath+"/") { + notFound(w) + return + } + rest := strings.TrimPrefix(path, repoPath) + segs := strings.Split(strings.Trim(rest, "/"), "/") + switch { + case rest == "" && r.Method == http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"full_name": Owner + "/" + Repo, "default_branch": s.DefaultBranch}) + case rest == "/issues" && r.Method == http.MethodPost: + s.createIssue(w, r) + case rest == "/pulls" && r.Method == http.MethodPost: + s.createPull(w, r) + case len(segs) == 2 && segs[0] == "issues": + s.serveIssue(w, r, atoi(segs[1])) + case len(segs) == 3 && segs[0] == "issues" && segs[2] == "comments": + s.serveComments(w, r, atoi(segs[1])) + case len(segs) == 2 && segs[0] == "pulls" && r.Method == http.MethodGet: + p, ok := s.pulls[atoi(segs[1])] + if !ok { + notFound(w) + return + } + writeJSON(w, http.StatusOK, s.pullJSON(p)) + default: + notFound(w) + } +} + +func (s *Server) createIssue(w http.ResponseWriter, r *http.Request) { + var in struct { + Title string `json:"title"` + Body string `json:"body"` + Assignees []string `json:"assignees"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "title is required"}) + return + } + n := s.addIssueLocked(Issue{Title: in.Title, Body: in.Body, Author: s.Login, Assignees: in.Assignees}) + writeJSON(w, http.StatusCreated, s.issueJSON(s.issues[n])) +} + +func (s *Server) createPull(w http.ResponseWriter, r *http.Request) { + var in struct { + Title string `json:"title"` + Body string `json:"body"` + Head string `json:"head"` + Base string `json:"base"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" || in.Head == "" || in.Base == "" { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "title, head and base are required"}) + return + } + n := s.addPullLocked(Pull{ + Title: in.Title, Body: in.Body, Author: s.Login, + HeadBranch: in.Head, HeadSHA: "head-sha-" + in.Head, + BaseBranch: in.Base, BaseSHA: "base-sha-" + in.Base, + }) + writeJSON(w, http.StatusCreated, s.pullJSON(s.pulls[n])) +} + +// serveIssue answers `GET` and `PATCH` on one issue; a pull request number is answered as an issue, as both forges do. +func (s *Server) serveIssue(w http.ResponseWriter, r *http.Request, n int) { + if p, ok := s.pulls[n]; ok && r.Method == http.MethodGet { + writeJSON(w, http.StatusOK, s.issueJSON(&Issue{Number: p.Number, Title: p.Title, Body: p.Body, State: p.State, Author: p.Author})) + return + } + is, ok := s.issues[n] + if !ok { + notFound(w) + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, s.issueJSON(is)) + case http.MethodPatch: + var in struct { + Body *string `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": err.Error()}) + return + } + if in.Body != nil { + is.Body = *in.Body + } + writeJSON(w, http.StatusOK, s.issueJSON(is)) + default: + notFound(w) + } +} + +func (s *Server) serveComments(w http.ResponseWriter, r *http.Request, n int) { + if _, isIssue := s.issues[n]; !isIssue { + if _, isPull := s.pulls[n]; !isPull { + notFound(w) + return + } + } + switch r.Method { + case http.MethodPost: + var in struct { + Body string `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": err.Error()}) + return + } + c := s.addCommentLocked(n, s.Login, in.Body) + writeJSON(w, http.StatusCreated, s.commentJSON(n, c)) + case http.MethodGet: + all := s.comments[n] + if s.Flavor == GitHub { + all = s.paginate(w, r, all) + } + out := make([]map[string]any, 0, len(all)) + for _, c := range all { + out = append(out, s.commentJSON(n, c)) + } + writeJSON(w, http.StatusOK, out) + default: + notFound(w) + } +} + +// paginate cuts out the page GitHub would return for `per_page` and `page` and sets the `Link` header to the next one. +func (s *Server) paginate(w http.ResponseWriter, r *http.Request, all []Comment) []Comment { + perPage := 30 + if v, err := strconv.Atoi(r.URL.Query().Get("per_page")); err == nil && v > 0 { + perPage = min(v, 100) + } + page := 1 + if v, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && v > 0 { + page = v + } + start := min((page-1)*perPage, len(all)) + end := min(start+perPage, len(all)) + if end < len(all) { + last := (len(all) + perPage - 1) / perPage + base := "http://" + r.Host + r.URL.Path + w.Header().Set("Link", fmt.Sprintf(`<%s?per_page=%d&page=%d>; rel="next", <%s?per_page=%d&page=%d>; rel="last"`, + base, perPage, page+1, base, perPage, last)) + } + return all[start:end] +} + +func (s *Server) issueJSON(is *Issue) map[string]any { + var assignees any + if len(is.Assignees) > 0 || s.Flavor == GitHub { + list := make([]map[string]any, 0, len(is.Assignees)) + for _, a := range is.Assignees { + list = append(list, map[string]any{"login": a}) + } + assignees = list + } + return map[string]any{ + "number": is.Number, + "title": is.Title, + "body": is.Body, + "state": is.State, + "html_url": fmt.Sprintf("%s/%s/%s/issues/%d", s.URL, Owner, Repo, is.Number), + "user": map[string]any{"login": is.Author}, + "assignees": assignees, + } +} + +// pullJSON renders a pull request the way each forge does; the two disagree on the head branch fields: +// GitHub's `label` is `owner:branch` and `ref` the branch, while Gitea's `label` is the branch and `ref` turns into +// `refs/pull//head` once the branch is gone. +func (s *Server) pullJSON(p *Pull) map[string]any { + segment := "pulls" + head := map[string]any{"label": p.HeadBranch, "ref": p.HeadBranch, "sha": p.HeadSHA} + base := map[string]any{"label": p.BaseBranch, "ref": p.BaseBranch, "sha": p.BaseSHA} + if s.Flavor == GitHub { + segment = "pull" + head["label"] = Owner + ":" + p.HeadBranch + base["label"] = Owner + ":" + p.BaseBranch + } else if p.HeadDeleted { + head["ref"] = fmt.Sprintf("refs/pull/%d/head", p.Number) + } + return map[string]any{ + "number": p.Number, + "title": p.Title, + "body": p.Body, + "state": p.State, + "merged": p.Merged, + "html_url": fmt.Sprintf("%s/%s/%s/%s/%d", s.URL, Owner, Repo, segment, p.Number), + "user": map[string]any{"login": p.Author}, + "head": head, + "base": base, + } +} + +func (s *Server) commentJSON(n int, c Comment) map[string]any { + return map[string]any{ + "id": c.ID, + "body": c.Body, + "user": map[string]any{"login": c.Author}, + "created_at": c.CreatedAt, + "html_url": fmt.Sprintf("%s/%s/%s/issues/%d#issuecomment-%d", s.URL, Owner, Repo, n, c.ID), + } +} + +func atoi(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + return -1 + } + return n +} + +func notFound(w http.ResponseWriter) { + writeJSON(w, http.StatusNotFound, map[string]any{"message": "Not Found"}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/vscode/cli/internal/forge/open.go b/vscode/cli/internal/forge/open.go new file mode 100644 index 0000000..10834dc --- /dev/null +++ b/vscode/cli/internal/forge/open.go @@ -0,0 +1,83 @@ +package forge + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/cruldra/superpowers-vscode/cli/internal/repo" + "github.com/cruldra/superpowers-vscode/cli/internal/tea" +) + +// Env is where [Open] gets credentials from; tests swap in canned answers, real runs use [SystemEnv]. +type Env struct { + // GitHubToken returns the token the GitHub CLI holds for `host`. + GitHubToken func(host string) (string, error) + // TeaLogin returns the tea login whose URL points at `host`, or `nil` when there is none. + TeaLogin func(host string) (*tea.TeaLogin, error) + // Getenv reads an environment variable. + Getenv func(key string) string +} + +// SystemEnv reads credentials from this machine: `gh auth token`, the tea config file and the process environment. +func SystemEnv() Env { + return Env{ + GitHubToken: ghAuthToken, + TeaLogin: func(host string) (*tea.TeaLogin, error) { + path, err := tea.DefaultConfigPath() + if err != nil { + return nil, err + } + return tea.LoginForHost(path, host) + }, + Getenv: os.Getenv, + } +} + +// ghAuthToken asks the GitHub CLI for the token it stores for `host`. +func ghAuthToken(host string) (string, error) { + out, err := exec.Command("gh", "auth", "token", "--hostname", host).Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { + return "", fmt.Errorf("gh auth token --hostname %s 失败: %w: %s", host, err, strings.TrimSpace(string(exitErr.Stderr))) + } + return "", fmt.Errorf("gh auth token --hostname %s 失败: %w", host, err) + } + return string(out), nil +} + +// Open connects to the repo at `o` on the forge its host names, picking credentials the way that forge expects. +// +// GitHub takes the token from `gh auth token`. Gitea takes the token of the tea login for the same host, and its +// URL as the API root; with no such login, or one without a token, it falls back to `GITEA_TOKEN` and the origin's web root. +func Open(o repo.Origin, env Env) (Forge, error) { + if KindForHost(o.Host) == KindGitHub { + token, err := env.GitHubToken(o.Host) + if err != nil { + return nil, fmt.Errorf("取 GitHub token 失败,请先运行 `gh auth login`: %w", err) + } + token = strings.TrimSpace(token) + if token == "" { + return nil, fmt.Errorf("gh 没有 %s 的 token,请先运行 `gh auth login`", o.Host) + } + return NewGitHub(GitHubAPIBaseURL, token, o.Owner, o.Repo), nil + } + login, err := env.TeaLogin(o.Host) + if err != nil { + return nil, err + } + baseURL, token := o.WebURL, "" + if login != nil { + baseURL, token = login.URL, login.Token + } + if token == "" { + token = env.Getenv("GITEA_TOKEN") + } + if token == "" { + return nil, fmt.Errorf("没有 %s 的 Gitea token:tea config 里没有该 host 的 login 或其 token 为空,GITEA_TOKEN 也没设置;请运行 `tea login add` 或设置 GITEA_TOKEN", o.Host) + } + return NewGitea(baseURL, token, o.Owner, o.Repo), nil +} diff --git a/vscode/cli/internal/forge/open_test.go b/vscode/cli/internal/forge/open_test.go new file mode 100644 index 0000000..1eca631 --- /dev/null +++ b/vscode/cli/internal/forge/open_test.go @@ -0,0 +1,154 @@ +package forge + +import ( + "errors" + "strings" + "testing" + + "github.com/cruldra/superpowers-vscode/cli/internal/repo" + "github.com/cruldra/superpowers-vscode/cli/internal/tea" +) + +// fakeEnv builds an [Env] whose credential sources are canned answers, recording which hosts were asked. +type fakeEnv struct { + ghToken string + ghErr error + logins map[string]*tea.TeaLogin + env map[string]string + ghAsked []string + teaAsked []string +} + +func (f *fakeEnv) Env() Env { + return Env{ + GitHubToken: func(host string) (string, error) { + f.ghAsked = append(f.ghAsked, host) + return f.ghToken, f.ghErr + }, + TeaLogin: func(host string) (*tea.TeaLogin, error) { + f.teaAsked = append(f.teaAsked, host) + return f.logins[host], nil + }, + Getenv: func(key string) string { return f.env[key] }, + } +} + +func githubOrigin() repo.Origin { + return repo.Origin{Host: "github.com", WebURL: "https://github.com", Owner: "acme", Repo: "widgets"} +} + +func giteaOrigin() repo.Origin { + return repo.Origin{Host: "gitea.example.com", WebURL: "https://gitea.example.com", Owner: "acme", Repo: "widgets"} +} + +func TestKindForHost(t *testing.T) { + cases := map[string]Kind{ + "github.com": KindGitHub, + "GitHub.com": KindGitHub, + "gitea.example.com": KindGitea, + "github.example.com": KindGitea, + "api.github.com": KindGitea, + "localhost": KindGitea, + } + for host, want := range cases { + if got := KindForHost(host); got != want { + t.Errorf("KindForHost(%q) = %s, want %s", host, got, want) + } + } +} + +func TestOpenGitHubUsesGhToken(t *testing.T) { + fe := &fakeEnv{ghToken: "gho_abc\n", env: map[string]string{"GITEA_TOKEN": "ignored"}} + f, err := Open(githubOrigin(), fe.Env()) + if err != nil { + t.Fatalf("Open 出错: %v", err) + } + if f.Kind() != KindGitHub { + t.Fatalf("github.com 应选 GitHub,得到 %s", f.Kind()) + } + rf := f.(*restForge) + if rf.apiBase != GitHubAPIBaseURL || rf.header.Get("Authorization") != "Bearer gho_abc" { + t.Fatalf("GitHub 客户端配置不对: base=%s auth=%q", rf.apiBase, rf.header.Get("Authorization")) + } + if rf.owner != "acme" || rf.repo != "widgets" { + t.Fatalf("owner/repo 不对: %s/%s", rf.owner, rf.repo) + } + if len(fe.ghAsked) != 1 || fe.ghAsked[0] != "github.com" || len(fe.teaAsked) != 0 { + t.Fatalf("应只向 gh 要 github.com 的 token: gh=%v tea=%v", fe.ghAsked, fe.teaAsked) + } +} + +func TestOpenGitHubFailsWithLoginHintWhenGhFails(t *testing.T) { + fe := &fakeEnv{ghErr: errors.New("exit status 1: no oauth token found")} + _, err := Open(githubOrigin(), fe.Env()) + if err == nil || !strings.Contains(err.Error(), "gh auth login") { + t.Fatalf("gh 取 token 失败应提示运行 gh auth login,得到 %v", err) + } + if !strings.Contains(err.Error(), "no oauth token found") { + t.Fatalf("错误里应带上 gh 的原因: %v", err) + } +} + +func TestOpenGitHubFailsWithLoginHintWhenGhTokenEmpty(t *testing.T) { + fe := &fakeEnv{ghToken: " \n"} + _, err := Open(githubOrigin(), fe.Env()) + if err == nil || !strings.Contains(err.Error(), "gh auth login") { + t.Fatalf("gh 返回空 token 应提示运行 gh auth login,得到 %v", err) + } +} + +func TestOpenGiteaUsesTeaLoginOfOriginHost(t *testing.T) { + fe := &fakeEnv{ + logins: map[string]*tea.TeaLogin{ + "gitea.example.com": {Name: "work", URL: "https://gitea.example.com:8443/", Token: "tea-tok"}, + }, + env: map[string]string{"GITEA_TOKEN": "env-tok"}, + } + f, err := Open(giteaOrigin(), fe.Env()) + if err != nil { + t.Fatalf("Open 出错: %v", err) + } + rf := f.(*restForge) + if f.Kind() != KindGitea || rf.apiBase != "https://gitea.example.com:8443/api/v1" || rf.header.Get("Authorization") != "token tea-tok" { + t.Fatalf("Gitea 客户端配置不对: kind=%s base=%s auth=%q", f.Kind(), rf.apiBase, rf.header.Get("Authorization")) + } + if len(fe.teaAsked) != 1 || fe.teaAsked[0] != "gitea.example.com" || len(fe.ghAsked) != 0 { + t.Fatalf("应只按 origin host 查 tea login: tea=%v gh=%v", fe.teaAsked, fe.ghAsked) + } +} + +func TestOpenGiteaFallsBackToEnvTokenWhenLoginHasNone(t *testing.T) { + fe := &fakeEnv{ + logins: map[string]*tea.TeaLogin{ + "gitea.example.com": {Name: "work", URL: "https://gitea.example.com", Token: ""}, + }, + env: map[string]string{"GITEA_TOKEN": "env-tok"}, + } + f, err := Open(giteaOrigin(), fe.Env()) + if err != nil { + t.Fatalf("Open 出错: %v", err) + } + if got := f.(*restForge).header.Get("Authorization"); got != "token env-tok" { + t.Fatalf("login 没 token 时应回退 GITEA_TOKEN,得到 %q", got) + } +} + +func TestOpenGiteaWithoutLoginUsesOriginWebURLAndEnvToken(t *testing.T) { + fe := &fakeEnv{env: map[string]string{"GITEA_TOKEN": "env-tok"}} + f, err := Open(giteaOrigin(), fe.Env()) + if err != nil { + t.Fatalf("Open 出错: %v", err) + } + rf := f.(*restForge) + if rf.apiBase != "https://gitea.example.com/api/v1" || rf.header.Get("Authorization") != "token env-tok" { + t.Fatalf("没有 tea login 时应用 origin 地址 + GITEA_TOKEN: base=%s auth=%q", rf.apiBase, rf.header.Get("Authorization")) + } +} + +func TestOpenGiteaWithoutAnyTokenFails(t *testing.T) { + fe := &fakeEnv{} + _, err := Open(giteaOrigin(), fe.Env()) + if err == nil || !strings.Contains(err.Error(), "GITEA_TOKEN") || !strings.Contains(err.Error(), "gitea.example.com") { + t.Fatalf("没有 token 时应报错并点名 host 与 GITEA_TOKEN,得到 %v", err) + } +} diff --git a/vscode/cli/internal/forge/pagination_test.go b/vscode/cli/internal/forge/pagination_test.go new file mode 100644 index 0000000..7abb2b9 --- /dev/null +++ b/vscode/cli/internal/forge/pagination_test.go @@ -0,0 +1,131 @@ +package forge + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestNextLink(t *testing.T) { + cases := map[string]string{ + ``: ``, + `; rel="next", ; rel="last"`: `https://api.github.com/repositories/1/issues?page=2`, + `; rel="prev", ; rel="next"`: `https://x/a?page=3`, + `; rel="first", ; rel="prev"`: ``, + `; rel=next`: `https://x/a?labels=a,b&page=2`, + `; rel="next",; rel="last"`: `http://localhost/api/v1/repos/search?limit=1&page=2`, + } + for header, want := range cases { + if got := nextLink(header); got != want { + t.Errorf("nextLink(%q) = %q, want %q", header, got, want) + } + } +} + +// pagedServer serves `total` comments `size` at a time at any path, linking each page to the next one under `nextPath`. +func pagedServer(t *testing.T, total, size int, nextPath string, check func(r *http.Request)) (*httptest.Server, *int32) { + t.Helper() + var calls int32 + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + check(r) + page := 1 + fmt.Sscan(r.URL.Query().Get("page"), &page) + start := (page - 1) * size + end := min(start+size, total) + if end < total { + w.Header().Set("Link", fmt.Sprintf(`<%s%s?page=%d>; rel="next"`, srv.URL, nextPath, page+1)) + } + out := []map[string]any{} + for i := start; i < end; i++ { + out = append(out, map[string]any{"id": i + 1, "body": fmt.Sprintf("c%d", i+1), "user": map[string]any{"login": "bob"}}) + } + _ = json.NewEncoder(w).Encode(out) + })) + t.Cleanup(srv.Close) + return srv, &calls +} + +func TestGitHubPaginationFollowsLinkToOtherPath(t *testing.T) { + srv, calls := pagedServer(t, 230, 100, "/repositories/42/issues/7/comments", func(r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" || + r.Header.Get("Accept") != "application/vnd.github+json" || + r.Header.Get("X-GitHub-Api-Version") != "2022-11-28" { + t.Errorf("GitHub 请求头不对: %v", r.Header) + } + if r.URL.Query().Get("page") == "" { + if r.URL.Path != "/repos/o/r/issues/7/comments" || r.URL.Query().Get("per_page") != "100" { + t.Errorf("第一页请求不对: %s", r.URL.RequestURI()) + } + } else if r.URL.Path != "/repositories/42/issues/7/comments" { + t.Errorf("后续页应照 Link 走: %s", r.URL.RequestURI()) + } + }) + list, err := NewGitHub(srv.URL, "tok", "o", "r").ListIssueComments(7) + if err != nil { + t.Fatalf("ListIssueComments 出错: %v", err) + } + if len(list) != 230 || list[229].Body != "c230" || *calls != 3 { + t.Fatalf("应 3 次请求取全 230 条,得到 %d 条 / %d 次", len(list), *calls) + } +} + +func TestGiteaPaginationFollowsLinkWithLimit(t *testing.T) { + srv, calls := pagedServer(t, 120, 50, "/api/v1/repos/o/r/issues/7/comments", func(r *http.Request) { + if r.Header.Get("Authorization") != "token tok" { + t.Errorf("Gitea 认证头不对: %q", r.Header.Get("Authorization")) + } + if !strings.HasPrefix(r.URL.Path, "/api/v1/") { + t.Errorf("Gitea 请求应在 /api/v1 下: %s", r.URL.Path) + } + if r.URL.Query().Get("page") == "" { + if r.URL.Query().Get("limit") != "50" { + t.Errorf("第一页应带 limit=50: %s", r.URL.RequestURI()) + } + } + }) + list, err := NewGitea(srv.URL, "tok", "o", "r").ListIssueComments(7) + if err != nil { + t.Fatalf("ListIssueComments 出错: %v", err) + } + if len(list) != 120 || *calls != 3 { + t.Fatalf("应 3 次请求取全 120 条,得到 %d 条 / %d 次", len(list), *calls) + } +} + +func TestPaginationStopsWithoutLinkHeader(t *testing.T) { + srv, calls := pagedServer(t, 120, 1000, "", func(*http.Request) {}) + list, err := NewGitea(srv.URL, "tok", "o", "r").ListIssueComments(7) + if err != nil { + t.Fatalf("ListIssueComments 出错: %v", err) + } + if len(list) != 120 || *calls != 1 { + t.Fatalf("没有 Link 头时应一次取完,得到 %d 条 / %d 次", len(list), *calls) + } +} + +func TestPaginationRefusesLinkToAnotherHost(t *testing.T) { + var foreignCalls int32 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&foreignCalls, 1) + _, _ = w.Write([]byte("[]")) + })) + t.Cleanup(foreign.Close) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Link", fmt.Sprintf(`<%s/steal?page=2>; rel="next"`, foreign.URL)) + _, _ = w.Write([]byte(`[{"id":1,"body":"x","user":{"login":"bob"}}]`)) + })) + t.Cleanup(srv.Close) + _, err := NewGitHub(srv.URL, "tok", "o", "r").ListIssueComments(7) + if err == nil { + t.Fatal("Link 指向别的主机时应报错") + } + if foreignCalls != 0 { + t.Fatalf("不应带 token 请求别的主机,实际请求了 %d 次", foreignCalls) + } +} diff --git a/vscode/cli/internal/forge/rest.go b/vscode/cli/internal/forge/rest.go new file mode 100644 index 0000000..d009809 --- /dev/null +++ b/vscode/cli/internal/forge/rest.go @@ -0,0 +1,366 @@ +package forge + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +// GitHubAPIBaseURL is the root of the github.com REST API. +const GitHubAPIBaseURL = "https://api.github.com" + +// gitHubAPIVersion pins the GitHub REST API version, so a newer default version cannot change response shapes under spx. +const gitHubAPIVersion = "2022-11-28" + +// restForge is the [Forge] for both Gitea and GitHub: their REST endpoints for issues, comments and pull requests +// share paths and JSON shapes, so the two differ only in API root, auth headers, page-size parameter and where a pull +// request names its branches. +type restForge struct { + kind Kind + apiBase string + owner string + repo string + header http.Header + pageParam string + pageSize int + // branchOf reads the branch name out of a pull request's `head` or `base`. + branchOf func(apiBranchRef) string + http *http.Client +} + +// NewGitea returns the [Forge] for repo `owner`/`repo` on the Gitea instance at `baseURL`, e.g. `https://gitea.example.com`. +// +// Gitea serves at most 50 items per page by default (`MAX_RESPONSE_ITEMS`), so lists ask for that many. +func NewGitea(baseURL, token, owner, repo string) Forge { + h := http.Header{} + h.Set("Authorization", "token "+token) + h.Set("Accept", "application/json") + return &restForge{ + kind: KindGitea, + apiBase: strings.TrimRight(baseURL, "/") + "/api/v1", + owner: owner, + repo: repo, + header: h, + pageParam: "limit", + pageSize: 50, + branchOf: giteaBranch, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// NewGitHub returns the [Forge] for repo `owner`/`repo` on GitHub, whose REST API sits at `apiBaseURL`, normally [GitHubAPIBaseURL]. +func NewGitHub(apiBaseURL, token, owner, repo string) Forge { + h := http.Header{} + h.Set("Authorization", "Bearer "+token) + h.Set("Accept", "application/vnd.github+json") + h.Set("X-GitHub-Api-Version", gitHubAPIVersion) + return &restForge{ + kind: KindGitHub, + apiBase: strings.TrimRight(apiBaseURL, "/"), + owner: owner, + repo: repo, + header: h, + pageParam: "per_page", + pageSize: 100, + branchOf: func(r apiBranchRef) string { return r.Ref }, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (f *restForge) Kind() Kind { return f.kind } + +// repoURL builds the API URL of `suffix` under this repo, e.g. `/issues/3` becomes `/repos///issues/3`. +func (f *restForge) repoURL(suffix string) string { + return f.apiBase + "/repos/" + f.owner + "/" + f.repo + suffix +} + +// do sends one authenticated request to `rawURL`, sending `payload` as the JSON body when not nil, and decodes the +// JSON response into `out` when not nil. +// +// @return the response headers, so list calls can read the `Link` header for the next page +func (f *restForge) do(method, rawURL string, payload, out any) (http.Header, error) { + var body io.Reader + if payload != nil { + buf, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("编码请求体失败: %w", err) + } + body = bytes.NewReader(buf) + } + req, err := http.NewRequest(method, rawURL, body) + if err != nil { + return nil, fmt.Errorf("构造请求失败: %w", err) + } + for k, v := range f.header { + req.Header[k] = v + } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := f.http.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s 请求失败: %w", method, rawURL, err) + } + defer resp.Body.Close() + respBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s %s 返回 %d: %s", method, rawURL, resp.StatusCode, strings.TrimSpace(string(respBytes))) + } + if out != nil && len(respBytes) > 0 { + if err := json.Unmarshal(respBytes, out); err != nil { + return nil, fmt.Errorf("解析响应失败 (%s %s): %w; body=%s", method, rawURL, err, string(respBytes)) + } + } + return resp.Header, nil +} + +// listAll reads every page of the list at `rawURL`, asking for the largest page size and then following each +// `Link: rel="next"` header until there is none. +// +// An endpoint that does not paginate, such as Gitea's issue comments, sends everything with no `Link`, so one request is enough. +func listAll[T any](f *restForge, rawURL string) ([]T, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("构造列表 URL 失败: %w", err) + } + q := u.Query() + q.Set(f.pageParam, strconv.Itoa(f.pageSize)) + u.RawQuery = q.Encode() + var all []T + for next := u.String(); next != ""; { + var page []T + header, err := f.do(http.MethodGet, next, nil, &page) + if err != nil { + return nil, err + } + all = append(all, page...) + if next, err = f.nextPage(next, header.Get("Link")); err != nil { + return nil, err + } + } + return all, nil +} + +// nextPage resolves the `rel="next"` URL of a `Link` header against the page just read, or returns "" on the last page. +// +// It refuses a next URL on another scheme or host than the API root, so the token is never sent anywhere else. +func (f *restForge) nextPage(current, linkHeader string) (string, error) { + target := nextLink(linkHeader) + if target == "" { + return "", nil + } + cur, err := url.Parse(current) + if err != nil { + return "", fmt.Errorf("解析当前页 URL %q 失败: %w", current, err) + } + ref, err := url.Parse(target) + if err != nil { + return "", fmt.Errorf("解析分页链接 %q 失败: %w", target, err) + } + abs := cur.ResolveReference(ref) + base, err := url.Parse(f.apiBase) + if err != nil { + return "", fmt.Errorf("解析 API 根地址 %q 失败: %w", f.apiBase, err) + } + if !strings.EqualFold(abs.Scheme, base.Scheme) || !strings.EqualFold(abs.Host, base.Host) { + return "", fmt.Errorf("分页链接 %s 不在 API 地址 %s 下,拒绝带 token 访问", abs, f.apiBase) + } + return abs.String(), nil +} + +// linkEntryRegex matches one `; params` entry of a `Link` header; the params run until the next `<`. +var linkEntryRegex = regexp.MustCompile(`<([^>]*)>([^<]*)`) + +// nextLink picks the URL marked `rel="next"` out of a `Link` header, or returns "" when there is none. +func nextLink(header string) string { + for _, m := range linkEntryRegex.FindAllStringSubmatch(header, -1) { + for _, param := range strings.Split(m[2], ";") { + name, value, ok := strings.Cut(strings.TrimSpace(strings.TrimRight(strings.TrimSpace(param), ",")), "=") + if !ok || !strings.EqualFold(strings.TrimSpace(name), "rel") { + continue + } + for _, rel := range strings.Fields(strings.Trim(strings.TrimSpace(value), `"`)) { + if strings.EqualFold(rel, "next") { + return m[1] + } + } + } + } + return "" +} + +// apiUser, apiIssue, apiComment and apiPull mirror the response fields spx reads; Gitea and GitHub name them alike. +type apiUser struct { + Login string `json:"login"` +} + +type apiIssue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + User apiUser `json:"user"` + Assignees []apiUser `json:"assignees"` +} + +func (a apiIssue) issue() *Issue { + assignees := make([]string, 0, len(a.Assignees)) + for _, u := range a.Assignees { + assignees = append(assignees, u.Login) + } + return &Issue{ + Number: a.Number, + Title: a.Title, + Body: a.Body, + State: a.State, + Author: a.User.Login, + Assignees: assignees, + HTMLURL: a.HTMLURL, + } +} + +type apiComment struct { + ID int64 `json:"id"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` + User apiUser `json:"user"` +} + +func (a apiComment) comment() Comment { + return Comment{ID: a.ID, Author: a.User.Login, Body: a.Body, CreatedAt: a.CreatedAt, HTMLURL: a.HTMLURL} +} + +type apiBranchRef struct { + Label string `json:"label"` + Ref string `json:"ref"` + SHA string `json:"sha"` +} + +// giteaBranch reads the branch name the way Gitea reports it: `label` is always the branch, while `ref` becomes +// `refs/pull//head` once the branch is deleted. AGit pull requests leave `label` empty, so `ref` is the fallback. +func giteaBranch(r apiBranchRef) string { + if r.Label != "" { + return r.Label + } + return r.Ref +} + +type apiPull struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Merged bool `json:"merged"` + HTMLURL string `json:"html_url"` + User apiUser `json:"user"` + Head apiBranchRef `json:"head"` + Base apiBranchRef `json:"base"` +} + +// pull converts the response, naming branches with this forge's `branchOf`. +func (f *restForge) pull(a apiPull) *PullRequest { + return &PullRequest{ + Number: a.Number, + Title: a.Title, + Body: a.Body, + State: a.State, + Merged: a.Merged, + Author: a.User.Login, + Head: BranchRef{Branch: f.branchOf(a.Head), SHA: a.Head.SHA}, + Base: BranchRef{Branch: f.branchOf(a.Base), SHA: a.Base.SHA}, + HTMLURL: a.HTMLURL, + } +} + +func (f *restForge) CurrentUserLogin() (string, error) { + var out apiUser + if _, err := f.do(http.MethodGet, f.apiBase+"/user", nil, &out); err != nil { + return "", err + } + return out.Login, nil +} + +func (f *restForge) CreateIssue(title, body string, assignees []string) (*Issue, error) { + payload := map[string]any{"title": title, "body": body} + if len(assignees) > 0 { + payload["assignees"] = assignees + } + var out apiIssue + if _, err := f.do(http.MethodPost, f.repoURL("/issues"), payload, &out); err != nil { + return nil, err + } + return out.issue(), nil +} + +func (f *restForge) GetIssue(number int) (*Issue, error) { + var out apiIssue + if _, err := f.do(http.MethodGet, f.repoURL(fmt.Sprintf("/issues/%d", number)), nil, &out); err != nil { + return nil, err + } + return out.issue(), nil +} + +func (f *restForge) UpdateIssueBody(number int, body string) error { + _, err := f.do(http.MethodPatch, f.repoURL(fmt.Sprintf("/issues/%d", number)), map[string]any{"body": body}, nil) + return err +} + +func (f *restForge) ListIssueComments(number int) ([]Comment, error) { + raw, err := listAll[apiComment](f, f.repoURL(fmt.Sprintf("/issues/%d/comments", number))) + if err != nil { + return nil, err + } + out := make([]Comment, 0, len(raw)) + for _, c := range raw { + out = append(out, c.comment()) + } + return out, nil +} + +func (f *restForge) CreateIssueComment(number int, body string) (*Comment, error) { + var out apiComment + if _, err := f.do(http.MethodPost, f.repoURL(fmt.Sprintf("/issues/%d/comments", number)), map[string]any{"body": body}, &out); err != nil { + return nil, err + } + c := out.comment() + return &c, nil +} + +func (f *restForge) GetPullRequest(number int) (*PullRequest, error) { + var out apiPull + if _, err := f.do(http.MethodGet, f.repoURL(fmt.Sprintf("/pulls/%d", number)), nil, &out); err != nil { + return nil, err + } + return f.pull(out), nil +} + +func (f *restForge) CreatePullRequest(pr NewPullRequest) (*PullRequest, error) { + payload := map[string]any{"title": pr.Title, "body": pr.Body, "head": pr.Head, "base": pr.Base} + var out apiPull + if _, err := f.do(http.MethodPost, f.repoURL("/pulls"), payload, &out); err != nil { + return nil, err + } + return f.pull(out), nil +} + +func (f *restForge) DefaultBranch() (string, error) { + var out struct { + DefaultBranch string `json:"default_branch"` + } + if _, err := f.do(http.MethodGet, f.repoURL(""), nil, &out); err != nil { + return "", err + } + if out.DefaultBranch == "" { + return "", fmt.Errorf("仓库 %s/%s 没有返回默认分支", f.owner, f.repo) + } + return out.DefaultBranch, nil +} diff --git a/vscode/cli/internal/forge/rest_test.go b/vscode/cli/internal/forge/rest_test.go new file mode 100644 index 0000000..e4dd4cf --- /dev/null +++ b/vscode/cli/internal/forge/rest_test.go @@ -0,0 +1,241 @@ +package forge_test + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "github.com/cruldra/superpowers-vscode/cli/internal/forge" + "github.com/cruldra/superpowers-vscode/cli/internal/forge/forgetest" +) + +// flavors runs fn once against a fake Gitea and once against a fake GitHub, each wired to a matching client. +func flavors(t *testing.T, fn func(t *testing.T, srv *forgetest.Server, f forge.Forge)) { + cases := []struct { + name string + flavor forgetest.Flavor + }{ + {"gitea", forgetest.Gitea}, + {"github", forgetest.GitHub}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + srv := forgetest.NewServer(t, c.flavor) + fn(t, srv, clientFor(srv)) + }) + } +} + +func clientFor(srv *forgetest.Server) forge.Forge { + if srv.Flavor == forgetest.GitHub { + return forge.NewGitHub(srv.URL, srv.Token, forgetest.Owner, forgetest.Repo) + } + return forge.NewGitea(srv.URL, srv.Token, forgetest.Owner, forgetest.Repo) +} + +func TestKindMatchesConstructor(t *testing.T) { + if k := forge.NewGitea("https://g.example.com", "t", "o", "r").Kind(); k != forge.KindGitea { + t.Fatalf("NewGitea().Kind() = %s", k) + } + if k := forge.NewGitHub(forge.GitHubAPIBaseURL, "t", "o", "r").Kind(); k != forge.KindGitHub { + t.Fatalf("NewGitHub().Kind() = %s", k) + } +} + +func TestCurrentUserLogin(t *testing.T) { + flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) { + login, err := f.CurrentUserLogin() + if err != nil || login != "alice" { + t.Fatalf("CurrentUserLogin() = (%q, %v)", login, err) + } + }) +} + +func TestWrongTokenSurfacesHTTPError(t *testing.T) { + flavors(t, func(t *testing.T, srv *forgetest.Server, _ forge.Forge) { + var f forge.Forge + if srv.Flavor == forgetest.GitHub { + f = forge.NewGitHub(srv.URL, "wrong", forgetest.Owner, forgetest.Repo) + } else { + f = forge.NewGitea(srv.URL, "wrong", forgetest.Owner, forgetest.Repo) + } + _, err := f.CurrentUserLogin() + if err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("错误 token 应返回带 401 的错误,得到 %v", err) + } + }) +} + +func TestCreateGetAndUpdateIssue(t *testing.T) { + flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) { + created, err := f.CreateIssue("标题", "正文", []string{"alice"}) + if err != nil { + t.Fatalf("CreateIssue 出错: %v", err) + } + if created.Number <= 0 || created.HTMLURL == "" { + t.Fatalf("CreateIssue 返回不完整: %+v", created) + } + if err := f.UpdateIssueBody(created.Number, "新正文"); err != nil { + t.Fatalf("UpdateIssueBody 出错: %v", err) + } + got, err := f.GetIssue(created.Number) + if err != nil { + t.Fatalf("GetIssue 出错: %v", err) + } + want := &forge.Issue{ + Number: created.Number, + Title: "标题", + Body: "新正文", + State: "open", + Author: "alice", + Assignees: []string{"alice"}, + HTMLURL: fmt.Sprintf("%s/acme/widgets/issues/%d", srv.URL, created.Number), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("GetIssue = %+v\nwant %+v", got, want) + } + }) +} + +func TestGetIssueWithoutAssigneesGivesEmptyList(t *testing.T) { + flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) { + n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"}) + got, err := f.GetIssue(n) + if err != nil { + t.Fatalf("GetIssue 出错: %v", err) + } + if got.Assignees == nil || len(got.Assignees) != 0 { + t.Fatalf("没有指派人时应是非 nil 空切片,得到 %#v", got.Assignees) + } + }) +} + +func TestCommentsRoundTrip(t *testing.T) { + flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) { + n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"}) + srv.AddComment(n, "bob", "第一条") + posted, err := f.CreateIssueComment(n, "第二条") + if err != nil { + t.Fatalf("CreateIssueComment 出错: %v", err) + } + if posted.ID == 0 || posted.HTMLURL == "" || posted.Author != "alice" { + t.Fatalf("CreateIssueComment 返回不完整: %+v", posted) + } + list, err := f.ListIssueComments(n) + if err != nil { + t.Fatalf("ListIssueComments 出错: %v", err) + } + if len(list) != 2 || list[0].Body != "第一条" || list[0].Author != "bob" || list[1].Body != "第二条" { + t.Fatalf("评论列表不对: %+v", list) + } + if list[0].CreatedAt == "" { + t.Fatalf("评论应带 created_at: %+v", list[0]) + } + }) +} + +func TestListIssueCommentsReadsEveryPageOnGitHub(t *testing.T) { + srv := forgetest.NewServer(t, forgetest.GitHub) + f := clientFor(srv) + n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"}) + for i := 1; i <= 250; i++ { + srv.AddComment(n, "bob", fmt.Sprintf("c%d", i)) + } + list, err := f.ListIssueComments(n) + if err != nil { + t.Fatalf("ListIssueComments 出错: %v", err) + } + if len(list) != 250 || list[0].Body != "c1" || list[249].Body != "c250" { + t.Fatalf("应读全 250 条且保持顺序,得到 %d 条", len(list)) + } + var commentCalls []string + for _, r := range srv.Requests() { + if strings.Contains(r, "/comments") { + commentCalls = append(commentCalls, r) + } + } + if len(commentCalls) != 3 { + t.Fatalf("250 条应按 per_page=100 分 3 页取,实际请求: %v", commentCalls) + } + if !strings.Contains(commentCalls[0], "per_page=100") { + t.Fatalf("第一页应带 per_page=100: %s", commentCalls[0]) + } +} + +func TestGetPullRequest(t *testing.T) { + flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) { + n := srv.AddPull(forgetest.Pull{ + Title: "加功能", Body: "说明", State: "closed", Merged: true, Author: "bob", + HeadBranch: "feat/x", HeadSHA: "aaa111", BaseBranch: "main", BaseSHA: "bbb222", + }) + got, err := f.GetPullRequest(n) + if err != nil { + t.Fatalf("GetPullRequest 出错: %v", err) + } + if got.Number != n || got.Title != "加功能" || got.Body != "说明" || got.State != "closed" || !got.Merged || got.Author != "bob" { + t.Fatalf("PR 字段不对: %+v", got) + } + if got.Head != (forge.BranchRef{Branch: "feat/x", SHA: "aaa111"}) || got.Base != (forge.BranchRef{Branch: "main", SHA: "bbb222"}) { + t.Fatalf("head/base 不对: %+v / %+v", got.Head, got.Base) + } + if got.HTMLURL == "" { + t.Fatal("PR 应带 html_url") + } + }) +} + +func TestGetPullRequestReportsHeadBranchAfterItIsDeleted(t *testing.T) { + flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) { + n := srv.AddPull(forgetest.Pull{ + Title: "t", State: "closed", Merged: true, Author: "bob", + HeadBranch: "feat/gone", HeadSHA: "aaa111", BaseBranch: "main", BaseSHA: "bbb222", HeadDeleted: true, + }) + got, err := f.GetPullRequest(n) + if err != nil { + t.Fatalf("GetPullRequest 出错: %v", err) + } + if got.Head.Branch != "feat/gone" || got.Base.Branch != "main" { + t.Fatalf("head 分支删掉后仍应报分支名,得到 head=%+v base=%+v", got.Head, got.Base) + } + }) +} + +func TestCreatePullRequestAndDefaultBranch(t *testing.T) { + flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) { + srv.DefaultBranch = "trunk" + base, err := f.DefaultBranch() + if err != nil || base != "trunk" { + t.Fatalf("DefaultBranch() = (%q, %v)", base, err) + } + pr, err := f.CreatePullRequest(forge.NewPullRequest{Title: "T", Body: "B", Head: "feat/y", Base: base}) + if err != nil { + t.Fatalf("CreatePullRequest 出错: %v", err) + } + stored, ok := srv.Pull(pr.Number) + if !ok || stored.Title != "T" || stored.Body != "B" || stored.HeadBranch != "feat/y" || stored.BaseBranch != "trunk" { + t.Fatalf("服务端收到的 PR 不对: %+v", stored) + } + if pr.HTMLURL == "" || pr.Head.Branch != "feat/y" || pr.Base.Branch != "trunk" { + t.Fatalf("CreatePullRequest 返回不完整: %+v", pr) + } + }) +} + +func TestPullRequestCommentsUseIssueCommentsEndpoint(t *testing.T) { + flavors(t, func(t *testing.T, srv *forgetest.Server, f forge.Forge) { + n := srv.AddPull(forgetest.Pull{Title: "t", Author: "bob", HeadBranch: "h", BaseBranch: "main"}) + if _, err := f.CreateIssueComment(n, "看过了"); err != nil { + t.Fatalf("CreateIssueComment 出错: %v", err) + } + reqs := srv.Requests() + prefix := "" + if srv.Flavor == forgetest.Gitea { + prefix = "/api/v1" + } + want := fmt.Sprintf("POST %s/repos/acme/widgets/issues/%d/comments", prefix, n) + if reqs[len(reqs)-1] != want { + t.Fatalf("PR 评论应走 issues comments 接口,实际 %s,want %s", reqs[len(reqs)-1], want) + } + }) +} diff --git a/vscode/cli/internal/gitea/client.go b/vscode/cli/internal/gitea/client.go deleted file mode 100644 index 3542444..0000000 --- a/vscode/cli/internal/gitea/client.go +++ /dev/null @@ -1,151 +0,0 @@ -// Package gitea is a minimal Gitea REST client for the three calls spx needs: -// create issue, get/update issue body, create issue comment. -package gitea - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// Client talks to a Gitea instance using a personal access token. -type Client struct { - BaseURL string // host without trailing slash, e.g. "https://gitea.example.com" - Token string - HTTPClient *http.Client -} - -// New builds a Client with sane defaults. -func New(baseURL, token string) *Client { - return &Client{ - BaseURL: strings.TrimRight(baseURL, "/"), - Token: token, - HTTPClient: &http.Client{Timeout: 30 * time.Second}, - } -} - -// Issue is the subset of Gitea's Issue object spx cares about. -type Issue struct { - Number int `json:"number"` - Body string `json:"body"` - HTMLURL string `json:"html_url"` -} - -// Comment is the subset of Gitea's Comment object spx cares about. -type Comment struct { - ID int64 `json:"id"` - HTMLURL string `json:"html_url"` - Body string `json:"body"` -} - -// do executes an HTTP request with auth + JSON content type and decodes the -// response into out (if non-nil and the body is non-empty). -func (c *Client) do(method, path string, payload any, out any) error { - var body io.Reader - if payload != nil { - buf, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("编码请求体失败: %w", err) - } - body = bytes.NewReader(buf) - } - url := c.BaseURL + path - req, err := http.NewRequest(method, url, body) - if err != nil { - return fmt.Errorf("构造请求失败: %w", err) - } - req.Header.Set("Authorization", "token "+c.Token) - req.Header.Set("Accept", "application/json") - if payload != nil { - req.Header.Set("Content-Type", "application/json") - } - resp, err := c.HTTPClient.Do(req) - if err != nil { - return fmt.Errorf("%s %s 请求失败: %w", method, url, err) - } - defer resp.Body.Close() - respBytes, _ := io.ReadAll(resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s %s 返回 %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(respBytes))) - } - if out == nil || len(respBytes) == 0 { - return nil - } - if err := json.Unmarshal(respBytes, out); err != nil { - return fmt.Errorf("解析响应失败 (%s %s): %w; body=%s", method, url, err, string(respBytes)) - } - return nil -} - -// CurrentUserLogin resolves the token owner's login. -func (c *Client) CurrentUserLogin() (string, error) { - var out struct { - Login string `json:"login"` - } - if err := c.do(http.MethodGet, "/api/v1/user", nil, &out); err != nil { - return "", err - } - return out.Login, nil -} - -// CreateIssue creates a new issue and returns the created object. -func (c *Client) CreateIssue(owner, repo, title, body string, assignees []string) (*Issue, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo) - payload := map[string]any{"title": title, "body": body} - if len(assignees) > 0 { - payload["assignees"] = assignees - } - var out Issue - if err := c.do(http.MethodPost, path, payload, &out); err != nil { - return nil, err - } - return &out, nil -} - -// GetIssue fetches an existing issue (or PR — Gitea treats PRs as issues for -// metadata purposes). -func (c *Client) GetIssue(owner, repo string, number int) (*Issue, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number) - var out Issue - if err := c.do(http.MethodGet, path, nil, &out); err != nil { - return nil, err - } - return &out, nil -} - -// UpdateIssueBody patches an issue's body field. -func (c *Client) UpdateIssueBody(owner, repo string, number int, body string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number) - payload := map[string]any{"body": body} - return c.do(http.MethodPatch, path, payload, nil) -} - -// ListIssueComments returns the issue/PR comments in Gitea's default order -// (ascending by creation time). Used by `spx issue state get|merge` to find -// the last comment, which is where the state JSON blob lives. -func (c *Client) ListIssueComments(owner, repo string, number int) ([]Comment, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number) - var out []Comment - if err := c.do(http.MethodGet, path, nil, &out); err != nil { - return nil, err - } - return out, nil -} - -// CreateIssueComment posts a comment on an issue or PR. -// -// In Gitea, PR comments (not review threads) live under the same endpoint as -// issue comments — pass the PR number as the issue number. -func (c *Client) CreateIssueComment(owner, repo string, number int, body string) (*Comment, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number) - payload := map[string]any{"body": body} - var out Comment - if err := c.do(http.MethodPost, path, payload, &out); err != nil { - return nil, err - } - return &out, nil -} diff --git a/vscode/cli/internal/repo/origin.go b/vscode/cli/internal/repo/origin.go index 05b25a6..1f4301c 100644 --- a/vscode/cli/internal/repo/origin.go +++ b/vscode/cli/internal/repo/origin.go @@ -1,5 +1,5 @@ -// Package repo detects the owner/repo pair from a git working directory's -// origin remote. +// Package repo reads where a repo lives, its forge host and owner/repo pair, +// from a git working directory's origin remote. package repo import ( @@ -16,7 +16,17 @@ import ( // url.Parse instead. var sshLikeRegex = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+?)/?$`) -// DetectOwnerRepo runs `git -C cwd remote get-url origin` and parses the URL. +// Origin is where a repo lives: the forge host, the forge's web root and the owner/repo pair. +type Origin struct { + // Host is the lowercase hostname without port, e.g. `github.com`; it decides which forge to talk to. + Host string + // WebURL is the forge's web root such as `https://gitea.example.com`, the best guess at where its API sits. + WebURL string + Owner string + Repo string +} + +// DetectOrigin runs `git -C cwd remote get-url origin` and reads which host and owner/repo the repo lives at. // // Supported formats: // - https://host/owner/repo(.git) @@ -26,37 +36,78 @@ var sshLikeRegex = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+?)/?$`) // // owner may include subgroups (e.g. "group/subgroup") if the host supports it; // repo is the last path segment without the .git suffix. -func DetectOwnerRepo(cwd string) (owner, repo string, err error) { +func DetectOrigin(cwd string) (Origin, error) { cmd := exec.Command("git", "-C", cwd, "remote", "get-url", "origin") out, err := cmd.Output() if err != nil { - return "", "", fmt.Errorf("git remote get-url origin 失败 (cwd=%s): %w", cwd, err) + return Origin{}, fmt.Errorf("git remote get-url origin 失败 (cwd=%s): %w", cwd, err) } raw := strings.TrimSpace(string(out)) if raw == "" { - return "", "", fmt.Errorf("origin remote 是空的 (cwd=%s)", cwd) + return Origin{}, fmt.Errorf("origin remote 是空的 (cwd=%s)", cwd) } return parseRemoteURL(raw) } // parseRemoteURL is the pure-logic core of DetectOwnerRepo, extracted for testing. -func parseRemoteURL(raw string) (owner, repo string, err error) { +// parseRemoteURL is the pure-logic core of [DetectOrigin], extracted for testing. +// +// An SSH remote says nothing about the web side, so its `WebURL` is a guess of `https://`. +func parseRemoteURL(raw string) (Origin, error) { // Try scp-like ssh: `git@host:owner/repo.git` (only if not URI-shaped). if !strings.Contains(raw, "://") { if m := sshLikeRegex.FindStringSubmatch(raw); m != nil { - return splitOwnerRepo(m[2]) + return newOrigin(m[1], "https://"+m[1], m[2]) } } // Try URL form (http/https/ssh://). u, perr := url.Parse(raw) if perr != nil { - return "", "", fmt.Errorf("无法解析 origin URL %q: %w", raw, perr) + return Origin{}, fmt.Errorf("无法解析 origin URL %q: %w", raw, perr) + } + if u.Hostname() == "" { + return Origin{}, fmt.Errorf("origin URL %q 没有 host 部分", raw) } path := strings.TrimPrefix(u.Path, "/") if path == "" { - return "", "", fmt.Errorf("origin URL %q 没有 path 部分", raw) + return Origin{}, fmt.Errorf("origin URL %q 没有 path 部分", raw) } - return splitOwnerRepo(path) + webURL := "https://" + u.Hostname() + if u.Scheme == "http" || u.Scheme == "https" { + webURL = u.Scheme + "://" + u.Host + } + return newOrigin(u.Hostname(), webURL, path) +} + +// newOrigin assembles an [Origin] from a remote's host, web root and `owner/repo(.git)` path, lowercasing the host. +func newOrigin(host, webURL, path string) (Origin, error) { + owner, repo, err := splitOwnerRepo(path) + if err != nil { + return Origin{}, err + } + return Origin{Host: strings.ToLower(host), WebURL: strings.ToLower(webURL), Owner: owner, Repo: repo}, nil +} + +// ParseHost reads a `--host` value, either a bare host like `github.com` or a URL like `https://gitea.example.com/`. +// +// @return the lowercase hostname without port, used to pick the forge, and the web root to call, which is the URL +// itself without its trailing slash, or `https://` for a bare host +func ParseHost(value string) (host, webURL string, err error) { + v := strings.TrimSpace(value) + if v == "" { + return "", "", fmt.Errorf("--host 不能为空") + } + if !strings.Contains(v, "://") { + v = "https://" + v + } + u, err := url.Parse(v) + if err != nil { + return "", "", fmt.Errorf("无法解析 --host %q: %w", value, err) + } + if u.Hostname() == "" { + return "", "", fmt.Errorf("--host %q 里没有主机名", value) + } + return strings.ToLower(u.Hostname()), u.Scheme + "://" + strings.ToLower(u.Host) + strings.TrimRight(u.Path, "/"), nil } // splitOwnerRepo turns "owner/repo(.git)" into ("owner", "repo"). diff --git a/vscode/cli/internal/repo/origin_test.go b/vscode/cli/internal/repo/origin_test.go index 0311e35..a132ce4 100644 --- a/vscode/cli/internal/repo/origin_test.go +++ b/vscode/cli/internal/repo/origin_test.go @@ -1,26 +1,33 @@ package repo -import "testing" +import ( + "os/exec" + "testing" +) func TestParseRemoteURL(t *testing.T) { cases := []struct { - raw, owner, repo string + raw, host, webURL, owner, repo string }{ - {"https://gitea.example.com/foo/bar.git", "foo", "bar"}, - {"https://gitea.example.com/foo/bar", "foo", "bar"}, - {"http://localhost:3000/foo/bar.git", "foo", "bar"}, - {"git@gitea.example.com:foo/bar.git", "foo", "bar"}, - {"git@gitea.example.com:foo/bar", "foo", "bar"}, - {"ssh://git@gitea.example.com:22/foo/bar.git", "foo", "bar"}, + {"https://gitea.example.com/foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"}, + {"https://gitea.example.com/foo/bar", "gitea.example.com", "https://gitea.example.com", "foo", "bar"}, + {"https://user:secret@gitea.example.com/foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"}, + {"http://localhost:3000/foo/bar.git", "localhost", "http://localhost:3000", "foo", "bar"}, + {"https://GitHub.com/Foo/Bar.git", "github.com", "https://github.com", "Foo", "Bar"}, + {"git@gitea.example.com:foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"}, + {"git@gitea.example.com:foo/bar", "gitea.example.com", "https://gitea.example.com", "foo", "bar"}, + {"git@github.com:foo/bar.git", "github.com", "https://github.com", "foo", "bar"}, + {"ssh://git@gitea.example.com:22/foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"}, } for _, c := range cases { t.Run(c.raw, func(t *testing.T) { - owner, repo, err := parseRemoteURL(c.raw) + o, err := parseRemoteURL(c.raw) if err != nil { t.Fatalf("unexpected error: %v", err) } - if owner != c.owner || repo != c.repo { - t.Fatalf("got %s/%s want %s/%s", owner, repo, c.owner, c.repo) + want := Origin{Host: c.host, WebURL: c.webURL, Owner: c.owner, Repo: c.repo} + if o != want { + t.Fatalf("got %+v want %+v", o, want) } }) } @@ -34,8 +41,68 @@ func TestParseRemoteURLRejectsBadInput(t *testing.T) { "https://gitea.example.com/onlyowner", } for _, raw := range bad { - if _, _, err := parseRemoteURL(raw); err == nil { + if _, err := parseRemoteURL(raw); err == nil { t.Fatalf("expected error for %q", raw) } } } + +func TestParseHost(t *testing.T) { + cases := []struct { + in, host, webURL string + }{ + {"github.com", "github.com", "https://github.com"}, + {"GitHub.com", "github.com", "https://github.com"}, + {"gitea.example.com/", "gitea.example.com", "https://gitea.example.com"}, + {"https://gitea.example.com/", "gitea.example.com", "https://gitea.example.com"}, + {"http://localhost:3000", "localhost", "http://localhost:3000"}, + {"https://example.com/gitea/", "example.com", "https://example.com/gitea"}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + host, webURL, err := ParseHost(c.in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != c.host || webURL != c.webURL { + t.Fatalf("got (%s, %s) want (%s, %s)", host, webURL, c.host, c.webURL) + } + }) + } + for _, bad := range []string{"", " ", "https://", "https:///foo"} { + if _, _, err := ParseHost(bad); err == nil { + t.Fatalf("expected error for %q", bad) + } + } +} + +func TestDetectOriginReadsGitRemote(t *testing.T) { + dir := t.TempDir() + for _, args := range [][]string{ + {"init", "-q"}, + {"remote", "add", "origin", "git@github.com:acme/widgets.git"}, + } { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v 失败: %v: %s", args, err, out) + } + } + o, err := DetectOrigin(dir) + if err != nil { + t.Fatalf("DetectOrigin 出错: %v", err) + } + want := Origin{Host: "github.com", WebURL: "https://github.com", Owner: "acme", Repo: "widgets"} + if o != want { + t.Fatalf("got %+v want %+v", o, want) + } +} + +func TestDetectOriginFailsWithoutRemote(t *testing.T) { + dir := t.TempDir() + if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil { + t.Fatalf("git init 失败: %v: %s", err, out) + } + if _, err := DetectOrigin(dir); err == nil { + t.Fatal("没有 origin 时应当报错") + } +} diff --git a/vscode/cli/internal/state/comment.go b/vscode/cli/internal/state/comment.go new file mode 100644 index 0000000..04e53b2 --- /dev/null +++ b/vscode/cli/internal/state/comment.go @@ -0,0 +1,81 @@ +package state + +import ( + "encoding/json" + "fmt" + "strings" + "sync" +) + +// The state comment is wrapped in an HTML comment so the forge's web page hides it from readers. +const ( + commentPrefix = "" + // legacyMarker heads the state comments the YouTrack integration wrote before the hidden format existed. + legacyMarker = "" +) + +// knownFields lists the top-level keys of the embedded schema, so a comment counts as state only if it uses a real state key. +var knownFields = sync.OnceValue(func() map[string]struct{} { + var doc struct { + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(schemaBytes, &doc); err != nil || len(doc.Properties) == 0 { + panic(fmt.Sprintf("内嵌 schema 没有可用的 properties: %v", err)) + } + set := make(map[string]struct{}, len(doc.Properties)) + for name := range doc.Properties { + set[name] = struct{}{} + } + return set +}) + +// FormatComment renders `s` as a comment body that the forge's web page hides: ``. +// +// [json.Marshal] escapes `<`, `>` and `&` as `\u003c`, `\u003e` and `\u0026`, so no value can close the HTML comment early. +func FormatComment(s map[string]any) (string, error) { + buf, err := json.Marshal(s) + if err != nil { + return "", fmt.Errorf("序列化 state JSON 失败: %w", err) + } + return commentPrefix + "\n" + string(buf) + "\n" + commentSuffix, nil +} + +// ParseComment reads the state out of a comment body, or reports `false` when the body is not a state comment. +// +// It accepts the hidden format [FormatComment] writes, the older YouTrack form (`` then JSON) and an +// older bare JSON object. The JSON must be an object carrying at least one schema field, so ordinary chatter that +// happens to be JSON is not mistaken for state. +func ParseComment(body string) (map[string]any, bool) { + text := strings.TrimSpace(body) + switch { + case strings.HasPrefix(text, legacyMarker): + text = text[len(legacyMarker):] + case strings.HasPrefix(text, commentPrefix) && strings.HasSuffix(text, commentSuffix) && + len(text) >= len(commentPrefix)+len(commentSuffix): + text = text[len(commentPrefix) : len(text)-len(commentSuffix)] + } + var m map[string]any + if err := json.Unmarshal([]byte(text), &m); err != nil || m == nil { + return nil, false + } + fields := knownFields() + for k := range m { + if _, ok := fields[k]; ok { + return m, true + } + } + return nil, false +} + +// Latest returns the state from the newest state comment among `bodies` (oldest first), or an empty map when there is none. +// +// Scanning from the end means comments posted after the state, such as review notes or chatter, never hide it. +func Latest(bodies []string) map[string]any { + for i := len(bodies) - 1; i >= 0; i-- { + if m, ok := ParseComment(bodies[i]); ok { + return m + } + } + return map[string]any{} +} diff --git a/vscode/cli/internal/state/comment_test.go b/vscode/cli/internal/state/comment_test.go new file mode 100644 index 0000000..bee06ad --- /dev/null +++ b/vscode/cli/internal/state/comment_test.go @@ -0,0 +1,131 @@ +package state + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func TestFormatCommentWrapsCompactJSONInHiddenComment(t *testing.T) { + body, err := FormatComment(map[string]any{"column": "review", "branch": "feat/x"}) + if err != nil { + t.Fatalf("FormatComment 出错: %v", err) + } + want := "" + if body != want { + t.Fatalf("got %q\nwant %q", body, want) + } +} + +func TestFormatCommentEscapesCommentTerminators(t *testing.T) { + raw := "a --> b ") + if inner == body { + t.Fatalf("输出不是隐藏注释格式: %q", body) + } + if strings.Contains(inner, "-->") || strings.ContainsAny(inner, "<>&") { + t.Fatalf("JSON 正文里不应出现 `-->` 或裸的 < > &: %q", inner) + } + for _, esc := range []string{`\u003c`, `\u003e`, `\u0026`} { + if !strings.Contains(inner, esc) { + t.Fatalf("JSON 正文应把字符转义成 %s: %q", esc, inner) + } + } + got, ok := ParseComment(body) + if !ok { + t.Fatalf("自己写出的评论应能读回: %q", body) + } + if got["handoffRequestError"] != raw { + t.Fatalf("往返后值变了: got %q want %q", got["handoffRequestError"], raw) + } +} + +func TestParseCommentAcceptsAllThreeFormats(t *testing.T) { + cases := map[string]string{ + "隐藏注释": "", + "隐藏注释带首尾空白": " \n\n ", + "旧 YouTrack 格式": "\n{\"column\":\"review\"}", + "旧裸 JSON": `{"column":"review"}`, + "旧裸 JSON 带空白": "\n {\"column\":\"review\"} \n", + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + m, ok := ParseComment(body) + if !ok { + t.Fatalf("应当识别为 state 评论: %q", body) + } + if m["column"] != "review" { + t.Fatalf("column 解析错误: %v", m) + } + }) + } +} + +func TestParseCommentRejectsNonStateBodies(t *testing.T) { + bad := []string{ + "", + "LGTM", + `{"foo":1}`, + `["column"]`, + `null`, + `"column"`, + "", + "", + "\n{\"foo\":1}", + "状态如下: {\"column\":\"review\"}", + "\n\nLGTM", + } + for _, body := range bad { + if m, ok := ParseComment(body); ok { + t.Fatalf("不应识别为 state 评论: %q -> %v", body, m) + } + } +} + +func TestParseCommentKnowsEverySchemaProperty(t *testing.T) { + var doc struct { + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(schemaBytes, &doc); err != nil { + t.Fatalf("解析内嵌 schema 失败: %v", err) + } + if len(doc.Properties) == 0 { + t.Fatal("内嵌 schema 没有 properties") + } + for name := range doc.Properties { + body := fmt.Sprintf(`{%q:null}`, name) + if _, ok := ParseComment(body); !ok { + t.Errorf("schema 字段 %s 应当被识别为 state 字段", name) + } + } +} + +func TestLatestPicksNewestValidStateComment(t *testing.T) { + bodies := []string{ + `{"column":"todo","branch":"old"}`, + "\n{\"column\":\"in-progress\"}", + "", + "LGTM", + `{"unrelated":true}`, + } + got := Latest(bodies) + if got["column"] != "review" || got["branch"] != "new" { + t.Fatalf("应取最新一条有效 state,得到 %v", got) + } +} + +func TestLatestReturnsEmptyMapWhenNoState(t *testing.T) { + got := Latest([]string{"hello", `{"x":1}`}) + if got == nil || len(got) != 0 { + t.Fatalf("没有 state 时应返回非 nil 空 map,得到 %#v", got) + } + if got := Latest(nil); got == nil { + t.Fatal("空评论列表也应返回非 nil 空 map") + } +} diff --git a/vscode/cli/internal/tea/config.go b/vscode/cli/internal/tea/config.go index 402e452..6483e37 100644 --- a/vscode/cli/internal/tea/config.go +++ b/vscode/cli/internal/tea/config.go @@ -1,16 +1,18 @@ -// Package tea reads the tea CLI config to extract Gitea host + token. +// Package tea reads the tea CLI config to find the Gitea login, and its token, for a given host. // // tea config typically lives at $HOME/.config/tea/config.yml. Older versions // of tea (