✨ feat(spx): 按 git origin 支持 GitHub 仓库,state 评论改成隐藏注释格式,新增 issue view / pr view / pr create

This commit is contained in:
cruldra committed 2026-10-19 07:41:13 +11:00
1 parent 64d5961b62
commit c90d84d7f2
17 files changed
+2683 -400

No files matched your search

+297 -176
View File
@@ -1,35 +1,35 @@
// Command spx is a small Gitea wrapper for the superpowers-vscode workflow.
// Command spx is a small Gitea/GitHub wrapper for the superpowers-vscode workflow.
//
// It targets agent (cc/codex) usage: deterministic flags, JSON output mode,
// and tea-config-based credential discovery.
// and the same commands whichever forge the repo's git origin points at.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/cruldra/superpowers-vscode/cli/internal/gitea"
"github.com/cruldra/superpowers-vscode/cli/internal/forge"
"github.com/cruldra/superpowers-vscode/cli/internal/marker"
"github.com/cruldra/superpowers-vscode/cli/internal/repo"
"github.com/cruldra/superpowers-vscode/cli/internal/state"
"github.com/cruldra/superpowers-vscode/cli/internal/tea"
)
// runtimeContext is the bag of resolved configuration we hand to subcommands
// via cobra's context. Filled in by the root PersistentPreRunE.
type runtimeContext struct {
Client *gitea.Client
Owner string
Repo string
Host string
JSON bool
Forge forge.Forge
JSON bool
}
// forgeOpener connects to the repo spx should act on; main resolves it from flags and git origin, tests from a fake server.
type forgeOpener func() (forge.Forge, error)
type ctxKey struct{}
// global flag values (bound by Cobra).
@@ -70,6 +70,12 @@ var (
ismStateFile string
)
// issue view flags.
var (
ivIssue int
ivComments bool
)
// pr review-comment flags.
var (
prcPR int
@@ -77,32 +83,49 @@ var (
prcBodyFile string
)
// pr view flags.
var (
pvPR int
)
// pr create flags.
var (
pcTitle string
pcBody string
pcBodyFile string
pcHead string
pcBase string
)
func main() {
root := buildRootCmd()
root := buildRootCmd(openForge)
if err := root.Execute(); err != nil {
// cobra already printed the error to stderr; ensure non-zero exit.
os.Exit(1)
}
}
func buildRootCmd() *cobra.Command {
func buildRootCmd(open forgeOpener) *cobra.Command {
root := &cobra.Command{
Use: "spx",
Short: "superpowers-vscode 工作流 CLI(Gitea 薄包装)",
Long: "spx 为 cc/codex agent 在 superpowers-vscode 工作流里提供确定性的 Gitea 操作。\n复用 ~/.config/tea/config.yml 拿 host + token。",
Use: "spx",
Short: "superpowers-vscode 工作流 CLI(Gitea / GitHub 薄包装)",
Long: "spx 为 cc/codex agent 在 superpowers-vscode 工作流里提供确定性的工单 / PR 操作。\n" +
"按 git origin 的 host 选仓库:github.com 走 GitHub(token 取自 `gh auth token`),\n" +
"其它 host 走 Gitea(token 取自 ~/.config/tea/config.yml 里同 host 的 login,回退 GITEA_TOKEN)。",
SilenceUsage: true,
SilenceErrors: false,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
rc, err := resolveRuntime()
f, err := open()
if err != nil {
return err
}
rc := &runtimeContext{Forge: f, JSON: flagJSON}
cmd.SetContext(context.WithValue(cmd.Context(), ctxKey{}, rc))
return nil
},
}
root.PersistentFlags().StringVar(&flagRepo, "repo", "", "OWNER/REPO,缺省从当前 git origin 推断")
root.PersistentFlags().StringVar(&flagHost, "host", "", "Gitea host URL,缺省从 tea config 默认 login 取")
root.PersistentFlags().StringVar(&flagHost, "host", "", "仓库 host(如 github.com 或 https://gitea.example.com),缺省从当前 git origin 推断")
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "JSON 输出(默认人类可读)")
root.PersistentFlags().StringVar(&flagCwd, "cwd", ".", "repo 探测的工作目录")
@@ -128,7 +151,7 @@ const markerTypesHint = "spec、plan 或 prototype"
func buildIssueCmd() *cobra.Command {
issue := &cobra.Command{
Use: "issue",
Short: "Gitea issue 操作",
Short: "工单操作",
}
create := &cobra.Command{
@@ -181,12 +204,12 @@ func buildIssueCmd() *cobra.Command {
// 创建即认领:assignee=创建人,是插件 webhook 门禁的路由依据。
// 身份解析失败不阻塞创建(门禁侧有本地终端兜底)。
var assignees []string
if login, loginErr := rc.Client.CurrentUserLogin(); loginErr == nil && login != "" {
if login, loginErr := rc.Forge.CurrentUserLogin(); loginErr == nil && login != "" {
assignees = []string{login}
} else if loginErr != nil {
fmt.Fprintf(os.Stderr, "警告: 解析当前用户失败,工单将无 assignee: %v\n", loginErr)
}
issue, err := rc.Client.CreateIssue(rc.Owner, rc.Repo, icTitle, body, assignees)
issue, err := rc.Forge.CreateIssue(icTitle, body, assignees)
if err != nil {
return err
}
@@ -198,17 +221,17 @@ func buildIssueCmd() *cobra.Command {
}
var statePayload any
if hasState {
mergedBytes, err := json.Marshal(mergedState)
comment, err := state.FormatComment(mergedState)
if err != nil {
return fmt.Errorf("工单已建 (#%d),但序列化 state JSON 失败: %w", issue.Number, err)
return fmt.Errorf("工单已建 (#%d),但%w", issue.Number, err)
}
if _, postErr := rc.Client.CreateIssueComment(rc.Owner, rc.Repo, issue.Number, string(mergedBytes)); postErr != nil {
if _, postErr := rc.Forge.CreateIssueComment(issue.Number, comment); postErr != nil {
return fmt.Errorf("工单已建 (#%d),但写入 state JSON comment 失败: %w", issue.Number, postErr)
}
statePayload = mergedState
}
if rc.JSON {
return emitJSON(map[string]any{
return emitJSON(cmd.OutOrStdout(), map[string]any{
"number": issue.Number,
"html_url": issue.HTMLURL,
"state": statePayload,
@@ -223,7 +246,7 @@ func buildIssueCmd() *cobra.Command {
create.Flags().StringVar(&icBodyFile, "body-file", "", "从文件读取工单正文")
create.Flags().StringVar(&icSpec, "spec", "", "在 body 追加 spx:spec marker,并把 specFile 同步合并进 state JSON comment")
create.Flags().StringVar(&icPlan, "plan", "", "在 body 追加 spx:plan marker,并把 planFile 同步合并进 state JSON comment")
create.Flags().StringVar(&icStateJSON, "state-json", "", "state JSON 字符串(建工单后写入最后一条 comment;与 --state-file 二选一)")
create.Flags().StringVar(&icStateJSON, "state-json", "", "state JSON 字符串(建工单后写成一条 state 评论;与 --state-file 二选一)")
create.Flags().StringVar(&icStateFile, "state-file", "", "从文件读取 state JSON")
_ = create.MarkFlagRequired("title")
@@ -245,13 +268,13 @@ func buildIssueCmd() *cobra.Command {
return err
}
rc := fromCtx(cmd)
cur, err := rc.Client.GetIssue(rc.Owner, rc.Repo, imIssue)
cur, err := rc.Forge.GetIssue(imIssue)
if err != nil {
return err
}
newBody := marker.UpsertMarker(cur.Body, imType, imValue)
if newBody != cur.Body {
if err := rc.Client.UpdateIssueBody(rc.Owner, rc.Repo, imIssue, newBody); err != nil {
if err := rc.Forge.UpdateIssueBody(imIssue, newBody); err != nil {
return err
}
}
@@ -265,7 +288,7 @@ func buildIssueCmd() *cobra.Command {
return mergeErr
}
if rc.JSON {
return emitJSON(map[string]any{
return emitJSON(cmd.OutOrStdout(), map[string]any{
"number": imIssue,
"type": imType,
"value": imValue,
@@ -283,42 +306,124 @@ func buildIssueCmd() *cobra.Command {
_ = markerCmd.MarkFlagRequired("type")
_ = markerCmd.MarkFlagRequired("value")
issue.AddCommand(create, markerCmd, buildIssueStateCmd())
issue.AddCommand(create, markerCmd, buildIssueViewCmd(), buildIssueStateCmd())
return issue
}
// buildIssueViewCmd assembles `spx issue view`, which shows an issue and, with `--comments`, its discussion.
//
// State comments are machine bookkeeping, so they are left out of the comment list; `spx issue state get` reads them.
func buildIssueViewCmd() *cobra.Command {
view := &cobra.Command{
Use: "view",
Short: "查看工单:标题、正文、状态、作者、指派人、链接;--comments 附带评论(不含 state 评论)",
RunE: func(cmd *cobra.Command, args []string) error {
if ivIssue <= 0 {
return fmt.Errorf("--issue 必须是正整数")
}
rc := fromCtx(cmd)
is, err := rc.Forge.GetIssue(ivIssue)
if err != nil {
return err
}
var comments []forge.Comment
if ivComments {
all, err := rc.Forge.ListIssueComments(ivIssue)
if err != nil {
return err
}
comments = withoutStateComments(all)
}
if rc.JSON {
if !ivComments {
return emitJSON(cmd.OutOrStdout(), is)
}
return emitJSON(cmd.OutOrStdout(), struct {
*forge.Issue
Comments []forge.Comment `json:"comments"`
}{is, comments})
}
printIssue(cmd.OutOrStdout(), is)
if ivComments {
printComments(cmd.OutOrStdout(), comments)
}
return nil
},
}
view.Flags().IntVar(&ivIssue, "issue", 0, "工单编号(必填)")
view.Flags().BoolVar(&ivComments, "comments", false, "附带评论(不含 state 评论)")
_ = view.MarkFlagRequired("issue")
return view
}
// withoutStateComments drops the comments that carry state JSON, keeping the human discussion in order.
//
// @return a non-nil slice, so `--json` prints `[]` rather than `null` when nothing is left
func withoutStateComments(all []forge.Comment) []forge.Comment {
out := make([]forge.Comment, 0, len(all))
for _, c := range all {
if _, isState := state.ParseComment(c.Body); !isState {
out = append(out, c)
}
}
return out
}
// printIssue writes the human-readable form of `spx issue view`.
func printIssue(w io.Writer, is *forge.Issue) {
assignees := "无"
if len(is.Assignees) > 0 {
assignees = strings.Join(is.Assignees, ", ")
}
fmt.Fprintf(w, "#%d %s\n", is.Number, is.Title)
fmt.Fprintf(w, "状态: %s\n", is.State)
fmt.Fprintf(w, "作者: %s\n", is.Author)
fmt.Fprintf(w, "指派: %s\n", assignees)
fmt.Fprintf(w, "链接: %s\n", is.HTMLURL)
fmt.Fprintf(w, "\n%s\n", bodyOrPlaceholder(is.Body))
}
// printComments writes the comment list of `spx issue view --comments`, one header line per comment.
func printComments(w io.Writer, comments []forge.Comment) {
fmt.Fprintf(w, "\n评论 (%d):\n", len(comments))
for _, c := range comments {
fmt.Fprintf(w, "\n--- %s · %s ---\n%s\n", c.Author, c.CreatedAt, bodyOrPlaceholder(c.Body))
}
}
// bodyOrPlaceholder returns `body`, or a visible placeholder when it is blank, so empty text is not mistaken for missing output.
func bodyOrPlaceholder(body string) string {
if strings.TrimSpace(body) == "" {
return "(无正文)"
}
return strings.TrimRight(body, "\n")
}
// buildIssueStateCmd assembles the `spx issue state` subtree (get + merge).
//
// The state JSON convention: the most recent state-JSON comment on an issue
// holds a single JSON blob (see schemas/state-json.schema.json), located by
// scanning comments from the tail for the first one carrying a known state
// field. `get` reads it, `merge` does a shallow-merge write of new keys on top.
// The newest state comment on an issue holds its state JSON (see the [state schema](vscode/schemas/state-json.schema.json)); [state.Latest]
// finds it from the tail. `get` reads it, `merge` shallow-merges new keys on top and posts the result as a new
// state comment in the hidden format of [state.FormatComment].
func buildIssueStateCmd() *cobra.Command {
stateCmd := &cobra.Command{
Use: "state",
Short: "读取/合并工单的 state JSON(持久化在最后一条 comment)",
Short: "读取/合并工单的 state JSON(持久化在最新一条 state 评论)",
}
get := &cobra.Command{
Use: "get",
Short: "读取工单最后一条 comment 里的 state JSON",
Short: "读取工单最新一条 state 评论里的 state JSON",
RunE: func(cmd *cobra.Command, args []string) error {
if isgIssue <= 0 {
return fmt.Errorf("--issue 必须是正整数")
}
rc := fromCtx(cmd)
cur, err := lastStateMap(rc.Client, rc.Owner, rc.Repo, isgIssue)
cur, err := lastStateMap(rc.Forge, isgIssue)
if err != nil {
return err
}
// `cur` may be an empty map; print `{}` in that case.
out := os.Stdout
enc := json.NewEncoder(out)
enc.SetEscapeHTML(false)
if rc.JSON {
enc.SetIndent("", " ")
}
return enc.Encode(cur)
return emitState(cmd.OutOrStdout(), cur, rc.JSON)
},
}
get.Flags().IntVar(&isgIssue, "issue", 0, "工单编号(必填)")
@@ -326,7 +431,7 @@ func buildIssueStateCmd() *cobra.Command {
merge := &cobra.Command{
Use: "merge",
Short: "浅合并新 state JSON 到现有 state(最后一条 comment)",
Short: "浅合并新 state JSON 到现有 state,并追加一条新的 state 评论",
RunE: func(cmd *cobra.Command, args []string) error {
if ismIssue <= 0 {
return fmt.Errorf("--issue 必须是正整数")
@@ -340,7 +445,7 @@ func buildIssueStateCmd() *cobra.Command {
}
// Validate the incoming payload BEFORE any network call so a
// malformed input fails fast (e.g. wrong enum) without surfacing
// confusing "issue not found" errors from Gitea first.
// confusing "issue not found" errors from the forge first.
if err := state.Validate(incomingBytes); err != nil {
return err
}
@@ -353,12 +458,7 @@ func buildIssueStateCmd() *cobra.Command {
if err != nil {
return err
}
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
if rc.JSON {
enc.SetIndent("", " ")
}
return enc.Encode(merged)
return emitState(cmd.OutOrStdout(), merged, rc.JSON)
},
}
merge.Flags().IntVar(&ismIssue, "issue", 0, "工单编号(必填)")
@@ -371,12 +471,12 @@ func buildIssueStateCmd() *cobra.Command {
}
// mergeAndPostState shallow-merges patch into the issue's current state JSON
// (last comment), re-validates the result against the schema, and posts a new
// comment with the merged blob. Returns the merged map for callers that want
// to echo it back. Shared by `issue create --spec/--plan`, `issue marker`,
// and `issue state merge` so the three paths can't drift.
// (newest state comment), re-validates the result against the schema, and
// posts the merged blob as a new state comment. Returns the merged map for
// callers that want to echo it back. Shared by `issue marker` and
// `issue state merge` so the two paths can't drift.
func mergeAndPostState(rc *runtimeContext, issueNumber int, patch map[string]any) (map[string]any, error) {
cur, err := lastStateMap(rc.Client, rc.Owner, rc.Repo, issueNumber)
cur, err := lastStateMap(rc.Forge, issueNumber)
if err != nil {
return nil, err
}
@@ -388,86 +488,41 @@ func mergeAndPostState(rc *runtimeContext, issueNumber int, patch map[string]any
if err := state.ValidateValue(cur); err != nil {
return nil, err
}
mergedBytes, err := json.Marshal(cur)
comment, err := state.FormatComment(cur)
if err != nil {
return nil, fmt.Errorf("序列化合并后的 state 失败: %w", err)
return nil, err
}
if _, err := rc.Client.CreateIssueComment(rc.Owner, rc.Repo, issueNumber, string(mergedBytes)); err != nil {
if _, err := rc.Forge.CreateIssueComment(issueNumber, comment); err != nil {
return nil, err
}
return cur, nil
}
// knownStateFields is the set of keys that mark a comment as a state JSON blob
// rather than ordinary chatter. A comment counts as state only if it parses to
// an object carrying at least one of these — mirrors the TypeScript loader so
// the two stay in lockstep.
var knownStateFields = map[string]struct{}{
"column": {},
"sessionId": {},
"implementSessionId": {},
"reviewSessionId": {},
"testSessionId": {},
"prototypeSessionId": {},
"profilePath": {},
"brainstormProfilePath": {},
"testProfilePath": {},
"prototypeProfilePath": {},
"specFile": {},
"planFile": {},
"prototypeFile": {},
"prDiffFile": {},
"pr": {},
"prMerged": {},
"prMergedAt": {},
"branch": {},
"worktreePath": {},
"implementStatus": {},
"color": {},
"autoReview": {},
"reviewPassed": {},
"handoffBundleKey": {},
"handoffFrom": {},
"handoffRequestFrom": {},
"handoffRequestError": {},
}
// parseStateComment returns the parsed map when body is a JSON object carrying
// at least one known state field, or (nil, false) otherwise.
func parseStateComment(body string) (map[string]any, bool) {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return nil, false
}
var m map[string]any
if err := json.Unmarshal([]byte(trimmed), &m); err != nil {
return nil, false
}
for k := range m {
if _, ok := knownStateFields[k]; ok {
return m, true
}
}
return nil, false
}
// lastStateMap fetches all comments on an issue and returns the most recent
// state JSON blob, found by scanning from the tail for the first comment that
// parses as an object carrying a known state field. Ordinary text/JSON comments
// inserted afterwards (cc chatter, review notes, Gitea's PR auto-link) are
// skipped so they can't shadow real state and wipe history on the next merge.
// lastStateMap reads all comments on an issue and returns the state of the
// newest state comment, via [state.Latest]. Ordinary comments posted
// afterwards (cc chatter, review notes, the forge's PR auto-link) are skipped
// so they can't shadow real state and wipe history on the next merge.
// Empty issue or no state comment yields an empty (non-nil) map — never an error.
func lastStateMap(c *gitea.Client, owner, repo string, number int) (map[string]any, error) {
comments, err := c.ListIssueComments(owner, repo, number)
func lastStateMap(f forge.Forge, number int) (map[string]any, error) {
comments, err := f.ListIssueComments(number)
if err != nil {
return nil, err
}
for i := len(comments) - 1; i >= 0; i-- {
if m, ok := parseStateComment(comments[i].Body); ok {
return m, nil
}
bodies := make([]string, len(comments))
for i, c := range comments {
bodies[i] = c.Body
}
return map[string]any{}, nil
return state.Latest(bodies), nil
}
// emitState prints a state map for `spx issue state`: one line by default, indented when `pretty` is set.
func emitState(w io.Writer, s map[string]any, pretty bool) error {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
if pretty {
enc.SetIndent("", " ")
}
return enc.Encode(s)
}
// resolveStatePayload picks the state JSON bytes from --state-json /
@@ -493,7 +548,7 @@ func resolveStatePayload(inline, file string) ([]byte, error) {
func buildPRCmd() *cobra.Command {
pr := &cobra.Command{
Use: "pr",
Short: "Gitea PR 操作",
Short: "PR 操作",
}
rc := &cobra.Command{
Use: "review-comment",
@@ -508,12 +563,12 @@ func buildPRCmd() *cobra.Command {
}
body = marker.PrependReviewMarker(body)
rt := fromCtx(cmd)
cm, err := rt.Client.CreateIssueComment(rt.Owner, rt.Repo, prcPR, body)
cm, err := rt.Forge.CreateIssueComment(prcPR, body)
if err != nil {
return err
}
if rt.JSON {
return emitJSON(map[string]any{
return emitJSON(cmd.OutOrStdout(), map[string]any{
"id": cm.ID,
"html_url": cm.HTMLURL,
})
@@ -527,10 +582,92 @@ func buildPRCmd() *cobra.Command {
rc.Flags().StringVar(&prcBodyFile, "body-file", "", "从文件读取评论正文")
_ = rc.MarkFlagRequired("pr")
pr.AddCommand(rc)
view := &cobra.Command{
Use: "view",
Short: "查看 PR:标题、正文、状态、是否已合并、head/base 分支与 SHA、作者、链接",
RunE: func(cmd *cobra.Command, args []string) error {
if pvPR <= 0 {
return fmt.Errorf("--pr 必须是正整数")
}
rt := fromCtx(cmd)
p, err := rt.Forge.GetPullRequest(pvPR)
if err != nil {
return err
}
if rt.JSON {
return emitJSON(cmd.OutOrStdout(), p)
}
printPullRequest(cmd.OutOrStdout(), p)
return nil
},
}
view.Flags().IntVar(&pvPR, "pr", 0, "PR 编号(必填)")
_ = view.MarkFlagRequired("pr")
create := &cobra.Command{
Use: "create",
Short: "从 --head 分支向 --base 分支(缺省为仓库默认分支)发起 PR",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(pcTitle) == "" {
return fmt.Errorf("--title 不能为空")
}
if strings.TrimSpace(pcHead) == "" {
return fmt.Errorf("--head 不能为空")
}
if pcBody == "" && pcBodyFile == "" {
return fmt.Errorf("必须提供 --body 或 --body-file 之一")
}
body, err := resolveBody(pcBody, pcBodyFile)
if err != nil {
return err
}
rt := fromCtx(cmd)
base := pcBase
if base == "" {
if base, err = rt.Forge.DefaultBranch(); err != nil {
return fmt.Errorf("没给 --base,查仓库默认分支失败: %w", err)
}
}
p, err := rt.Forge.CreatePullRequest(forge.NewPullRequest{Title: pcTitle, Body: body, Head: pcHead, Base: base})
if err != nil {
return err
}
if rt.JSON {
return emitJSON(cmd.OutOrStdout(), map[string]any{
"number": p.Number,
"html_url": p.HTMLURL,
})
}
fmt.Fprintf(cmd.OutOrStdout(), "#%d %s\n", p.Number, p.HTMLURL)
return nil
},
}
create.Flags().StringVar(&pcTitle, "title", "", "PR 标题(必填)")
create.Flags().StringVar(&pcBody, "body", "", "PR 正文(与 --body-file 二选一,必须给一个)")
create.Flags().StringVar(&pcBodyFile, "body-file", "", "从文件读取 PR 正文")
create.Flags().StringVar(&pcHead, "head", "", "要合并进来的分支(必填)")
create.Flags().StringVar(&pcBase, "base", "", "合并到的目标分支,缺省取仓库默认分支")
_ = create.MarkFlagRequired("title")
_ = create.MarkFlagRequired("head")
pr.AddCommand(rc, view, create)
return pr
}
// printPullRequest writes the human-readable form of `spx pr view`.
func printPullRequest(w io.Writer, p *forge.PullRequest) {
merged := "未合并"
if p.Merged {
merged = "已合并"
}
fmt.Fprintf(w, "#%d %s\n", p.Number, p.Title)
fmt.Fprintf(w, "状态: %s(%s)\n", p.State, merged)
fmt.Fprintf(w, "作者: %s\n", p.Author)
fmt.Fprintf(w, "分支: %s (%s) → %s (%s)\n", p.Head.Branch, p.Head.SHA, p.Base.Branch, p.Base.SHA)
fmt.Fprintf(w, "链接: %s\n", p.HTMLURL)
fmt.Fprintf(w, "\n%s\n", bodyOrPlaceholder(p.Body))
}
// resolveBody picks the body text from --body / --body-file. Both empty is OK
// (callers may then prepend markers); both set is an error.
func resolveBody(body, file string) (string, error) {
@@ -547,57 +684,41 @@ func resolveBody(body, file string) (string, error) {
return body, nil
}
// resolveRuntime turns the global flags + tea config + git remote into a
// ready-to-use Client and owner/repo pair.
func resolveRuntime() (*runtimeContext, error) {
host := flagHost
var token string
if host == "" {
h, t, err := tea.LoadDefault()
if err != nil {
return nil, err
}
host, token = h, t
} else {
t, err := tea.LoadByHost(host)
if err != nil {
return nil, err
}
token = t
}
// Fallback: token can come from env when tea stores it in a keyring
// (newer tea releases) rather than in config.yml.
if token == "" {
if env := os.Getenv("GITEA_TOKEN"); env != "" {
token = env
}
}
if token == "" {
return nil, fmt.Errorf("没有可用的 Gitea token:tea config 里 token 字段为空,且 GITEA_TOKEN 环境变量也没设置")
// openForge connects to the repo the global flags and git origin point at, on the forge its host names.
func openForge() (forge.Forge, error) {
o, err := resolveOrigin()
if err != nil {
return nil, err
}
return forge.Open(o, forge.SystemEnv())
}
owner, repoName := "", ""
// resolveOrigin works out the repo to act on from `git remote get-url origin`, with `--host` and `--repo` overriding
// its parts; git is not consulted when both flags are given.
func resolveOrigin() (repo.Origin, error) {
var o repo.Origin
if flagHost == "" || flagRepo == "" {
detected, err := repo.DetectOrigin(flagCwd)
if err != nil {
return repo.Origin{}, fmt.Errorf("从 git origin 推断仓库失败,请用 --host 和 --repo 显式指定: %w", err)
}
o = detected
}
if flagHost != "" {
host, webURL, err := repo.ParseHost(flagHost)
if err != nil {
return repo.Origin{}, err
}
o.Host, o.WebURL = host, webURL
}
if flagRepo != "" {
parts := strings.SplitN(flagRepo, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, fmt.Errorf("--repo 必须是 OWNER/REPO 形式,得到 %q", flagRepo)
owner, name, ok := strings.Cut(flagRepo, "/")
if !ok || owner == "" || name == "" {
return repo.Origin{}, fmt.Errorf("--repo 必须是 OWNER/REPO 形式,得到 %q", flagRepo)
}
owner, repoName = parts[0], parts[1]
} else {
o, r, err := repo.DetectOwnerRepo(flagCwd)
if err != nil {
return nil, fmt.Errorf("自动推断 owner/repo 失败,请用 --repo 显式指定: %w", err)
}
owner, repoName = o, r
o.Owner, o.Repo = owner, name
}
return &runtimeContext{
Client: gitea.New(host, token),
Owner: owner,
Repo: repoName,
Host: host,
JSON: flagJSON,
}, nil
return o, nil
}
// fromCtx extracts the runtimeContext set up by PersistentPreRunE.
@@ -610,9 +731,9 @@ func fromCtx(cmd *cobra.Command) *runtimeContext {
return v.(*runtimeContext)
}
// emitJSON writes v as a single-line JSON object to stdout.
func emitJSON(v any) error {
enc := json.NewEncoder(os.Stdout)
// emitJSON writes v as a single-line JSON object to w.
func emitJSON(w io.Writer, v any) error {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(v)
}