11
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// CreateIssue creates a new issue and returns the created object.
|
||||
func (c *Client) CreateIssue(owner, repo, title, body string) (*Issue, error) {
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
|
||||
payload := map[string]any{"title": title, "body": body}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package marker manages spx HTML-comment markers embedded in issue/PR bodies.
|
||||
//
|
||||
// Markers look like: <!-- spx:spec=docs/specs/foo.md -->
|
||||
//
|
||||
// They let spx and the surrounding tooling round-trip metadata (spec/plan
|
||||
// paths, review flags, etc.) without dedicated Gitea fields.
|
||||
package marker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// markerRegex builds a regex matching a full marker line of the given type.
|
||||
//
|
||||
// The "value" capture group accepts any sequence of non-whitespace, non-'>'
|
||||
// characters, mirroring how the marker is written by UpsertMarker.
|
||||
func markerRegex(markerType string) *regexp.Regexp {
|
||||
return regexp.MustCompile(`<!--\s*spx:` + regexp.QuoteMeta(markerType) + `=([^\s>]*)\s*-->`)
|
||||
}
|
||||
|
||||
// reviewMarkerRegex matches the review-comment lead marker.
|
||||
var reviewMarkerRegex = regexp.MustCompile(`<!--\s*spx:review=1\s*-->`)
|
||||
|
||||
// UpsertMarker inserts or updates a marker of the given type in body.
|
||||
//
|
||||
// If a marker of this type already exists, the matching marker (just the tag,
|
||||
// not the whole line) is replaced in place. Otherwise the marker is appended
|
||||
// to the end of the body, separated by a newline so it sits on its own line.
|
||||
//
|
||||
// Other marker types in the body are preserved untouched.
|
||||
func UpsertMarker(body, markerType, value string) string {
|
||||
newMarker := fmt.Sprintf("<!-- spx:%s=%s -->", markerType, value)
|
||||
re := markerRegex(markerType)
|
||||
if re.MatchString(body) {
|
||||
return re.ReplaceAllString(body, newMarker)
|
||||
}
|
||||
if body == "" {
|
||||
return newMarker
|
||||
}
|
||||
// Ensure separation: end with at least one newline before the new marker.
|
||||
if strings.HasSuffix(body, "\n") {
|
||||
return body + newMarker
|
||||
}
|
||||
return body + "\n" + newMarker
|
||||
}
|
||||
|
||||
// PrependReviewMarker prepends "<!-- spx:review=1 -->\n\n" to body if not
|
||||
// already present. Idempotent: bodies already containing the marker are
|
||||
// returned unchanged.
|
||||
func PrependReviewMarker(body string) string {
|
||||
if reviewMarkerRegex.MatchString(body) {
|
||||
return body
|
||||
}
|
||||
const lead = "<!-- spx:review=1 -->\n\n"
|
||||
return lead + body
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package marker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpsertMarkerAppendsWhenAbsent(t *testing.T) {
|
||||
got := UpsertMarker("hello body", "spec", "docs/specs/x.md")
|
||||
want := "hello body\n<!-- spx:spec=docs/specs/x.md -->"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertMarkerReplacesWhenPresent(t *testing.T) {
|
||||
body := "intro\n<!-- spx:spec=old.md -->\nmore"
|
||||
got := UpsertMarker(body, "spec", "new.md")
|
||||
if strings.Contains(got, "old.md") {
|
||||
t.Fatalf("old value should be gone: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<!-- spx:spec=new.md -->") {
|
||||
t.Fatalf("new marker missing: %q", got)
|
||||
}
|
||||
// Other content preserved.
|
||||
if !strings.Contains(got, "intro") || !strings.Contains(got, "more") {
|
||||
t.Fatalf("surrounding content lost: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertMarkerLeavesOtherTypesAlone(t *testing.T) {
|
||||
body := "<!-- spx:plan=p.md -->\n<!-- spx:spec=old.md -->"
|
||||
got := UpsertMarker(body, "spec", "new.md")
|
||||
if !strings.Contains(got, "<!-- spx:plan=p.md -->") {
|
||||
t.Fatalf("plan marker should be preserved: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<!-- spx:spec=new.md -->") {
|
||||
t.Fatalf("spec marker should be updated: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertMarkerEmptyBody(t *testing.T) {
|
||||
got := UpsertMarker("", "plan", "p.md")
|
||||
want := "<!-- spx:plan=p.md -->"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrependReviewMarkerAdds(t *testing.T) {
|
||||
got := PrependReviewMarker("body")
|
||||
want := "<!-- spx:review=1 -->\n\nbody"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrependReviewMarkerIdempotent(t *testing.T) {
|
||||
body := "<!-- spx:review=1 -->\n\nbody"
|
||||
got := PrependReviewMarker(body)
|
||||
if got != body {
|
||||
t.Fatalf("expected unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package repo detects the owner/repo pair from a git working directory's
|
||||
// origin remote.
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sshLikeRegex matches `git@host:owner/repo(.git)?` style remotes.
|
||||
//
|
||||
// Excludes URI-shaped strings (anything with "://") so those go through
|
||||
// url.Parse instead.
|
||||
var sshLikeRegex = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+?)/?$`)
|
||||
|
||||
// DetectOwnerRepo runs `git -C cwd remote get-url origin` and parses the URL.
|
||||
//
|
||||
// Supported formats:
|
||||
// - https://host/owner/repo(.git)
|
||||
// - http://host/owner/repo(.git)
|
||||
// - ssh://git@host[:port]/owner/repo(.git)
|
||||
// - git@host:owner/repo(.git)
|
||||
//
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
raw := strings.TrimSpace(string(out))
|
||||
if raw == "" {
|
||||
return "", "", 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) {
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
// Try URL form (http/https/ssh://).
|
||||
u, perr := url.Parse(raw)
|
||||
if perr != nil {
|
||||
return "", "", fmt.Errorf("无法解析 origin URL %q: %w", raw, perr)
|
||||
}
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
if path == "" {
|
||||
return "", "", fmt.Errorf("origin URL %q 没有 path 部分", raw)
|
||||
}
|
||||
return splitOwnerRepo(path)
|
||||
}
|
||||
|
||||
// splitOwnerRepo turns "owner/repo(.git)" into ("owner", "repo").
|
||||
func splitOwnerRepo(path string) (owner, repo string, err error) {
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
path = strings.TrimSuffix(path, ".git")
|
||||
idx := strings.LastIndex(path, "/")
|
||||
if idx <= 0 || idx == len(path)-1 {
|
||||
return "", "", fmt.Errorf("origin path %q 不是 owner/repo 形式", path)
|
||||
}
|
||||
return path[:idx], path[idx+1:], nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package repo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseRemoteURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw, 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"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.raw, func(t *testing.T) {
|
||||
owner, repo, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRemoteURLRejectsBadInput(t *testing.T) {
|
||||
bad := []string{
|
||||
"",
|
||||
"not a url",
|
||||
"https://gitea.example.com/",
|
||||
"https://gitea.example.com/onlyowner",
|
||||
}
|
||||
for _, raw := range bad {
|
||||
if _, _, err := parseRemoteURL(raw); err == nil {
|
||||
t.Fatalf("expected error for %q", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package state embeds the issue state JSON schema and exposes a Validate
|
||||
// helper for spx subcommands.
|
||||
//
|
||||
// The schema lives in the repo root under schemas/ so non-Go tooling
|
||||
// (TypeScript / docs) can pick it up too; we embed it at build time so the
|
||||
// shipped binary doesn't depend on the source tree.
|
||||
package state
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v5"
|
||||
)
|
||||
|
||||
//go:embed schema.json
|
||||
var schemaBytes []byte
|
||||
|
||||
var (
|
||||
compiled *jsonschema.Schema
|
||||
compiledOnce sync.Once
|
||||
compiledErr error
|
||||
)
|
||||
|
||||
// SchemaBytes returns the embedded schema document. Useful for tooling that
|
||||
// wants to dump or re-publish the schema.
|
||||
func SchemaBytes() []byte {
|
||||
out := make([]byte, len(schemaBytes))
|
||||
copy(out, schemaBytes)
|
||||
return out
|
||||
}
|
||||
|
||||
// compile parses the embedded schema once and caches it.
|
||||
func compile() (*jsonschema.Schema, error) {
|
||||
compiledOnce.Do(func() {
|
||||
c := jsonschema.NewCompiler()
|
||||
c.Draft = jsonschema.Draft7
|
||||
// Use a synthetic URL — the real $id in the schema is just a marker.
|
||||
const url = "memory://state-json.schema.json"
|
||||
if err := c.AddResource(url, strings.NewReader(string(schemaBytes))); err != nil {
|
||||
compiledErr = fmt.Errorf("加载内嵌 schema 失败: %w", err)
|
||||
return
|
||||
}
|
||||
sch, err := c.Compile(url)
|
||||
if err != nil {
|
||||
compiledErr = fmt.Errorf("编译内嵌 schema 失败: %w", err)
|
||||
return
|
||||
}
|
||||
compiled = sch
|
||||
})
|
||||
return compiled, compiledErr
|
||||
}
|
||||
|
||||
// Validate parses jsonBytes as JSON and validates the resulting value against
|
||||
// the embedded state schema. Returns nil on success and a descriptive error
|
||||
// (listing failing fields and rules) on validation failure.
|
||||
func Validate(jsonBytes []byte) error {
|
||||
sch, err := compile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var doc any
|
||||
if err := json.Unmarshal(jsonBytes, &doc); err != nil {
|
||||
return fmt.Errorf("state JSON 解析失败: %w", err)
|
||||
}
|
||||
if err := sch.Validate(doc); err != nil {
|
||||
return formatValidationError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateValue validates an already-parsed value (e.g. the merged map).
|
||||
func ValidateValue(v any) error {
|
||||
sch, err := compile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sch.Validate(v); err != nil {
|
||||
return formatValidationError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatValidationError flattens a jsonschema validation tree into a
|
||||
// human-readable, multi-line error listing each failing field and rule.
|
||||
func formatValidationError(err error) error {
|
||||
ve, ok := err.(*jsonschema.ValidationError)
|
||||
if !ok {
|
||||
return fmt.Errorf("state JSON 校验失败: %w", err)
|
||||
}
|
||||
var lines []string
|
||||
collectValidationLines(ve, &lines)
|
||||
if len(lines) == 0 {
|
||||
return fmt.Errorf("state JSON 校验失败: %s", ve.Error())
|
||||
}
|
||||
return fmt.Errorf("state JSON 校验失败:\n - %s", strings.Join(lines, "\n - "))
|
||||
}
|
||||
|
||||
// collectValidationLines walks the validation tree and emits one line per leaf
|
||||
// failure: "<json-pointer>: <message>".
|
||||
func collectValidationLines(ve *jsonschema.ValidationError, out *[]string) {
|
||||
if ve == nil {
|
||||
return
|
||||
}
|
||||
if len(ve.Causes) == 0 {
|
||||
loc := ve.InstanceLocation
|
||||
if loc == "" {
|
||||
loc = "(root)"
|
||||
}
|
||||
*out = append(*out, fmt.Sprintf("%s: %s", loc, ve.Message))
|
||||
return
|
||||
}
|
||||
for _, c := range ve.Causes {
|
||||
collectValidationLines(c, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://github.com/cruldra/superpowers-vscode/schemas/state-json.schema.json",
|
||||
"title": "Superpowers VSCode issue state JSON",
|
||||
"description": "持久化在 issue 最后一条 comment 里的 JSON blob,KanbanPanel 用来恢复 issue 在看板上的列、关联的 spec/plan/PR/branch/worktree、以及各个会话 id 等运行时状态。所有字段均可选,缺失字段由 issueLoader 用默认值兜底。",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"column": {
|
||||
"type": "string",
|
||||
"description": "看板列 id;缺失时 issueLoader 按 issue.state 兜底为 todo/done。",
|
||||
"enum": ["todo", "in-progress", "review", "done"]
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string",
|
||||
"description": "讨论/头脑风暴会话的 Claude Code session id,用于 resume 已有对话。",
|
||||
"minLength": 1
|
||||
},
|
||||
"implementSessionId": {
|
||||
"type": "string",
|
||||
"description": "实施阶段 (implement) 的 Claude Code session id,与讨论 sessionId 隔离。",
|
||||
"minLength": 1
|
||||
},
|
||||
"reviewSessionId": {
|
||||
"type": "string",
|
||||
"description": "审查阶段的会话 id(v1 存 codex thread id),用于 webhook synchronize 时 resume。",
|
||||
"minLength": 1
|
||||
},
|
||||
"testSessionId": {
|
||||
"type": "string",
|
||||
"description": "测试阶段的 Claude Code session id;PR 合并后手动启动的测试会话,用于 resume。",
|
||||
"minLength": 1
|
||||
},
|
||||
"profilePath": {
|
||||
"type": "string",
|
||||
"description": "创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。",
|
||||
"minLength": 1
|
||||
},
|
||||
"specFile": {
|
||||
"type": "string",
|
||||
"description": "本工单的 spec 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
|
||||
"minLength": 1,
|
||||
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
|
||||
},
|
||||
"planFile": {
|
||||
"type": "string",
|
||||
"description": "本工单的 plan 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
|
||||
"minLength": 1,
|
||||
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
|
||||
},
|
||||
"prDiffFile": {
|
||||
"type": "string",
|
||||
"description": "本工单的 PR 变更摘要文件路径,相对 workspace,必须位于 docs/pr-diff/ 下。",
|
||||
"minLength": 1,
|
||||
"pattern": "^docs/pr-diff/[^\\s]+\\.md$"
|
||||
},
|
||||
"pr": {
|
||||
"type": "string",
|
||||
"description": "本工单关联的 PR number(字符串形式,webhook 触发后写入)。",
|
||||
"minLength": 1
|
||||
},
|
||||
"prMerged": {
|
||||
"type": "boolean",
|
||||
"description": "关联 PR 是否已合并;优先以 PR API 实时结果为准,此字段是 fallback。"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"description": "实施分支名,例如 `feature/<hash>`。",
|
||||
"minLength": 1
|
||||
},
|
||||
"worktreePath": {
|
||||
"type": "string",
|
||||
"description": "实施 worktree 的 workspace 相对路径。",
|
||||
"minLength": 1
|
||||
},
|
||||
"implementStatus": {
|
||||
"type": "string",
|
||||
"description": "实施流程生命周期状态。",
|
||||
"enum": ["running", "done", "failed"]
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "Terminal/tab 配色(一个 terminal.ansi* ThemeColor key),首次开会话时固化,后续所有会话复用同一色。",
|
||||
"minLength": 1
|
||||
},
|
||||
"autoReview": {
|
||||
"type": "boolean",
|
||||
"description": "本工单是否启用自动审查(覆盖全局 autoReview 设置)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSchemaMirrorInSync makes sure the embedded copy under internal/state/
|
||||
// matches the canonical schemas/state-json.schema.json at the repo root.
|
||||
// The two-file layout exists so plain `go build` keeps working (embed needs
|
||||
// the file inside the package tree) while non-Go tooling can still consume
|
||||
// the root schemas/ directory. Drift between the two would be silent without
|
||||
// this check.
|
||||
func TestSchemaMirrorInSync(t *testing.T) {
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("无法定位测试文件路径")
|
||||
}
|
||||
pkgDir := filepath.Dir(thisFile)
|
||||
canonical := filepath.Join(pkgDir, "..", "..", "..", "schemas", "state-json.schema.json")
|
||||
canonicalBytes, err := os.ReadFile(canonical)
|
||||
if err != nil {
|
||||
t.Fatalf("读取根目录 schema 失败: %v", err)
|
||||
}
|
||||
if !bytes.Equal(canonicalBytes, schemaBytes) {
|
||||
t.Fatalf("internal/state/schema.json 与 schemas/state-json.schema.json 内容不一致;请 `make sync-schema` 同步")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsEmpty(t *testing.T) {
|
||||
if err := Validate([]byte(`{}`)); err != nil {
|
||||
t.Fatalf("空对象应当通过校验,得到: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsBadColumn(t *testing.T) {
|
||||
err := Validate([]byte(`{"column":"todoo"}`))
|
||||
if err == nil {
|
||||
t.Fatal("非法 column 应当被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknownField(t *testing.T) {
|
||||
err := Validate([]byte(`{"weirdField":1}`))
|
||||
if err == nil {
|
||||
t.Fatal("未声明字段应当被 additionalProperties:false 拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsBadSpecFile(t *testing.T) {
|
||||
err := Validate([]byte(`{"specFile":"..."}`))
|
||||
if err == nil {
|
||||
t.Fatal("占位 `...` 应当被 pattern 拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsBadPrDiffFile(t *testing.T) {
|
||||
err := Validate([]byte(`{"prDiffFile":"docs/superpowers/plans/foo.md"}`))
|
||||
if err == nil {
|
||||
t.Fatal("非 docs/pr-diff/*.md 路径应当被 prDiffFile pattern 拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsFullState(t *testing.T) {
|
||||
full := `{
|
||||
"column":"in-progress",
|
||||
"sessionId":"abc",
|
||||
"implementSessionId":"impl-1",
|
||||
"reviewSessionId":"rev-1",
|
||||
"profilePath":"/home/x/.claude/settings.json",
|
||||
"specFile":"docs/superpowers/specs/foo.md",
|
||||
"planFile":"docs/superpowers/plans/foo.md",
|
||||
"prDiffFile":"docs/pr-diff/pr-42-issue-7.md",
|
||||
"pr":"42",
|
||||
"prMerged":true,
|
||||
"branch":"feature/abc",
|
||||
"worktreePath":".worktrees/foo",
|
||||
"implementStatus":"done",
|
||||
"color":"terminal.ansiBlue",
|
||||
"autoReview":true
|
||||
}`
|
||||
if err := Validate([]byte(full)); err != nil {
|
||||
t.Fatalf("完整合法 state 应当通过校验,得到: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package tea reads the tea CLI config to extract Gitea host + token.
|
||||
//
|
||||
// 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.
|
||||
package tea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TeaLogin represents a single login entry in tea config.yml.
|
||||
type TeaLogin struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token"`
|
||||
Default bool `yaml:"default"`
|
||||
}
|
||||
|
||||
// TeaConfig is the top-level structure of tea config.yml.
|
||||
type TeaConfig struct {
|
||||
Logins []TeaLogin `yaml:"logins"`
|
||||
}
|
||||
|
||||
// DefaultConfigPath returns the conventional path to tea's config.yml.
|
||||
func DefaultConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析 home 目录失败: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".config", "tea", "config.yml"), nil
|
||||
}
|
||||
|
||||
// load reads and parses the tea config file at the given path.
|
||||
func load(path string) (*TeaConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 tea config %s 失败: %w", path, err)
|
||||
}
|
||||
var cfg TeaConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析 tea config %s 失败: %w", path, err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// LoadDefault returns host and token from the default login entry.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
cfg, err := load(path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(cfg.Logins) == 0 {
|
||||
return "", "", fmt.Errorf("tea config %s 里没有 logins 条目,请先运行 `tea login add`", path)
|
||||
}
|
||||
var chosen *TeaLogin
|
||||
for i := range cfg.Logins {
|
||||
if cfg.Logins[i].Default {
|
||||
chosen = &cfg.Logins[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user