✨ feat(spx): 按 git origin 支持 GitHub 仓库,state 评论改成隐藏注释格式,新增 issue view / pr view / pr create
This commit is contained in:
1 parent
64d5961b62
commit
c90d84d7f2
17 files changed
+2677
-394
No files matched your search
+32
-5
@@ -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 `<host>/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`,保证正文里不会出现 `-->`:
|
||||
|
||||
```
|
||||
<!-- spx-state
|
||||
{"column":"review","specFile":"docs/specs/foo.md"}
|
||||
-->
|
||||
```
|
||||
|
||||
读取时从最新一条评论往前找,第一条有效的胜出。兼容三种形式:上面的隐藏格式、旧 YouTrack 格式(`<!--spx-state-->` 后接 JSON)、旧的裸 JSON 对象。内容必须是 JSON 对象,且至少带一个 schema 里声明的字段。
|
||||
+291
-170
@@ -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
|
||||
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。",
|
||||
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 state.Latest(bodies), nil
|
||||
}
|
||||
return map[string]any{}, 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()
|
||||
// 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
|
||||
}
|
||||
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 环境变量也没设置")
|
||||
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)
|
||||
o.Owner, o.Repo = owner, name
|
||||
}
|
||||
owner, repoName = o, r
|
||||
}
|
||||
|
||||
return &runtimeContext{
|
||||
Client: gitea.New(host, token),
|
||||
Owner: owner,
|
||||
Repo: repoName,
|
||||
Host: host,
|
||||
JSON: flagJSON,
|
||||
}, nil
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// fromCtx extracts the runtimeContext set up by PersistentPreRunE.
|
||||
@@ -610,9 +731,9 @@ func fromCtx(cmd *cobra.Command) *runtimeContext {
|
||||
return v.(*runtimeContext)
|
||||
}
|
||||
|
||||
// emitJSON writes v as a single-line JSON object to stdout.
|
||||
func emitJSON(v any) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
// emitJSON writes v as a single-line JSON object to w.
|
||||
func emitJSON(w io.Writer, v any) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cruldra/superpowers-vscode/cli/internal/forge"
|
||||
"github.com/cruldra/superpowers-vscode/cli/internal/forge/forgetest"
|
||||
"github.com/cruldra/superpowers-vscode/cli/internal/repo"
|
||||
)
|
||||
|
||||
// flavors runs fn once against a fake Gitea and once against a fake GitHub.
|
||||
func flavors(t *testing.T, fn func(t *testing.T, srv *forgetest.Server)) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
flavor forgetest.Flavor
|
||||
}{{"gitea", forgetest.Gitea}, {"github", forgetest.GitHub}} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
fn(t, forgetest.NewServer(t, c.flavor))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// runSpx executes the spx command tree with args against srv and returns what it printed to stdout.
|
||||
func runSpx(t *testing.T, srv *forgetest.Server, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
root := buildRootCmd(func() (forge.Forge, error) {
|
||||
if srv.Flavor == forgetest.GitHub {
|
||||
return forge.NewGitHub(srv.URL, srv.Token, forgetest.Owner, forgetest.Repo), nil
|
||||
}
|
||||
return forge.NewGitea(srv.URL, srv.Token, forgetest.Owner, forgetest.Repo), nil
|
||||
})
|
||||
var out, errOut bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&errOut)
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, srv *forgetest.Server, args ...string) string {
|
||||
t.Helper()
|
||||
out, err := runSpx(t, srv, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("spx %s 失败: %v", strings.Join(args, " "), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, s string) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
t.Fatalf("输出不是 JSON 对象: %v\n%s", err, s)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func lastComment(t *testing.T, srv *forgetest.Server, n int) string {
|
||||
t.Helper()
|
||||
cs := srv.Comments(n)
|
||||
if len(cs) == 0 {
|
||||
t.Fatalf("#%d 没有评论", n)
|
||||
}
|
||||
return cs[len(cs)-1].Body
|
||||
}
|
||||
|
||||
func TestIssueStateMergeWritesHiddenStateComment(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
||||
srv.AddComment(n, "bob", `{"column":"todo","branch":"b1"}`)
|
||||
srv.AddComment(n, "bob", "LGTM")
|
||||
out := mustRun(t, srv, "issue", "state", "merge", "--issue", fmt.Sprint(n), "--state-json", `{"column":"review","handoffRequestError":"a --> b"}`)
|
||||
want := "<!-- spx-state\n{\"branch\":\"b1\",\"column\":\"review\",\"handoffRequestError\":\"a --\\u003e b\"}\n-->"
|
||||
if got := lastComment(t, srv, n); got != want {
|
||||
t.Fatalf("写出的 state 评论不对:\ngot %q\nwant %q", got, want)
|
||||
}
|
||||
m := decodeJSON(t, out)
|
||||
if m["column"] != "review" || m["branch"] != "b1" || m["handoffRequestError"] != "a --> b" {
|
||||
t.Fatalf("merge 输出不对: %v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueStateGetReadsEveryFormat(t *testing.T) {
|
||||
bodies := map[string]string{
|
||||
"隐藏注释": "<!-- spx-state\n{\"column\":\"review\"}\n-->",
|
||||
"旧 YouTrack 格式": "<!--spx-state-->\n{\"column\":\"review\"}",
|
||||
"旧裸 JSON": `{"column":"review"}`,
|
||||
}
|
||||
for name, body := range bodies {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
||||
srv.AddComment(n, "bob", `{"column":"todo"}`)
|
||||
srv.AddComment(n, "bob", body)
|
||||
srv.AddComment(n, "bob", "普通评论")
|
||||
out := mustRun(t, srv, "issue", "state", "get", "--issue", fmt.Sprint(n))
|
||||
if strings.TrimSpace(out) != `{"column":"review"}` {
|
||||
t.Fatalf("state get 输出不对: %q", out)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueStateGetFindsStateOnLaterGitHubPage(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.GitHub)
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
||||
srv.AddComment(n, "bob", `{"column":"todo"}`)
|
||||
for i := 0; i < 220; i++ {
|
||||
srv.AddComment(n, "bob", fmt.Sprintf("聊天 %d", i))
|
||||
}
|
||||
srv.AddComment(n, "bob", "<!-- spx-state\n{\"column\":\"done\"}\n-->")
|
||||
srv.AddComment(n, "bob", "收尾")
|
||||
out := mustRun(t, srv, "issue", "state", "get", "--issue", fmt.Sprint(n))
|
||||
if strings.TrimSpace(out) != `{"column":"done"}` {
|
||||
t.Fatalf("应读到第 3 页上的最新 state,得到 %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueStateGetEmptyIssuePrintsEmptyObject(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.GitHub)
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
||||
out := mustRun(t, srv, "issue", "state", "get", "--issue", fmt.Sprint(n))
|
||||
if strings.TrimSpace(out) != `{}` {
|
||||
t.Fatalf("没有 state 时应输出 {},得到 %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCreateAssignsSelfAndWritesHiddenState(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
out := mustRun(t, srv, "issue", "create", "--title", "新功能", "--body", "正文",
|
||||
"--spec", "docs/superpowers/specs/x.md", "--state-json", `{"column":"todo"}`, "--json")
|
||||
m := decodeJSON(t, out)
|
||||
n := int(m["number"].(float64))
|
||||
is, ok := srv.Issue(n)
|
||||
if !ok || is.Title != "新功能" || !reflect.DeepEqual(is.Assignees, []string{"alice"}) {
|
||||
t.Fatalf("建出的工单不对: %+v", is)
|
||||
}
|
||||
if !strings.Contains(is.Body, "<!-- spx:spec=docs/superpowers/specs/x.md -->") {
|
||||
t.Fatalf("正文应带 spec marker: %q", is.Body)
|
||||
}
|
||||
want := "<!-- spx-state\n{\"column\":\"todo\",\"specFile\":\"docs/superpowers/specs/x.md\"}\n-->"
|
||||
if got := lastComment(t, srv, n); got != want {
|
||||
t.Fatalf("state 评论不对:\ngot %q\nwant %q", got, want)
|
||||
}
|
||||
if m["html_url"] == "" || m["state"] == nil {
|
||||
t.Fatalf("--json 输出缺字段: %v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueMarkerUpdatesBodyAndMergesState(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "t", Body: "正文", Author: "bob"})
|
||||
srv.AddComment(n, "bob", "<!--spx-state-->\n{\"column\":\"in-progress\"}")
|
||||
mustRun(t, srv, "issue", "marker", "--issue", fmt.Sprint(n), "--type", "plan", "--value", "docs/superpowers/plans/p.md")
|
||||
is, _ := srv.Issue(n)
|
||||
if is.Body != "正文\n<!-- spx:plan=docs/superpowers/plans/p.md -->" {
|
||||
t.Fatalf("正文 marker 不对: %q", is.Body)
|
||||
}
|
||||
want := "<!-- spx-state\n{\"column\":\"in-progress\",\"planFile\":\"docs/superpowers/plans/p.md\"}\n-->"
|
||||
if got := lastComment(t, srv, n); got != want {
|
||||
t.Fatalf("state 评论不对:\ngot %q\nwant %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func seedViewIssue(srv *forgetest.Server) int {
|
||||
n := srv.AddIssue(forgetest.Issue{Title: "登录失败", Body: "复现步骤", Author: "bob", Assignees: []string{"alice", "carol"}})
|
||||
srv.AddComment(n, "bob", "第一条")
|
||||
srv.AddComment(n, "alice", "<!-- spx-state\n{\"column\":\"review\"}\n-->")
|
||||
srv.AddComment(n, "alice", `{"column":"todo"}`)
|
||||
srv.AddComment(n, "alice", "<!--spx-state-->\n{\"column\":\"done\"}")
|
||||
srv.AddComment(n, "carol", "第二条")
|
||||
return n
|
||||
}
|
||||
|
||||
func TestIssueViewJSONExcludesStateComments(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := seedViewIssue(srv)
|
||||
m := decodeJSON(t, mustRun(t, srv, "issue", "view", "--issue", fmt.Sprint(n), "--comments", "--json"))
|
||||
if m["number"] != float64(n) || m["title"] != "登录失败" || m["body"] != "复现步骤" || m["state"] != "open" || m["author"] != "bob" {
|
||||
t.Fatalf("工单字段不对: %v", m)
|
||||
}
|
||||
if !reflect.DeepEqual(m["assignees"], []any{"alice", "carol"}) {
|
||||
t.Fatalf("assignees 不对: %v", m["assignees"])
|
||||
}
|
||||
if !strings.HasSuffix(fmt.Sprint(m["html_url"]), fmt.Sprintf("/issues/%d", n)) {
|
||||
t.Fatalf("html_url 不对: %v", m["html_url"])
|
||||
}
|
||||
comments, _ := m["comments"].([]any)
|
||||
var got []string
|
||||
for _, c := range comments {
|
||||
cm := c.(map[string]any)
|
||||
got = append(got, fmt.Sprint(cm["author"], ":", cm["body"]))
|
||||
if cm["created_at"] == "" || cm["html_url"] == "" {
|
||||
t.Fatalf("评论缺字段: %v", cm)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{"bob:第一条", "carol:第二条"}) {
|
||||
t.Fatalf("应排除 3 条 state 评论,得到 %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueViewWithoutCommentsFlagOmitsComments(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.Gitea)
|
||||
n := seedViewIssue(srv)
|
||||
m := decodeJSON(t, mustRun(t, srv, "issue", "view", "--issue", fmt.Sprint(n), "--json"))
|
||||
if _, ok := m["comments"]; ok {
|
||||
t.Fatalf("不带 --comments 时不应输出 comments: %v", m)
|
||||
}
|
||||
for _, r := range srv.Requests() {
|
||||
if strings.Contains(r, "/comments") {
|
||||
t.Fatalf("不带 --comments 时不应拉评论: %s", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueViewHumanOutput(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.GitHub)
|
||||
n := seedViewIssue(srv)
|
||||
out := mustRun(t, srv, "issue", "view", "--issue", fmt.Sprint(n), "--comments")
|
||||
for _, want := range []string{fmt.Sprintf("#%d 登录失败", n), "open", "bob", "alice, carol", "复现步骤", "第一条", "第二条"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("输出应包含 %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "spx-state") || strings.Contains(out, `"column"`) {
|
||||
t.Fatalf("输出不应包含 state 评论:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRViewJSON(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := srv.AddPull(forgetest.Pull{
|
||||
Title: "加功能", Body: "说明", State: "closed", Merged: true, Author: "bob",
|
||||
HeadBranch: "feat/x", HeadSHA: "aaa111", BaseBranch: "main", BaseSHA: "bbb222",
|
||||
})
|
||||
m := decodeJSON(t, mustRun(t, srv, "pr", "view", "--pr", fmt.Sprint(n), "--json"))
|
||||
want := map[string]any{
|
||||
"number": float64(n), "title": "加功能", "body": "说明", "state": "closed", "merged": true, "author": "bob",
|
||||
"head": map[string]any{"branch": "feat/x", "sha": "aaa111"},
|
||||
"base": map[string]any{"branch": "main", "sha": "bbb222"},
|
||||
}
|
||||
for k, v := range want {
|
||||
if !reflect.DeepEqual(m[k], v) {
|
||||
t.Fatalf("pr view 字段 %s = %v, want %v", k, m[k], v)
|
||||
}
|
||||
}
|
||||
if m["html_url"] == "" || m["html_url"] == nil {
|
||||
t.Fatalf("pr view 缺 html_url: %v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPRViewHumanOutput(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.Gitea)
|
||||
n := srv.AddPull(forgetest.Pull{Title: "加功能", Body: "说明", Author: "bob", HeadBranch: "feat/x", HeadSHA: "aaa111", BaseBranch: "main", BaseSHA: "bbb222"})
|
||||
out := mustRun(t, srv, "pr", "view", "--pr", fmt.Sprint(n))
|
||||
for _, want := range []string{fmt.Sprintf("#%d 加功能", n), "feat/x", "aaa111", "main", "bbb222", "未合并", "说明"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("输出应包含 %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCreateDefaultsBaseToRepoDefaultBranch(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
srv.DefaultBranch = "trunk"
|
||||
m := decodeJSON(t, mustRun(t, srv, "pr", "create", "--title", "T", "--body", "B", "--head", "feat/y", "--json"))
|
||||
n := int(m["number"].(float64))
|
||||
p, ok := srv.Pull(n)
|
||||
if !ok || p.Title != "T" || p.Body != "B" || p.HeadBranch != "feat/y" || p.BaseBranch != "trunk" {
|
||||
t.Fatalf("建出的 PR 不对: %+v", p)
|
||||
}
|
||||
if !strings.HasSuffix(fmt.Sprint(m["html_url"]), fmt.Sprint(n)) {
|
||||
t.Fatalf("输出应带 PR 链接: %v", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPRCreateHonorsBaseAndBodyFile(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.GitHub)
|
||||
bodyFile := filepath.Join(t.TempDir(), "body.md")
|
||||
if err := os.WriteFile(bodyFile, []byte("文件正文"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := mustRun(t, srv, "pr", "create", "--title", "T", "--body-file", bodyFile, "--head", "feat/z", "--base", "release")
|
||||
p, ok := srv.Pull(1)
|
||||
if !ok || p.Body != "文件正文" || p.BaseBranch != "release" {
|
||||
t.Fatalf("建出的 PR 不对: %+v", p)
|
||||
}
|
||||
if strings.TrimSpace(out) != fmt.Sprintf("#1 %s/acme/widgets/pull/1", srv.URL) {
|
||||
t.Fatalf("人类可读输出应是编号加链接,得到 %q", out)
|
||||
}
|
||||
if _, err := os.Stat(bodyFile); err != nil {
|
||||
t.Fatalf("pr create 不应删除 --body-file: %v", err)
|
||||
}
|
||||
for _, r := range srv.Requests() {
|
||||
if strings.HasPrefix(r, "GET /repos/acme/widgets ") || r == "GET /repos/acme/widgets" {
|
||||
t.Fatalf("显式 --base 时不应再查默认分支: %v", srv.Requests())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCreateRequiresBody(t *testing.T) {
|
||||
srv := forgetest.NewServer(t, forgetest.GitHub)
|
||||
if _, err := runSpx(t, srv, "pr", "create", "--title", "T", "--head", "feat/z"); err == nil {
|
||||
t.Fatal("没有 --body/--body-file 时应报错")
|
||||
}
|
||||
if len(srv.Requests()) != 0 {
|
||||
t.Fatalf("参数不全时不应发请求: %v", srv.Requests())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewCommentUsesIssueCommentsEndpoint(t *testing.T) {
|
||||
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
||||
n := srv.AddPull(forgetest.Pull{Title: "t", Author: "bob", HeadBranch: "h", BaseBranch: "main"})
|
||||
mustRun(t, srv, "pr", "review-comment", "--pr", fmt.Sprint(n), "--body", "审查意见")
|
||||
if got := lastComment(t, srv, n); got != "<!-- spx:review=1 -->\n\n审查意见" {
|
||||
t.Fatalf("评论正文不对: %q", got)
|
||||
}
|
||||
prefix := ""
|
||||
if srv.Flavor == forgetest.Gitea {
|
||||
prefix = "/api/v1"
|
||||
}
|
||||
reqs := srv.Requests()
|
||||
want := fmt.Sprintf("POST %s/repos/acme/widgets/issues/%d/comments", prefix, n)
|
||||
if reqs[len(reqs)-1] != want {
|
||||
t.Fatalf("应走 issues comments 接口,实际 %s", reqs[len(reqs)-1])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func gitRepoWithOrigin(t *testing.T, remote string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", remote}} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v 失败: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestResolveOriginFromGitRemoteAndOverrides(t *testing.T) {
|
||||
dir := gitRepoWithOrigin(t, "git@github.com:acme/widgets.git")
|
||||
cases := []struct {
|
||||
name, host, repoFlag string
|
||||
want repo.Origin
|
||||
}{
|
||||
{"只看 origin", "", "", repo.Origin{Host: "github.com", WebURL: "https://github.com", Owner: "acme", Repo: "widgets"}},
|
||||
{"覆盖 host", "https://gitea.example.com", "", repo.Origin{Host: "gitea.example.com", WebURL: "https://gitea.example.com", Owner: "acme", Repo: "widgets"}},
|
||||
{"覆盖 repo", "", "other/thing", repo.Origin{Host: "github.com", WebURL: "https://github.com", Owner: "other", Repo: "thing"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
flagCwd, flagHost, flagRepo = dir, c.host, c.repoFlag
|
||||
got, err := resolveOrigin()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveOrigin 出错: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("got %+v want %+v", got, c.want)
|
||||
}
|
||||
if forge.KindForHost(got.Host) == forge.KindGitHub != (c.want.Host == "github.com") {
|
||||
t.Fatalf("forge 选择不对: host=%s", got.Host)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOriginWithHostAndRepoSkipsGit(t *testing.T) {
|
||||
flagCwd, flagHost, flagRepo = t.TempDir(), "github.com", "acme/widgets"
|
||||
got, err := resolveOrigin()
|
||||
if err != nil {
|
||||
t.Fatalf("同时给 --host 和 --repo 时不应依赖 git: %v", err)
|
||||
}
|
||||
if got.Host != "github.com" || got.Owner != "acme" || got.Repo != "widgets" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOriginRejectsBadRepoFlag(t *testing.T) {
|
||||
flagCwd, flagHost, flagRepo = t.TempDir(), "github.com", "no-slash"
|
||||
if _, err := resolveOrigin(); err == nil {
|
||||
t.Fatal("--repo 不是 OWNER/REPO 时应报错")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/<n>/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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
``: ``,
|
||||
`<https://api.github.com/repositories/1/issues?page=2>; rel="next", <https://api.github.com/repositories/1/issues?page=9>; rel="last"`: `https://api.github.com/repositories/1/issues?page=2`,
|
||||
`<https://x/a?page=1>; rel="prev", <https://x/a?page=3>; rel="next"`: `https://x/a?page=3`,
|
||||
`<https://x/a?page=1>; rel="first", <https://x/a?page=1>; rel="prev"`: ``,
|
||||
`<https://x/a?labels=a,b&page=2>; rel=next`: `https://x/a?labels=a,b&page=2`,
|
||||
`<http://localhost/api/v1/repos/search?limit=1&page=2>; rel="next",<http://localhost/api/v1/repos/search?limit=1&page=5252>; 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)
|
||||
}
|
||||
}
|
||||
@@ -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 `<api>/repos/<owner>/<repo>/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 `<url>; 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/<n>/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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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://<host>`.
|
||||
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://<host>` 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").
|
||||
|
||||
@@ -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 时应当报错")
|
||||
}
|
||||
}
|
||||
@@ -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 = "<!-- spx-state"
|
||||
commentSuffix = "-->"
|
||||
// legacyMarker heads the state comments the YouTrack integration wrote before the hidden format existed.
|
||||
legacyMarker = "<!--spx-state-->"
|
||||
)
|
||||
|
||||
// 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: `<!-- spx-state`, compact JSON, `-->`.
|
||||
//
|
||||
// [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 (`<!--spx-state-->` 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{}
|
||||
}
|
||||
@@ -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 := "<!-- spx-state\n{\"branch\":\"feat/x\",\"column\":\"review\"}\n-->"
|
||||
if body != want {
|
||||
t.Fatalf("got %q\nwant %q", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCommentEscapesCommentTerminators(t *testing.T) {
|
||||
raw := "a --> b <!-- c & d"
|
||||
body, err := FormatComment(map[string]any{"handoffRequestError": raw})
|
||||
if err != nil {
|
||||
t.Fatalf("FormatComment 出错: %v", err)
|
||||
}
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(body, "<!-- spx-state\n"), "\n-->")
|
||||
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{
|
||||
"隐藏注释": "<!-- spx-state\n{\"column\":\"review\"}\n-->",
|
||||
"隐藏注释带首尾空白": " \n<!-- spx-state\n{\"column\":\"review\"}\n-->\n ",
|
||||
"旧 YouTrack 格式": "<!--spx-state-->\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"`,
|
||||
"<!-- spx-state\n{\"column\":\"review\"}",
|
||||
"<!-- spx-state\n[1]\n-->",
|
||||
"<!-- spx-state\n{\"foo\":1}\n-->",
|
||||
"<!--spx-state-->\n{\"foo\":1}",
|
||||
"状态如下: {\"column\":\"review\"}",
|
||||
"<!-- spx:review=1 -->\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"}`,
|
||||
"<!--spx-state-->\n{\"column\":\"in-progress\"}",
|
||||
"<!-- spx-state\n{\"column\":\"review\",\"branch\":\"new\"}\n-->",
|
||||
"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")
|
||||
}
|
||||
}
|
||||
@@ -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 (<v0.10) stored the token inline in the YAML; newer versions move
|
||||
// tokens into a system keyring and only keep metadata in the file.
|
||||
//
|
||||
// We support the inline form here (matches the spx task contract). If the
|
||||
// token field is missing, callers should consider falling back to a
|
||||
// GITEA_TOKEN environment variable and emit a clear error otherwise.
|
||||
// We support the inline form here. If the token field is missing, callers
|
||||
// fall back to the GITEA_TOKEN environment variable.
|
||||
package tea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -53,52 +55,25 @@ func load(path string) (*TeaConfig, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// LoadDefault returns host and token from the default login entry.
|
||||
// LoginForHost returns the login in the tea config at `path` whose URL points at `host`, or `nil` when none does.
|
||||
//
|
||||
// If multiple logins have default=true, the first wins. If no entry is marked
|
||||
// default, the first entry is used.
|
||||
func LoadDefault() (host, token string, err error) {
|
||||
path, err := DefaultConfigPath()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Hosts are compared by hostname only, ignoring case, scheme, port and trailing slashes, so the host read from a git
|
||||
// SSH remote still finds the login saved with the forge's web URL. A missing config file is not an error either,
|
||||
// because the caller can still fall back to `GITEA_TOKEN`.
|
||||
func LoginForHost(path, host string) (*TeaLogin, error) {
|
||||
cfg, err := load(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, err
|
||||
}
|
||||
if len(cfg.Logins) == 0 {
|
||||
return "", "", fmt.Errorf("tea config %s 里没有 logins 条目,请先运行 `tea login add`", path)
|
||||
}
|
||||
var chosen *TeaLogin
|
||||
want := strings.ToLower(host)
|
||||
for i := range cfg.Logins {
|
||||
if cfg.Logins[i].Default {
|
||||
chosen = &cfg.Logins[i]
|
||||
break
|
||||
u, perr := url.Parse(strings.TrimSpace(cfg.Logins[i].URL))
|
||||
if perr == nil && strings.ToLower(u.Hostname()) == want {
|
||||
return &cfg.Logins[i], nil
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
chosen = &cfg.Logins[0]
|
||||
}
|
||||
return chosen.URL, chosen.Token, nil
|
||||
}
|
||||
|
||||
// LoadByHost returns the token for the login whose URL matches host.
|
||||
//
|
||||
// Host matching is case-insensitive and ignores trailing slashes.
|
||||
func LoadByHost(host string) (token string, err error) {
|
||||
path, err := DefaultConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg, err := load(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
want := strings.TrimRight(strings.ToLower(host), "/")
|
||||
for _, l := range cfg.Logins {
|
||||
if strings.TrimRight(strings.ToLower(l.URL), "/") == want {
|
||||
return l.Token, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("tea config 里没找到 host=%s 的 login", host)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("写 tea config 失败: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
const sampleConfig = `logins:
|
||||
- name: local
|
||||
url: http://localhost:3000
|
||||
token: tok-local
|
||||
default: true
|
||||
- name: work
|
||||
url: https://Gitea.Example.com/
|
||||
token: tok-work
|
||||
`
|
||||
|
||||
func TestLoginForHostMatchesByHostname(t *testing.T) {
|
||||
path := writeConfig(t, sampleConfig)
|
||||
cases := map[string]string{
|
||||
"gitea.example.com": "tok-work",
|
||||
"GITEA.example.com": "tok-work",
|
||||
"localhost": "tok-local",
|
||||
}
|
||||
for host, wantToken := range cases {
|
||||
l, err := LoginForHost(path, host)
|
||||
if err != nil {
|
||||
t.Fatalf("LoginForHost(%s) 出错: %v", host, err)
|
||||
}
|
||||
if l == nil || l.Token != wantToken {
|
||||
t.Fatalf("LoginForHost(%s) = %+v, want token %s", host, l, wantToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginForHostIgnoresDefaultLoginOfOtherHost(t *testing.T) {
|
||||
path := writeConfig(t, sampleConfig)
|
||||
l, err := LoginForHost(path, "git.other.org")
|
||||
if err != nil {
|
||||
t.Fatalf("没匹配上不应报错: %v", err)
|
||||
}
|
||||
if l != nil {
|
||||
t.Fatalf("host 不匹配时不应回退到默认 login,得到 %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginForHostMissingConfigIsNotAnError(t *testing.T) {
|
||||
l, err := LoginForHost(filepath.Join(t.TempDir(), "absent.yml"), "gitea.example.com")
|
||||
if err != nil || l != nil {
|
||||
t.Fatalf("没有 tea config 时应返回 (nil, nil),得到 (%+v, %v)", l, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginForHostBadYAMLIsAnError(t *testing.T) {
|
||||
path := writeConfig(t, "logins: [this is: not valid")
|
||||
if _, err := LoginForHost(path, "gitea.example.com"); err == nil {
|
||||
t.Fatal("tea config 解析失败应当报错")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user