597 lines
19 KiB
Go
597 lines
19 KiB
Go
// Command spx is a small Gitea wrapper for the superpowers-vscode workflow.
|
||
//
|
||
// It targets agent (cc/codex) usage: deterministic flags, JSON output mode,
|
||
// and tea-config-based credential discovery.
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/spf13/cobra"
|
||
|
||
"github.com/cruldra/superpowers-vscode/cli/internal/gitea"
|
||
"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
|
||
}
|
||
|
||
type ctxKey struct{}
|
||
|
||
// global flag values (bound by Cobra).
|
||
var (
|
||
flagRepo string
|
||
flagHost string
|
||
flagJSON bool
|
||
flagCwd string
|
||
)
|
||
|
||
// issue create flags.
|
||
var (
|
||
icTitle string
|
||
icBody string
|
||
icBodyFile string
|
||
icSpec string
|
||
icPlan string
|
||
icStateJSON string
|
||
icStateFile string
|
||
)
|
||
|
||
// issue marker flags.
|
||
var (
|
||
imIssue int
|
||
imType string
|
||
imValue string
|
||
)
|
||
|
||
// issue state get flags.
|
||
var (
|
||
isgIssue int
|
||
)
|
||
|
||
// issue state merge flags.
|
||
var (
|
||
ismIssue int
|
||
ismStateJSON string
|
||
ismStateFile string
|
||
)
|
||
|
||
// pr review-comment flags.
|
||
var (
|
||
prcPR int
|
||
prcBody string
|
||
prcBodyFile string
|
||
)
|
||
|
||
func main() {
|
||
root := buildRootCmd()
|
||
if err := root.Execute(); err != nil {
|
||
// cobra already printed the error to stderr; ensure non-zero exit.
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func buildRootCmd() *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。",
|
||
SilenceUsage: true,
|
||
SilenceErrors: false,
|
||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||
rc, err := resolveRuntime()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
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().BoolVar(&flagJSON, "json", false, "JSON 输出(默认人类可读)")
|
||
root.PersistentFlags().StringVar(&flagCwd, "cwd", ".", "repo 探测的工作目录")
|
||
|
||
root.AddCommand(buildIssueCmd())
|
||
root.AddCommand(buildPRCmd())
|
||
return root
|
||
}
|
||
|
||
func buildIssueCmd() *cobra.Command {
|
||
issue := &cobra.Command{
|
||
Use: "issue",
|
||
Short: "Gitea issue 操作",
|
||
}
|
||
|
||
create := &cobra.Command{
|
||
Use: "create",
|
||
Short: "创建一个新工单并可选附加 spec/plan marker 与 state JSON",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if strings.TrimSpace(icTitle) == "" {
|
||
return fmt.Errorf("--title 不能为空")
|
||
}
|
||
body, err := resolveBody(icBody, icBodyFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if icSpec != "" {
|
||
body = marker.UpsertMarker(body, "spec", icSpec)
|
||
}
|
||
if icPlan != "" {
|
||
body = marker.UpsertMarker(body, "plan", icPlan)
|
||
}
|
||
// Build the merged state map (state-json/state-file as base, then
|
||
// fold in --spec / --plan as specFile / planFile so the kanban can
|
||
// read them from state JSON, which is the loader's source of truth
|
||
// — see superpowers-vscode/src/gitea/issueLoader.ts).
|
||
stateBytes, err := resolveStatePayload(icStateJSON, icStateFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
mergedState := map[string]any{}
|
||
if stateBytes != nil {
|
||
if err := json.Unmarshal(stateBytes, &mergedState); err != nil {
|
||
return fmt.Errorf("解析 --state-json/--state-file 内容失败: %w", err)
|
||
}
|
||
}
|
||
if icSpec != "" {
|
||
mergedState["specFile"] = icSpec
|
||
}
|
||
if icPlan != "" {
|
||
mergedState["planFile"] = icPlan
|
||
}
|
||
// Validate the merged blob BEFORE hitting the API so e.g. a bad
|
||
// --spec path fails fast instead of leaving an issue half-built.
|
||
// schema's specFile/planFile pattern doubles as input validation.
|
||
hasState := len(mergedState) > 0
|
||
if hasState {
|
||
if err := state.ValidateValue(mergedState); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
rc := fromCtx(cmd)
|
||
// 创建即认领:assignee=创建人,是插件 webhook 门禁的路由依据。
|
||
// 身份解析失败不阻塞创建(门禁侧有本地终端兜底)。
|
||
var assignees []string
|
||
if login, loginErr := rc.Client.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)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// --body-file is a one-shot handoff. Remove it now that the issue
|
||
// exists, so a stale file can't mislead a later brainstorm session
|
||
// into the wrong topic.
|
||
if icBodyFile != "" {
|
||
_ = os.Remove(icBodyFile)
|
||
}
|
||
var statePayload any
|
||
if hasState {
|
||
mergedBytes, err := json.Marshal(mergedState)
|
||
if err != nil {
|
||
return fmt.Errorf("工单已建 (#%d),但序列化 state JSON 失败: %w", issue.Number, err)
|
||
}
|
||
if _, postErr := rc.Client.CreateIssueComment(rc.Owner, rc.Repo, issue.Number, string(mergedBytes)); postErr != nil {
|
||
return fmt.Errorf("工单已建 (#%d),但写入 state JSON comment 失败: %w", issue.Number, postErr)
|
||
}
|
||
statePayload = mergedState
|
||
}
|
||
if rc.JSON {
|
||
return emitJSON(map[string]any{
|
||
"number": issue.Number,
|
||
"html_url": issue.HTMLURL,
|
||
"state": statePayload,
|
||
})
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "#%d\n", issue.Number)
|
||
return nil
|
||
},
|
||
}
|
||
create.Flags().StringVar(&icTitle, "title", "", "工单标题(必填)")
|
||
create.Flags().StringVar(&icBody, "body", "", "工单正文(与 --body-file 二选一)")
|
||
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(&icStateFile, "state-file", "", "从文件读取 state JSON")
|
||
_ = create.MarkFlagRequired("title")
|
||
|
||
markerCmd := &cobra.Command{
|
||
Use: "marker",
|
||
Short: "增量更新现有工单的 spec/plan marker,并同步合并 specFile/planFile 进 state JSON",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if imIssue <= 0 {
|
||
return fmt.Errorf("--issue 必须是正整数")
|
||
}
|
||
if imType != "spec" && imType != "plan" {
|
||
return fmt.Errorf("--type 必须是 spec 或 plan,得到 %q", imType)
|
||
}
|
||
// Fail fast on a bad path before we touch the network — the
|
||
// schema's specFile/planFile pattern doubles as input check.
|
||
fieldName := map[string]string{"spec": "specFile", "plan": "planFile"}[imType]
|
||
patch := map[string]any{fieldName: imValue}
|
||
if err := state.ValidateValue(patch); err != nil {
|
||
return err
|
||
}
|
||
rc := fromCtx(cmd)
|
||
cur, err := rc.Client.GetIssue(rc.Owner, rc.Repo, 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 {
|
||
return err
|
||
}
|
||
}
|
||
// Now merge specFile/planFile into the state JSON comment so the
|
||
// kanban (which reads state JSON, not body markers) sees the same
|
||
// value. If this fails the marker is already written, so we warn
|
||
// and exit non-zero rather than panic — re-running spx fixes it.
|
||
merged, mergeErr := mergeAndPostState(rc, imIssue, patch)
|
||
if mergeErr != nil {
|
||
fmt.Fprintf(os.Stderr, "warning: marker 已写入 #%d body,但合并到 state JSON 失败: %v\n", imIssue, mergeErr)
|
||
return mergeErr
|
||
}
|
||
if rc.JSON {
|
||
return emitJSON(map[string]any{
|
||
"number": imIssue,
|
||
"type": imType,
|
||
"value": imValue,
|
||
"state": merged,
|
||
})
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "已更新 #%d 的 %s marker → %s(state JSON 已同步合并 %s)\n", imIssue, imType, imValue, fieldName)
|
||
return nil
|
||
},
|
||
}
|
||
markerCmd.Flags().IntVar(&imIssue, "issue", 0, "工单编号(必填)")
|
||
markerCmd.Flags().StringVar(&imType, "type", "", "marker 类型:spec 或 plan(必填)")
|
||
markerCmd.Flags().StringVar(&imValue, "value", "", "marker 值(路径,必填,同步写入 state JSON 的 specFile/planFile)")
|
||
_ = markerCmd.MarkFlagRequired("issue")
|
||
_ = markerCmd.MarkFlagRequired("type")
|
||
_ = markerCmd.MarkFlagRequired("value")
|
||
|
||
issue.AddCommand(create, markerCmd, buildIssueStateCmd())
|
||
return issue
|
||
}
|
||
|
||
// 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.
|
||
func buildIssueStateCmd() *cobra.Command {
|
||
stateCmd := &cobra.Command{
|
||
Use: "state",
|
||
Short: "读取/合并工单的 state JSON(持久化在最后一条 comment)",
|
||
}
|
||
|
||
get := &cobra.Command{
|
||
Use: "get",
|
||
Short: "读取工单最后一条 comment 里的 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)
|
||
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)
|
||
},
|
||
}
|
||
get.Flags().IntVar(&isgIssue, "issue", 0, "工单编号(必填)")
|
||
_ = get.MarkFlagRequired("issue")
|
||
|
||
merge := &cobra.Command{
|
||
Use: "merge",
|
||
Short: "浅合并新 state JSON 到现有 state(最后一条 comment)",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if ismIssue <= 0 {
|
||
return fmt.Errorf("--issue 必须是正整数")
|
||
}
|
||
incomingBytes, err := resolveStatePayload(ismStateJSON, ismStateFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if incomingBytes == nil {
|
||
return fmt.Errorf("必须提供 --state-json 或 --state-file 之一")
|
||
}
|
||
// 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.
|
||
if err := state.Validate(incomingBytes); err != nil {
|
||
return err
|
||
}
|
||
var incoming map[string]any
|
||
if err := json.Unmarshal(incomingBytes, &incoming); err != nil {
|
||
return fmt.Errorf("解析 --state-json/--state-file 内容失败: %w", err)
|
||
}
|
||
rc := fromCtx(cmd)
|
||
merged, err := mergeAndPostState(rc, ismIssue, incoming)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
enc := json.NewEncoder(os.Stdout)
|
||
enc.SetEscapeHTML(false)
|
||
if rc.JSON {
|
||
enc.SetIndent("", " ")
|
||
}
|
||
return enc.Encode(merged)
|
||
},
|
||
}
|
||
merge.Flags().IntVar(&ismIssue, "issue", 0, "工单编号(必填)")
|
||
merge.Flags().StringVar(&ismStateJSON, "state-json", "", "state JSON 字符串(与 --state-file 二选一)")
|
||
merge.Flags().StringVar(&ismStateFile, "state-file", "", "从文件读取 state JSON")
|
||
_ = merge.MarkFlagRequired("issue")
|
||
|
||
stateCmd.AddCommand(get, merge)
|
||
return stateCmd
|
||
}
|
||
|
||
// 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.
|
||
func mergeAndPostState(rc *runtimeContext, issueNumber int, patch map[string]any) (map[string]any, error) {
|
||
cur, err := lastStateMap(rc.Client, rc.Owner, rc.Repo, issueNumber)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for k, v := range patch {
|
||
cur[k] = v
|
||
}
|
||
// Re-validate the merged blob before persisting so we never poison the
|
||
// issue's comment trail with malformed state.
|
||
if err := state.ValidateValue(cur); err != nil {
|
||
return nil, err
|
||
}
|
||
mergedBytes, err := json.Marshal(cur)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("序列化合并后的 state 失败: %w", err)
|
||
}
|
||
if _, err := rc.Client.CreateIssueComment(rc.Owner, rc.Repo, issueNumber, string(mergedBytes)); 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": {},
|
||
"profilePath": {},
|
||
"brainstormProfilePath": {},
|
||
"testProfilePath": {},
|
||
"specFile": {},
|
||
"planFile": {},
|
||
"prDiffFile": {},
|
||
"pr": {},
|
||
"prMerged": {},
|
||
"prMergedAt": {},
|
||
"branch": {},
|
||
"worktreePath": {},
|
||
"implementStatus": {},
|
||
"color": {},
|
||
"autoReview": {},
|
||
}
|
||
|
||
// 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.
|
||
// 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)
|
||
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
|
||
}
|
||
}
|
||
return map[string]any{}, nil
|
||
}
|
||
|
||
// resolveStatePayload picks the state JSON bytes from --state-json /
|
||
// --state-file. Returns (nil, nil) when neither is supplied so callers can
|
||
// branch on "no state requested". Both set is an error.
|
||
func resolveStatePayload(inline, file string) ([]byte, error) {
|
||
if inline != "" && file != "" {
|
||
return nil, fmt.Errorf("--state-json 和 --state-file 不能同时指定")
|
||
}
|
||
if inline != "" {
|
||
return []byte(inline), nil
|
||
}
|
||
if file != "" {
|
||
data, err := os.ReadFile(file)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 state 文件 %s 失败: %w", file, err)
|
||
}
|
||
return data, nil
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func buildPRCmd() *cobra.Command {
|
||
pr := &cobra.Command{
|
||
Use: "pr",
|
||
Short: "Gitea PR 操作",
|
||
}
|
||
rc := &cobra.Command{
|
||
Use: "review-comment",
|
||
Short: "给 PR 发一条带 review marker 的评论",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if prcPR <= 0 {
|
||
return fmt.Errorf("--pr 必须是正整数")
|
||
}
|
||
body, err := resolveBody(prcBody, prcBodyFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
body = marker.PrependReviewMarker(body)
|
||
rt := fromCtx(cmd)
|
||
cm, err := rt.Client.CreateIssueComment(rt.Owner, rt.Repo, prcPR, body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if rt.JSON {
|
||
return emitJSON(map[string]any{
|
||
"id": cm.ID,
|
||
"html_url": cm.HTMLURL,
|
||
})
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "已发评论到 PR #%d\n", prcPR)
|
||
return nil
|
||
},
|
||
}
|
||
rc.Flags().IntVar(&prcPR, "pr", 0, "PR 编号(必填)")
|
||
rc.Flags().StringVar(&prcBody, "body", "", "评论正文(与 --body-file 二选一)")
|
||
rc.Flags().StringVar(&prcBodyFile, "body-file", "", "从文件读取评论正文")
|
||
_ = rc.MarkFlagRequired("pr")
|
||
|
||
pr.AddCommand(rc)
|
||
return pr
|
||
}
|
||
|
||
// 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) {
|
||
if body != "" && file != "" {
|
||
return "", fmt.Errorf("--body 和 --body-file 不能同时指定")
|
||
}
|
||
if file != "" {
|
||
data, err := os.ReadFile(file)
|
||
if err != nil {
|
||
return "", fmt.Errorf("读取 body 文件 %s 失败: %w", file, err)
|
||
}
|
||
return string(data), nil
|
||
}
|
||
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 环境变量也没设置")
|
||
}
|
||
|
||
owner, repoName := "", ""
|
||
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, 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
|
||
}
|
||
|
||
return &runtimeContext{
|
||
Client: gitea.New(host, token),
|
||
Owner: owner,
|
||
Repo: repoName,
|
||
Host: host,
|
||
JSON: flagJSON,
|
||
}, nil
|
||
}
|
||
|
||
// fromCtx extracts the runtimeContext set up by PersistentPreRunE.
|
||
func fromCtx(cmd *cobra.Command) *runtimeContext {
|
||
v := cmd.Context().Value(ctxKey{})
|
||
if v == nil {
|
||
// Should be unreachable: PersistentPreRunE always populates it.
|
||
panic("runtimeContext missing from cobra context")
|
||
}
|
||
return v.(*runtimeContext)
|
||
}
|
||
|
||
// emitJSON writes v as a single-line JSON object to stdout.
|
||
func emitJSON(v any) error {
|
||
enc := json.NewEncoder(os.Stdout)
|
||
enc.SetEscapeHTML(false)
|
||
return enc.Encode(v)
|
||
}
|