feat: implement streaming chat, skill routing, and SAFe PI planning tools
- Add /api/chat/stream endpoint with Server-Sent Events (SSE) for real-time message streaming * Implement StreamEvent types (thought, tool_call, tool_result, final, error) * Add StreamEventCallback mechanism for event propagation * Create StreamChatHandler in webui/bot with proper HTTP headers and flushing - Implement LLM-based skill router for intelligent capability selection * Add optional routerLLM client for semantic routing * Implement routeSkillsWithLLM() to match user intent to available skills * Add matchSkillsByName() for fuzzy skill matching * Update buildUnifiedSystemPrompt() to use routed skills - Add streaming support to ReAct pipeline * Implement runUnifiedReActStream() for streaming thought/action/observation * Emit StreamEvent at each ReAct step * Support callback error handling in streaming mode - Integrate three new DevOps tools * tools/filedoc: Extract document content from file_id via OpenAI * tools/giteaticket: Create Gitea issues from PI plan items with SAFe metadata * tools/piplan: Publish PI planning blueprints with dependency tracking - Add SAFe PI Planning skill * Implement PM/SA/RTE (iron triangle) workflow * Support for Feature, Enabler, and Dependency definition * Automatic task decomposition and Gitea integration - Create frontend integration documentation * Complete SSE protocol specification * TypeScript fetch + ReadableStream example * LLM-ready refactoring template for other projects - Simplify file handling * Remove legacy file context structures and dual-mode processing * Consolidate file operations into UploadAndCacheFiles() * Remove FilePromptMode configuration and related complexity - Update configuration * Add Router model support (LLM_ROUTER_MODEL) * Add Gitea configuration (BaseURL, Token, Owner, Repo) * WebSearch and additional tool infrastructure Tests: All 22 test packages passing, 8/8 webui tests including 3 new stream tests
This commit is contained in:
208
tools/filedoc/filedoc.go
Normal file
208
tools/filedoc/filedoc.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package filedoc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"laodingbot/internal/logger"
|
||||
|
||||
openai "github.com/openai/openai-go"
|
||||
"github.com/openai/openai-go/option"
|
||||
"github.com/openai/openai-go/shared"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
client openai.Client
|
||||
model string
|
||||
maxOutputChars int
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
func New(cfg Config, maxOutputChars int, log *logger.Logger) *Tool {
|
||||
if strings.TrimSpace(cfg.Model) == "" {
|
||||
cfg.Model = "gpt-4o-mini"
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 60 * time.Second
|
||||
}
|
||||
if maxOutputChars <= 0 {
|
||||
maxOutputChars = 12000
|
||||
}
|
||||
|
||||
opts := []option.RequestOption{
|
||||
option.WithAPIKey(strings.TrimSpace(cfg.APIKey)),
|
||||
option.WithRequestTimeout(cfg.Timeout),
|
||||
}
|
||||
if strings.TrimSpace(cfg.BaseURL) != "" {
|
||||
opts = append(opts, option.WithBaseURL(strings.TrimSpace(cfg.BaseURL)))
|
||||
}
|
||||
|
||||
return &Tool{
|
||||
client: openai.NewClient(opts...),
|
||||
model: cfg.Model,
|
||||
maxOutputChars: maxOutputChars,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tool) Name() string { return "extract_file_document" }
|
||||
|
||||
func (t *Tool) Description() string {
|
||||
return "Extract full document details from a file ID via OpenAI. Input: file_id (supports plain ID, fileid://ID, or JSON {\"file_id\":\"...\"})."
|
||||
}
|
||||
|
||||
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||
fileID, userFocus, err := parseInput(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
prompt := buildExtractionPrompt(fileID, userFocus)
|
||||
messages := []openai.ChatCompletionMessageParamUnion{
|
||||
openai.SystemMessage("fileid://" + fileID),
|
||||
openai.UserMessage([]openai.ChatCompletionContentPartUnionParam{
|
||||
openai.TextContentPart(prompt),
|
||||
}),
|
||||
}
|
||||
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Model: shared.ChatModel(t.model),
|
||||
Messages: messages,
|
||||
}
|
||||
|
||||
if t.log != nil {
|
||||
t.log.Infof("filedoc tool request model=%s file_id=%s", t.model, fileID)
|
||||
}
|
||||
|
||||
resp, err := t.client.Chat.Completions.New(ctx, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("filedoc request failed: %w", err)
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return "", fmt.Errorf("filedoc returned empty choices")
|
||||
}
|
||||
|
||||
out := strings.TrimSpace(resp.Choices[0].Message.Content)
|
||||
if out == "" {
|
||||
out = "未提取到可读的文档内容。请确认 file_id 是否有效以及模型是否支持文件解析。"
|
||||
}
|
||||
if len(out) > t.maxOutputChars {
|
||||
out = out[:t.maxOutputChars]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildExtractionPrompt(fileID, userFocus string) string {
|
||||
focus := strings.TrimSpace(userFocus)
|
||||
if focus == "" {
|
||||
focus = "请输出完整文档信息,包括标题、主题、核心观点、结构大纲、关键术语、重要结论、风险点与后续建议。"
|
||||
}
|
||||
|
||||
return strings.Join([]string{
|
||||
"请基于所附文件输出完整文档信息。",
|
||||
"file_id: " + fileID,
|
||||
"",
|
||||
"输出要求:",
|
||||
"1) 文档基本信息:标题、文档类型、语言、可能作者/组织(若可判断)、时间线索(若可判断)。",
|
||||
"2) 结构化摘要:按章节或逻辑段落给出要点,尽量保持原文顺序。",
|
||||
"3) 关键数据与事实:列出关键数字、术语、专有名词、约束条件。",
|
||||
"4) 风险与不确定性:明确哪些信息来源于文档,哪些是无法确认。",
|
||||
"5) 面向执行的建议:给出可落地的后续行动项。",
|
||||
"",
|
||||
"补充关注点:",
|
||||
focus,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func parseInput(input string) (fileID string, userFocus string, err error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return "", "", fmt.Errorf("empty input: expected file_id")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(raw, "{") {
|
||||
var payload map[string]any
|
||||
if jsonErr := json.Unmarshal([]byte(raw), &payload); jsonErr == nil {
|
||||
if id := firstNonEmptyString(payload, "file_id", "fileid", "id", "fileID"); id != "" {
|
||||
return normalizeFileID(id), firstNonEmptyString(payload, "focus", "query", "instruction", "prompt"), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines := strings.Split(raw, "\n")
|
||||
for _, line := range lines {
|
||||
candidate := extractFileIDToken(line)
|
||||
if candidate != "" {
|
||||
focus := strings.TrimSpace(strings.ReplaceAll(raw, line, ""))
|
||||
return normalizeFileID(candidate), focus, nil
|
||||
}
|
||||
}
|
||||
|
||||
candidate := extractFileIDToken(raw)
|
||||
if candidate == "" {
|
||||
return "", "", fmt.Errorf("no file_id found in input")
|
||||
}
|
||||
return normalizeFileID(candidate), "", nil
|
||||
}
|
||||
|
||||
func extractFileIDToken(s string) string {
|
||||
fields := strings.FieldsFunc(s, func(r rune) bool {
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r', ',', ';', '|':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
for _, f := range fields {
|
||||
tok := strings.TrimSpace(strings.Trim(f, "\"'()[]{}"))
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(tok)
|
||||
if strings.HasPrefix(lower, "fileid://") {
|
||||
return tok[len("fileid://"):]
|
||||
}
|
||||
if strings.HasPrefix(lower, "file_id=") || strings.HasPrefix(lower, "fileid=") {
|
||||
idx := strings.Index(tok, "=")
|
||||
if idx >= 0 && idx+1 < len(tok) {
|
||||
return tok[idx+1:]
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(lower, "file_") || strings.HasPrefix(lower, "file-") {
|
||||
return tok
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeFileID(id string) string {
|
||||
id = strings.TrimSpace(strings.Trim(id, "\"'"))
|
||||
if strings.HasPrefix(strings.ToLower(id), "fileid://") {
|
||||
return strings.TrimSpace(id[len("fileid://"):])
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func firstNonEmptyString(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
74
tools/filedoc/filedoc_test.go
Normal file
74
tools/filedoc/filedoc_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package filedoc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNameAndDescription(t *testing.T) {
|
||||
tool := New(Config{APIKey: "k", Model: "gpt-4o-mini"}, 5000, nil)
|
||||
if tool.Name() != "extract_file_document" {
|
||||
t.Fatalf("unexpected tool name: %s", tool.Name())
|
||||
}
|
||||
if tool.Description() == "" {
|
||||
t.Fatal("description should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputPlainFileID(t *testing.T) {
|
||||
id, focus, err := parseInput("file_ec_452e96aad38940229058f193f5c5b9c6_12553222")
|
||||
if err != nil {
|
||||
t.Fatalf("parseInput returned error: %v", err)
|
||||
}
|
||||
if id != "file_ec_452e96aad38940229058f193f5c5b9c6_12553222" {
|
||||
t.Fatalf("unexpected id: %s", id)
|
||||
}
|
||||
if focus != "" {
|
||||
t.Fatalf("expected empty focus, got: %q", focus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputFileIDSchemeWithFocus(t *testing.T) {
|
||||
input := "fileid://file_ec_12345\n重点关注风险与建议"
|
||||
id, focus, err := parseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseInput returned error: %v", err)
|
||||
}
|
||||
if id != "file_ec_12345" {
|
||||
t.Fatalf("unexpected id: %s", id)
|
||||
}
|
||||
if focus == "" {
|
||||
t.Fatal("expected non-empty focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputJSON(t *testing.T) {
|
||||
input := `{"file_id":"file_ec_888", "focus":"提取关键结论"}`
|
||||
id, focus, err := parseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseInput returned error: %v", err)
|
||||
}
|
||||
if id != "file_ec_888" {
|
||||
t.Fatalf("unexpected id: %s", id)
|
||||
}
|
||||
if focus != "提取关键结论" {
|
||||
t.Fatalf("unexpected focus: %s", focus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputInvalid(t *testing.T) {
|
||||
_, _, err := parseInput("hello world")
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtractionPrompt(t *testing.T) {
|
||||
p := buildExtractionPrompt("file_ec_abc", "关注测试条目")
|
||||
if p == "" {
|
||||
t.Fatal("prompt should not be empty")
|
||||
}
|
||||
if p != "" && !(strings.Contains(p, "file_ec_abc") && strings.Contains(p, "关注测试条目")) {
|
||||
t.Fatalf("unexpected prompt content: %s", p)
|
||||
}
|
||||
}
|
||||
328
tools/giteaticket/giteaticket.go
Normal file
328
tools/giteaticket/giteaticket.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package giteaticket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"laodingbot/internal/logger"
|
||||
)
|
||||
|
||||
// Config Gitea 工单工具的配置。
|
||||
type Config struct {
|
||||
BaseURL string // Gitea 实例地址,例如 https://gitea.example.com
|
||||
Token string // Gitea Personal Access Token
|
||||
Owner string // 仓库所有者(用户名或组织名)
|
||||
Repo string // 仓库名称
|
||||
Timeout time.Duration // HTTP 请求超时
|
||||
}
|
||||
|
||||
// TicketInput 对应 tool schema 定义的输入参数。
|
||||
type TicketInput struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Labels []string `json:"labels"`
|
||||
ParentReferenceID string `json:"parent_reference_id"`
|
||||
EstimatedHours int `json:"estimated_hours,omitempty"`
|
||||
}
|
||||
|
||||
// giteaCreateIssueReq Gitea Create Issue API 请求体。
|
||||
type giteaCreateIssueReq struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Labels []int64 `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
// giteaIssueResp Gitea Issue API 响应(仅用到的字段)。
|
||||
type giteaIssueResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Number int64 `json:"number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// giteaLabelResp Gitea Label API 响应。
|
||||
type giteaLabelResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Tool 实现 create_gitea_ticket 工具。
|
||||
type Tool struct {
|
||||
baseURL string
|
||||
token string
|
||||
owner string
|
||||
repo string
|
||||
httpClient *http.Client
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
// New 创建一个新的 create_gitea_ticket 工具实例。
|
||||
func New(cfg Config, log *logger.Logger) *Tool {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 30 * time.Second
|
||||
}
|
||||
return &Tool{
|
||||
baseURL: baseURL,
|
||||
token: strings.TrimSpace(cfg.Token),
|
||||
owner: strings.TrimSpace(cfg.Owner),
|
||||
repo: strings.TrimSpace(cfg.Repo),
|
||||
httpClient: &http.Client{Timeout: cfg.Timeout},
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tool) Name() string { return "create_gitea_ticket" }
|
||||
|
||||
func (t *Tool) Description() string {
|
||||
return `DevOps Agent 使用此工具,将 PI Plan 中的 Feature/Enabler 拆解为可执行 User Story,并在 Gitea 创建 Issue。输入 JSON: {"title":"...","body":"...","labels":[...],"parent_reference_id":"...","estimated_hours":8}`
|
||||
}
|
||||
|
||||
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||
ticket, err := parseInput(input)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create_gitea_ticket: invalid input: %w", err)
|
||||
}
|
||||
if err := validate(ticket); err != nil {
|
||||
return "", fmt.Errorf("create_gitea_ticket: validation failed: %w", err)
|
||||
}
|
||||
if t.baseURL == "" || t.token == "" || t.owner == "" || t.repo == "" {
|
||||
return "", fmt.Errorf("create_gitea_ticket: missing Gitea configuration (base_url, token, owner, repo)")
|
||||
}
|
||||
|
||||
body := buildIssueBody(ticket)
|
||||
|
||||
labelIDs, err := t.resolveLabels(ctx, ticket.Labels)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create_gitea_ticket: label resolution failed: %w", err)
|
||||
}
|
||||
|
||||
if t.log != nil {
|
||||
t.log.Infof("create_gitea_ticket: creating issue title=%q parent=%s labels=%v",
|
||||
ticket.Title, ticket.ParentReferenceID, ticket.Labels)
|
||||
}
|
||||
|
||||
issue, err := t.createIssue(ctx, ticket.Title, body, labelIDs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create_gitea_ticket: create issue failed: %w", err)
|
||||
}
|
||||
|
||||
result := formatResult(issue, ticket)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseInput(input string) (*TicketInput, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("empty input")
|
||||
}
|
||||
var ticket TicketInput
|
||||
if err := json.Unmarshal([]byte(raw), &ticket); err != nil {
|
||||
return nil, fmt.Errorf("JSON parse error: %w", err)
|
||||
}
|
||||
return &ticket, nil
|
||||
}
|
||||
|
||||
func validate(t *TicketInput) error {
|
||||
if strings.TrimSpace(t.Title) == "" {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
if strings.TrimSpace(t.Body) == "" {
|
||||
return fmt.Errorf("body is required")
|
||||
}
|
||||
if len(t.Labels) == 0 {
|
||||
return fmt.Errorf("labels is required and must contain at least one label")
|
||||
}
|
||||
if strings.TrimSpace(t.ParentReferenceID) == "" {
|
||||
return fmt.Errorf("parent_reference_id is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildIssueBody 在原始 body 上追加溯源元数据和工时估算。
|
||||
func buildIssueBody(ticket *TicketInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(ticket.Body)
|
||||
b.WriteString("\n\n---\n\n")
|
||||
b.WriteString("## 📋 SAFe 元数据\n\n")
|
||||
b.WriteString(fmt.Sprintf("- **溯源 (Parent Reference)**: `%s`\n", ticket.ParentReferenceID))
|
||||
if ticket.EstimatedHours > 0 {
|
||||
b.WriteString(fmt.Sprintf("- **预估工时**: %d 小时\n", ticket.EstimatedHours))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- **标签**: %s\n", strings.Join(ticket.Labels, ", ")))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// resolveLabels 通过 Gitea API 查询已有标签,将标签名映射为 ID。
|
||||
// 如果标签不存在则自动创建。
|
||||
func (t *Tool) resolveLabels(ctx context.Context, labelNames []string) ([]int64, error) {
|
||||
existing, err := t.listLabels(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nameToID := make(map[string]int64, len(existing))
|
||||
for _, l := range existing {
|
||||
nameToID[l.Name] = l.ID
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(labelNames))
|
||||
for _, name := range labelNames {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if id, ok := nameToID[name]; ok {
|
||||
ids = append(ids, id)
|
||||
} else {
|
||||
id, err := t.createLabel(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create label %q: %w", name, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (t *Tool) listLabels(ctx context.Context) ([]giteaLabelResp, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", t.baseURL, t.owner, t.repo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.setAuth(req)
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list labels request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("list labels returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var labels []giteaLabelResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil {
|
||||
return nil, fmt.Errorf("decode labels response: %w", err)
|
||||
}
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func (t *Tool) createLabel(ctx context.Context, name string) (int64, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", t.baseURL, t.owner, t.repo)
|
||||
payload := map[string]string{
|
||||
"name": name,
|
||||
"color": labelColor(name),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
t.setAuth(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create label request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return 0, fmt.Errorf("create label returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var label giteaLabelResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&label); err != nil {
|
||||
return 0, fmt.Errorf("decode create label response: %w", err)
|
||||
}
|
||||
return label.ID, nil
|
||||
}
|
||||
|
||||
func (t *Tool) createIssue(ctx context.Context, title, body string, labelIDs []int64) (*giteaIssueResp, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", t.baseURL, t.owner, t.repo)
|
||||
payload := giteaCreateIssueReq{
|
||||
Title: title,
|
||||
Body: body,
|
||||
Labels: labelIDs,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.setAuth(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create issue request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("create issue returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var issue giteaIssueResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
|
||||
return nil, fmt.Errorf("decode create issue response: %w", err)
|
||||
}
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
func (t *Tool) setAuth(req *http.Request) {
|
||||
if t.token != "" {
|
||||
req.Header.Set("Authorization", "token "+t.token)
|
||||
}
|
||||
}
|
||||
|
||||
func formatResult(issue *giteaIssueResp, ticket *TicketInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("✅ Gitea Issue 创建成功\n\n")
|
||||
b.WriteString(fmt.Sprintf("- **Issue 编号**: #%d\n", issue.Number))
|
||||
b.WriteString(fmt.Sprintf("- **标题**: %s\n", issue.Title))
|
||||
b.WriteString(fmt.Sprintf("- **状态**: %s\n", issue.State))
|
||||
b.WriteString(fmt.Sprintf("- **链接**: %s\n", issue.HTMLURL))
|
||||
b.WriteString(fmt.Sprintf("- **溯源 (Parent)**: %s\n", ticket.ParentReferenceID))
|
||||
if ticket.EstimatedHours > 0 {
|
||||
b.WriteString(fmt.Sprintf("- **预估工时**: %d 小时\n", ticket.EstimatedHours))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- **标签**: %s\n", strings.Join(ticket.Labels, ", ")))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// labelColor 根据标签前缀返回一个辨识度高的颜色。
|
||||
func labelColor(name string) string {
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "type/"):
|
||||
return "#0075ca"
|
||||
case strings.HasPrefix(lower, "domain/"):
|
||||
return "#7057ff"
|
||||
case strings.HasPrefix(lower, "priority/"):
|
||||
if strings.Contains(lower, "high") || strings.Contains(lower, "critical") {
|
||||
return "#d73a4a"
|
||||
}
|
||||
return "#e4e669"
|
||||
case strings.HasPrefix(lower, "status/"):
|
||||
return "#0e8a16"
|
||||
default:
|
||||
return "#ededed"
|
||||
}
|
||||
}
|
||||
403
tools/giteaticket/giteaticket_test.go
Normal file
403
tools/giteaticket/giteaticket_test.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package giteaticket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validTicketInput() TicketInput {
|
||||
return TicketInput{
|
||||
Title: "实现云端固件版本解析 API",
|
||||
Body: `## 溯源
|
||||
- Parent: FEAT_OTA_001
|
||||
|
||||
## 任务上下文
|
||||
云端需要能够解析上传的固件包,提取版本号与依赖关系。
|
||||
|
||||
## 验收标准
|
||||
- [ ] 上传 .bin 固件文件后返回解析结果 JSON
|
||||
- [ ] 解析结果包含 version, dependencies 字段
|
||||
|
||||
## NFRs
|
||||
- 响应时间 P99 < 500ms
|
||||
|
||||
## 技术实现思路
|
||||
使用 Go 读取固件文件头部 metadata。`,
|
||||
Labels: []string{"type/story", "domain/cloud", "priority/high", "status/todo"},
|
||||
ParentReferenceID: "FEAT_OTA_001",
|
||||
EstimatedHours: 8,
|
||||
}
|
||||
}
|
||||
|
||||
// ── parseInput tests ──
|
||||
|
||||
func TestParseInputValid(t *testing.T) {
|
||||
in := validTicketInput()
|
||||
data, _ := json.Marshal(in)
|
||||
ticket, err := parseInput(string(data))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ticket.Title != in.Title {
|
||||
t.Errorf("title mismatch: got %q", ticket.Title)
|
||||
}
|
||||
if len(ticket.Labels) != 4 {
|
||||
t.Errorf("expected 4 labels, got %d", len(ticket.Labels))
|
||||
}
|
||||
if ticket.ParentReferenceID != "FEAT_OTA_001" {
|
||||
t.Errorf("parent_reference_id mismatch: got %q", ticket.ParentReferenceID)
|
||||
}
|
||||
if ticket.EstimatedHours != 8 {
|
||||
t.Errorf("estimated_hours mismatch: got %d", ticket.EstimatedHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputEmpty(t *testing.T) {
|
||||
_, err := parseInput("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputInvalidJSON(t *testing.T) {
|
||||
_, err := parseInput("{bad json}")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// ── validate tests ──
|
||||
|
||||
func TestValidateMissingTitle(t *testing.T) {
|
||||
in := validTicketInput()
|
||||
in.Title = ""
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "title") {
|
||||
t.Fatalf("expected title error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingBody(t *testing.T) {
|
||||
in := validTicketInput()
|
||||
in.Body = ""
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "body") {
|
||||
t.Fatalf("expected body error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingLabels(t *testing.T) {
|
||||
in := validTicketInput()
|
||||
in.Labels = nil
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "labels") {
|
||||
t.Fatalf("expected labels error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingParentRef(t *testing.T) {
|
||||
in := validTicketInput()
|
||||
in.ParentReferenceID = ""
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "parent_reference_id") {
|
||||
t.Fatalf("expected parent_reference_id error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOptionalEstimatedHours(t *testing.T) {
|
||||
in := validTicketInput()
|
||||
in.EstimatedHours = 0
|
||||
err := validate(&in)
|
||||
if err != nil {
|
||||
t.Fatalf("estimated_hours should be optional, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── buildIssueBody tests ──
|
||||
|
||||
func TestBuildIssueBodyContainsMetadata(t *testing.T) {
|
||||
in := validTicketInput()
|
||||
body := buildIssueBody(&in)
|
||||
|
||||
if !strings.Contains(body, "FEAT_OTA_001") {
|
||||
t.Error("body missing parent_reference_id")
|
||||
}
|
||||
if !strings.Contains(body, "8 小时") {
|
||||
t.Error("body missing estimated_hours")
|
||||
}
|
||||
if !strings.Contains(body, "type/story") {
|
||||
t.Error("body missing labels")
|
||||
}
|
||||
if !strings.Contains(body, "SAFe 元数据") {
|
||||
t.Error("body missing metadata section header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIssueBodyNoHours(t *testing.T) {
|
||||
in := validTicketInput()
|
||||
in.EstimatedHours = 0
|
||||
body := buildIssueBody(&in)
|
||||
if strings.Contains(body, "预估工时") {
|
||||
t.Error("body should not contain estimated hours when 0")
|
||||
}
|
||||
}
|
||||
|
||||
// ── labelColor tests ──
|
||||
|
||||
func TestLabelColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"type/story", "#0075ca"},
|
||||
{"domain/cloud", "#7057ff"},
|
||||
{"priority/high", "#d73a4a"},
|
||||
{"priority/low", "#e4e669"},
|
||||
{"status/todo", "#0e8a16"},
|
||||
{"custom-label", "#ededed"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := labelColor(tc.name)
|
||||
if got != tc.expected {
|
||||
t.Errorf("labelColor(%q) = %q, want %q", tc.name, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Name / Description tests ──
|
||||
|
||||
func TestNameAndDescription(t *testing.T) {
|
||||
tool := New(Config{}, nil)
|
||||
if tool.Name() != "create_gitea_ticket" {
|
||||
t.Errorf("unexpected name: %s", tool.Name())
|
||||
}
|
||||
if tool.Description() == "" {
|
||||
t.Error("description should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration test with mock Gitea server ──
|
||||
|
||||
func TestCallWithMockGitea(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var createdIssue map[string]interface{}
|
||||
labelIDCounter := int64(100)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify auth header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "token test-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
// GET /api/v1/repos/owner/repo/labels
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Return one existing label
|
||||
json.NewEncoder(w).Encode([]giteaLabelResp{
|
||||
{ID: 1, Name: "type/story"},
|
||||
})
|
||||
|
||||
// POST /api/v1/repos/owner/repo/labels
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/labels"):
|
||||
var payload map[string]string
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
mu.Lock()
|
||||
labelIDCounter++
|
||||
id := labelIDCounter
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(giteaLabelResp{
|
||||
ID: id,
|
||||
Name: payload["name"],
|
||||
})
|
||||
|
||||
// POST /api/v1/repos/owner/repo/issues
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/issues"):
|
||||
json.NewDecoder(r.Body).Decode(&createdIssue)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(giteaIssueResp{
|
||||
ID: 42,
|
||||
Number: 42,
|
||||
HTMLURL: "https://gitea.example.com/owner/repo/issues/42",
|
||||
Title: createdIssue["title"].(string),
|
||||
State: "open",
|
||||
CreatedAt: "2026-03-11T10:00:00Z",
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tool := New(Config{
|
||||
BaseURL: server.URL,
|
||||
Token: "test-token",
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}, nil)
|
||||
|
||||
in := validTicketInput()
|
||||
data, _ := json.Marshal(in)
|
||||
|
||||
result, err := tool.Call(context.Background(), string(data))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "#42") {
|
||||
t.Error("result missing issue number")
|
||||
}
|
||||
if !strings.Contains(result, "创建成功") {
|
||||
t.Error("result missing success message")
|
||||
}
|
||||
if !strings.Contains(result, "FEAT_OTA_001") {
|
||||
t.Error("result missing parent reference")
|
||||
}
|
||||
if !strings.Contains(result, "8 小时") {
|
||||
t.Error("result missing estimated hours")
|
||||
}
|
||||
|
||||
// Verify the issue body sent to Gitea contains SAFe metadata
|
||||
if body, ok := createdIssue["body"].(string); ok {
|
||||
if !strings.Contains(body, "SAFe 元数据") {
|
||||
t.Error("issue body missing SAFe metadata")
|
||||
}
|
||||
if !strings.Contains(body, "FEAT_OTA_001") {
|
||||
t.Error("issue body missing parent reference")
|
||||
}
|
||||
} else {
|
||||
t.Error("createdIssue body not captured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallMissingConfig(t *testing.T) {
|
||||
tool := New(Config{}, nil)
|
||||
in := validTicketInput()
|
||||
data, _ := json.Marshal(in)
|
||||
|
||||
_, err := tool.Call(context.Background(), string(data))
|
||||
if err == nil || !strings.Contains(err.Error(), "missing Gitea configuration") {
|
||||
t.Fatalf("expected config error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallInvalidInput(t *testing.T) {
|
||||
tool := New(Config{
|
||||
BaseURL: "http://localhost",
|
||||
Token: "t",
|
||||
Owner: "o",
|
||||
Repo: "r",
|
||||
}, nil)
|
||||
_, err := tool.Call(context.Background(), "not json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallValidationError(t *testing.T) {
|
||||
tool := New(Config{
|
||||
BaseURL: "http://localhost",
|
||||
Token: "t",
|
||||
Owner: "o",
|
||||
Repo: "r",
|
||||
}, nil)
|
||||
in := validTicketInput()
|
||||
in.Title = ""
|
||||
data, _ := json.Marshal(in)
|
||||
|
||||
_, err := tool.Call(context.Background(), string(data))
|
||||
if err == nil || !strings.Contains(err.Error(), "title") {
|
||||
t.Fatalf("expected title validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that labels resolution creates missing labels
|
||||
func TestCallCreatesNewLabels(t *testing.T) {
|
||||
createdLabels := []string{}
|
||||
var mu sync.Mutex
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/labels"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// No existing labels
|
||||
json.NewEncoder(w).Encode([]giteaLabelResp{})
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/labels"):
|
||||
var payload map[string]string
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
mu.Lock()
|
||||
createdLabels = append(createdLabels, payload["name"])
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(giteaLabelResp{
|
||||
ID: int64(len(createdLabels)),
|
||||
Name: payload["name"],
|
||||
})
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/issues"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(giteaIssueResp{
|
||||
ID: 1, Number: 1, HTMLURL: "http://test/1", Title: "t", State: "open",
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tool := New(Config{
|
||||
BaseURL: server.URL,
|
||||
Token: "tok",
|
||||
Owner: "o",
|
||||
Repo: "r",
|
||||
}, nil)
|
||||
|
||||
in := validTicketInput()
|
||||
data, _ := json.Marshal(in)
|
||||
_, err := tool.Call(context.Background(), string(data))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(createdLabels) != 4 {
|
||||
t.Errorf("expected 4 labels created, got %d: %v", len(createdLabels), createdLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatResult(t *testing.T) {
|
||||
issue := &giteaIssueResp{
|
||||
Number: 99,
|
||||
Title: "测试任务",
|
||||
State: "open",
|
||||
HTMLURL: "https://gitea.example.com/issues/99",
|
||||
}
|
||||
ticket := &TicketInput{
|
||||
ParentReferenceID: "ENAB_KAFKA_001",
|
||||
Labels: []string{"type/enabler"},
|
||||
EstimatedHours: 4,
|
||||
}
|
||||
result := formatResult(issue, ticket)
|
||||
|
||||
if !strings.Contains(result, "#99") {
|
||||
t.Error("missing issue number")
|
||||
}
|
||||
if !strings.Contains(result, "ENAB_KAFKA_001") {
|
||||
t.Error("missing parent reference")
|
||||
}
|
||||
if !strings.Contains(result, "4 小时") {
|
||||
t.Error("missing estimated hours")
|
||||
}
|
||||
}
|
||||
309
tools/piplan/piplan.go
Normal file
309
tools/piplan/piplan.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package piplan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"laodingbot/internal/logger"
|
||||
)
|
||||
|
||||
// Feature 产品经理视角输出的业务特性。
|
||||
type Feature struct {
|
||||
FeatureID string `json:"feature_id"`
|
||||
Title string `json:"title"`
|
||||
BenefitHypothesis string `json:"benefit_hypothesis"`
|
||||
AcceptanceCriteria []string `json:"acceptance_criteria"`
|
||||
}
|
||||
|
||||
// Enabler 系统架构师视角输出的技术赋能特性(架构跑道)。
|
||||
type Enabler struct {
|
||||
EnablerID string `json:"enabler_id"`
|
||||
Title string `json:"title"`
|
||||
ArchitecturalPurpose string `json:"architectural_purpose"`
|
||||
}
|
||||
|
||||
// NFRs 非功能性需求。
|
||||
type NFRs struct {
|
||||
Performance string `json:"performance"`
|
||||
Security string `json:"security"`
|
||||
}
|
||||
|
||||
// Dependency RTE 梳理的任务依赖关系。
|
||||
type Dependency struct {
|
||||
SourceID string `json:"source_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// PIPlanInput publish_pi_plan 工具的完整输入结构。
|
||||
type PIPlanInput struct {
|
||||
PIVision string `json:"pi_vision"`
|
||||
Features []Feature `json:"features"`
|
||||
Enablers []Enabler `json:"enablers"`
|
||||
NFRs NFRs `json:"nfrs"`
|
||||
Dependencies []Dependency `json:"dependencies"`
|
||||
}
|
||||
|
||||
// Tool 实现 SAFe PI 规划发布工具。
|
||||
type Tool struct {
|
||||
maxOutputChars int
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
// New 创建一个新的 publish_pi_plan 工具实例。
|
||||
func New(maxOutputChars int, log *logger.Logger) *Tool {
|
||||
if maxOutputChars <= 0 {
|
||||
maxOutputChars = 20000
|
||||
}
|
||||
return &Tool{
|
||||
maxOutputChars: maxOutputChars,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tool) Name() string { return "publish_pi_plan" }
|
||||
|
||||
func (t *Tool) Description() string {
|
||||
return `当铁三角(PM, 架构师, RTE)完成 PI 规划推演后,调用此工具输出标准化的架构蓝图与任务清单。输入为 JSON,包含 pi_vision, features, enablers, nfrs, dependencies 字段。`
|
||||
}
|
||||
|
||||
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||
plan, err := parseInput(input)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("publish_pi_plan: invalid input: %w", err)
|
||||
}
|
||||
|
||||
if err := validate(plan); err != nil {
|
||||
return "", fmt.Errorf("publish_pi_plan: validation failed: %w", err)
|
||||
}
|
||||
|
||||
if t.log != nil {
|
||||
t.log.Infof("publish_pi_plan: features=%d enablers=%d deps=%d",
|
||||
len(plan.Features), len(plan.Enablers), len(plan.Dependencies))
|
||||
}
|
||||
|
||||
output := render(plan)
|
||||
|
||||
if len(output) > t.maxOutputChars {
|
||||
output = output[:t.maxOutputChars]
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func parseInput(input string) (*PIPlanInput, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("empty input")
|
||||
}
|
||||
|
||||
var plan PIPlanInput
|
||||
if err := json.Unmarshal([]byte(raw), &plan); err != nil {
|
||||
return nil, fmt.Errorf("JSON parse error: %w", err)
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
func validate(p *PIPlanInput) error {
|
||||
if strings.TrimSpace(p.PIVision) == "" {
|
||||
return fmt.Errorf("pi_vision is required")
|
||||
}
|
||||
if len(p.Features) == 0 {
|
||||
return fmt.Errorf("at least one feature is required")
|
||||
}
|
||||
for i, f := range p.Features {
|
||||
if strings.TrimSpace(f.FeatureID) == "" {
|
||||
return fmt.Errorf("features[%d].feature_id is required", i)
|
||||
}
|
||||
if strings.TrimSpace(f.Title) == "" {
|
||||
return fmt.Errorf("features[%d].title is required", i)
|
||||
}
|
||||
if strings.TrimSpace(f.BenefitHypothesis) == "" {
|
||||
return fmt.Errorf("features[%d].benefit_hypothesis is required", i)
|
||||
}
|
||||
if len(f.AcceptanceCriteria) == 0 {
|
||||
return fmt.Errorf("features[%d].acceptance_criteria requires at least one item", i)
|
||||
}
|
||||
}
|
||||
for i, e := range p.Enablers {
|
||||
if strings.TrimSpace(e.EnablerID) == "" {
|
||||
return fmt.Errorf("enablers[%d].enabler_id is required", i)
|
||||
}
|
||||
if strings.TrimSpace(e.Title) == "" {
|
||||
return fmt.Errorf("enablers[%d].title is required", i)
|
||||
}
|
||||
if strings.TrimSpace(e.ArchitecturalPurpose) == "" {
|
||||
return fmt.Errorf("enablers[%d].architectural_purpose is required", i)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(p.NFRs.Performance) == "" {
|
||||
return fmt.Errorf("nfrs.performance is required")
|
||||
}
|
||||
if strings.TrimSpace(p.NFRs.Security) == "" {
|
||||
return fmt.Errorf("nfrs.security is required")
|
||||
}
|
||||
for i, d := range p.Dependencies {
|
||||
if strings.TrimSpace(d.SourceID) == "" {
|
||||
return fmt.Errorf("dependencies[%d].source_id is required", i)
|
||||
}
|
||||
if strings.TrimSpace(d.TargetID) == "" {
|
||||
return fmt.Errorf("dependencies[%d].target_id is required", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// render 将 PI 规划输入渲染为标准化的 Markdown 架构蓝图与任务清单。
|
||||
func render(p *PIPlanInput) string {
|
||||
var b strings.Builder
|
||||
|
||||
// ── 标题 ──
|
||||
b.WriteString("# PI 规划架构蓝图与任务清单\n\n")
|
||||
|
||||
// ── 1. PI 愿景 ──
|
||||
b.WriteString("## 1. PI 愿景\n\n")
|
||||
b.WriteString(strings.TrimSpace(p.PIVision))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// ── 2. 业务特性清单 (Features) ──
|
||||
b.WriteString("## 2. 业务特性清单 (Features)\n\n")
|
||||
for _, f := range p.Features {
|
||||
b.WriteString(fmt.Sprintf("### %s — %s\n\n", f.FeatureID, f.Title))
|
||||
b.WriteString(fmt.Sprintf("**业务价值假设**: %s\n\n", f.BenefitHypothesis))
|
||||
b.WriteString("**验收标准 (AC)**:\n\n")
|
||||
for j, ac := range f.AcceptanceCriteria {
|
||||
b.WriteString(fmt.Sprintf("- [ ] AC-%d: %s\n", j+1, ac))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// ── 3. 技术赋能特性 (Enablers / 架构跑道) ──
|
||||
b.WriteString("## 3. 技术赋能特性 (Enablers / 架构跑道)\n\n")
|
||||
if len(p.Enablers) == 0 {
|
||||
b.WriteString("_无技术赋能特性。_\n\n")
|
||||
} else {
|
||||
b.WriteString("| Enabler ID | 名称 | 架构意图 |\n")
|
||||
b.WriteString("|------------|------|----------|\n")
|
||||
for _, e := range p.Enablers {
|
||||
b.WriteString(fmt.Sprintf("| %s | %s | %s |\n",
|
||||
e.EnablerID, e.Title, e.ArchitecturalPurpose))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// ── 4. 非功能性需求 (NFRs) ──
|
||||
b.WriteString("## 4. 非功能性需求 (NFRs)\n\n")
|
||||
b.WriteString(fmt.Sprintf("- **性能**: %s\n", p.NFRs.Performance))
|
||||
b.WriteString(fmt.Sprintf("- **安全与合规**: %s\n", p.NFRs.Security))
|
||||
b.WriteString("\n")
|
||||
|
||||
// ── 5. 依赖关系图 ──
|
||||
b.WriteString("## 5. 依赖关系\n\n")
|
||||
if len(p.Dependencies) == 0 {
|
||||
b.WriteString("_无跨任务依赖。_\n\n")
|
||||
} else {
|
||||
b.WriteString("| 前置任务 (Source) | 后续任务 (Target) | 依赖原因 |\n")
|
||||
b.WriteString("|-------------------|-------------------|----------|\n")
|
||||
for _, d := range p.Dependencies {
|
||||
reason := d.Reason
|
||||
if reason == "" {
|
||||
reason = "—"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("| %s | %s | %s |\n",
|
||||
d.SourceID, d.TargetID, reason))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// ── 6. 建议执行顺序 ──
|
||||
b.WriteString("## 6. 建议执行顺序\n\n")
|
||||
order := computeExecutionOrder(p)
|
||||
for i, id := range order {
|
||||
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, id))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// ── 7. 质量门禁检查清单 ──
|
||||
b.WriteString("## 7. 质量门禁检查清单\n\n")
|
||||
b.WriteString("### 业务验收测试用例\n\n")
|
||||
for _, f := range p.Features {
|
||||
for j, ac := range f.AcceptanceCriteria {
|
||||
b.WriteString(fmt.Sprintf("- [ ] [%s] AC-%d: %s\n", f.FeatureID, j+1, ac))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n### 非功能性验证\n\n")
|
||||
b.WriteString(fmt.Sprintf("- [ ] 性能压测: %s\n", p.NFRs.Performance))
|
||||
b.WriteString(fmt.Sprintf("- [ ] 安全扫描: %s\n", p.NFRs.Security))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// computeExecutionOrder 根据依赖关系计算拓扑排序的执行顺序。
|
||||
// 先排 Enabler,再排 Feature;无依赖的排在前面。
|
||||
func computeExecutionOrder(p *PIPlanInput) []string {
|
||||
// 收集所有 ID
|
||||
allIDs := make([]string, 0, len(p.Enablers)+len(p.Features))
|
||||
idSet := make(map[string]bool)
|
||||
for _, e := range p.Enablers {
|
||||
allIDs = append(allIDs, e.EnablerID)
|
||||
idSet[e.EnablerID] = true
|
||||
}
|
||||
for _, f := range p.Features {
|
||||
allIDs = append(allIDs, f.FeatureID)
|
||||
idSet[f.FeatureID] = true
|
||||
}
|
||||
|
||||
// 构建入度表和邻接表
|
||||
inDegree := make(map[string]int)
|
||||
adj := make(map[string][]string)
|
||||
for _, id := range allIDs {
|
||||
inDegree[id] = 0
|
||||
}
|
||||
for _, d := range p.Dependencies {
|
||||
if !idSet[d.SourceID] || !idSet[d.TargetID] {
|
||||
continue
|
||||
}
|
||||
adj[d.SourceID] = append(adj[d.SourceID], d.TargetID)
|
||||
inDegree[d.TargetID]++
|
||||
}
|
||||
|
||||
// Kahn 拓扑排序
|
||||
queue := make([]string, 0)
|
||||
// 先加入度为 0 的 Enabler,再加入度为 0 的 Feature,保持稳定顺序
|
||||
for _, e := range p.Enablers {
|
||||
if inDegree[e.EnablerID] == 0 {
|
||||
queue = append(queue, e.EnablerID)
|
||||
}
|
||||
}
|
||||
for _, f := range p.Features {
|
||||
if inDegree[f.FeatureID] == 0 {
|
||||
queue = append(queue, f.FeatureID)
|
||||
}
|
||||
}
|
||||
|
||||
var result []string
|
||||
for len(queue) > 0 {
|
||||
curr := queue[0]
|
||||
queue = queue[1:]
|
||||
result = append(result, curr)
|
||||
for _, next := range adj[curr] {
|
||||
inDegree[next]--
|
||||
if inDegree[next] == 0 {
|
||||
queue = append(queue, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果存在环,将未排序的节点追加到末尾并标记
|
||||
if len(result) < len(allIDs) {
|
||||
for _, id := range allIDs {
|
||||
if inDegree[id] > 0 {
|
||||
result = append(result, id+" ⚠️(循环依赖)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
334
tools/piplan/piplan_test.go
Normal file
334
tools/piplan/piplan_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package piplan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validInput() PIPlanInput {
|
||||
return PIPlanInput{
|
||||
PIVision: "实现车云一体化 OTA 系统,支撑百万级终端设备的安全固件升级",
|
||||
Features: []Feature{
|
||||
{
|
||||
FeatureID: "FEAT_OTA_001",
|
||||
Title: "云端固件版本依赖检查",
|
||||
BenefitHypothesis: "上线后减少 30% 的固件回退率",
|
||||
AcceptanceCriteria: []string{
|
||||
"上传固件时自动解析并记录版本依赖关系",
|
||||
"下发升级任务时自动校验设备当前版本是否满足依赖",
|
||||
"不满足依赖时返回明确的错误提示及所需前置版本",
|
||||
},
|
||||
},
|
||||
{
|
||||
FeatureID: "FEAT_OTA_002",
|
||||
Title: "端侧断点续传",
|
||||
BenefitHypothesis: "弱网环境下固件下载成功率提升至 99.5%",
|
||||
AcceptanceCriteria: []string{
|
||||
"支持分片下载与本地缓存校验",
|
||||
"网络恢复后自动续传,无需用户干预",
|
||||
},
|
||||
},
|
||||
},
|
||||
Enablers: []Enabler{
|
||||
{
|
||||
EnablerID: "ENAB_KAFKA_001",
|
||||
Title: "搭建跨可用区的高可用 Kafka 集群",
|
||||
ArchitecturalPurpose: "为高并发 OTA 状态机提供可靠消息管道",
|
||||
},
|
||||
{
|
||||
EnablerID: "ENAB_S3_001",
|
||||
Title: "对象存储多区域同步",
|
||||
ArchitecturalPurpose: "保证固件文件在多区域的低延迟分发",
|
||||
},
|
||||
},
|
||||
NFRs: NFRs{
|
||||
Performance: "API 响应时间 P99 < 200ms,吞吐量 > 10000 QPS",
|
||||
Security: "车云通信必须使用 TLS 1.3,敏感数据必须脱敏",
|
||||
},
|
||||
Dependencies: []Dependency{
|
||||
{
|
||||
SourceID: "ENAB_KAFKA_001",
|
||||
TargetID: "FEAT_OTA_001",
|
||||
Reason: "版本检查服务依赖 Kafka 进行异步事件通知",
|
||||
},
|
||||
{
|
||||
SourceID: "ENAB_S3_001",
|
||||
TargetID: "FEAT_OTA_002",
|
||||
Reason: "断点续传需要对象存储支持 Range 请求",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputValid(t *testing.T) {
|
||||
in := validInput()
|
||||
data, _ := json.Marshal(in)
|
||||
plan, err := parseInput(string(data))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if plan.PIVision != in.PIVision {
|
||||
t.Errorf("pi_vision mismatch: got %q", plan.PIVision)
|
||||
}
|
||||
if len(plan.Features) != 2 {
|
||||
t.Errorf("expected 2 features, got %d", len(plan.Features))
|
||||
}
|
||||
if len(plan.Enablers) != 2 {
|
||||
t.Errorf("expected 2 enablers, got %d", len(plan.Enablers))
|
||||
}
|
||||
if len(plan.Dependencies) != 2 {
|
||||
t.Errorf("expected 2 dependencies, got %d", len(plan.Dependencies))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputEmpty(t *testing.T) {
|
||||
_, err := parseInput("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputInvalidJSON(t *testing.T) {
|
||||
_, err := parseInput("{not json}")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingVision(t *testing.T) {
|
||||
in := validInput()
|
||||
in.PIVision = ""
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "pi_vision") {
|
||||
t.Fatalf("expected pi_vision error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNoFeatures(t *testing.T) {
|
||||
in := validInput()
|
||||
in.Features = nil
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "feature") {
|
||||
t.Fatalf("expected feature error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFeatureMissingID(t *testing.T) {
|
||||
in := validInput()
|
||||
in.Features[0].FeatureID = ""
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "feature_id") {
|
||||
t.Fatalf("expected feature_id error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFeatureMissingAC(t *testing.T) {
|
||||
in := validInput()
|
||||
in.Features[0].AcceptanceCriteria = nil
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "acceptance_criteria") {
|
||||
t.Fatalf("expected acceptance_criteria error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEnablerMissingPurpose(t *testing.T) {
|
||||
in := validInput()
|
||||
in.Enablers[0].ArchitecturalPurpose = ""
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "architectural_purpose") {
|
||||
t.Fatalf("expected architectural_purpose error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNFRsMissingPerformance(t *testing.T) {
|
||||
in := validInput()
|
||||
in.NFRs.Performance = ""
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "performance") {
|
||||
t.Fatalf("expected performance error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNFRsMissingSecurity(t *testing.T) {
|
||||
in := validInput()
|
||||
in.NFRs.Security = ""
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "security") {
|
||||
t.Fatalf("expected security error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDepMissingSourceID(t *testing.T) {
|
||||
in := validInput()
|
||||
in.Dependencies[0].SourceID = ""
|
||||
err := validate(&in)
|
||||
if err == nil || !strings.Contains(err.Error(), "source_id") {
|
||||
t.Fatalf("expected source_id error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContainsSections(t *testing.T) {
|
||||
in := validInput()
|
||||
out := render(&in)
|
||||
|
||||
sections := []string{
|
||||
"# PI 规划架构蓝图与任务清单",
|
||||
"## 1. PI 愿景",
|
||||
"## 2. 业务特性清单 (Features)",
|
||||
"## 3. 技术赋能特性 (Enablers / 架构跑道)",
|
||||
"## 4. 非功能性需求 (NFRs)",
|
||||
"## 5. 依赖关系",
|
||||
"## 6. 建议执行顺序",
|
||||
"## 7. 质量门禁检查清单",
|
||||
}
|
||||
for _, s := range sections {
|
||||
if !strings.Contains(out, s) {
|
||||
t.Errorf("output missing section: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContainsFeatureDetails(t *testing.T) {
|
||||
in := validInput()
|
||||
out := render(&in)
|
||||
|
||||
if !strings.Contains(out, "FEAT_OTA_001") {
|
||||
t.Error("output missing FEAT_OTA_001")
|
||||
}
|
||||
if !strings.Contains(out, "云端固件版本依赖检查") {
|
||||
t.Error("output missing feature title")
|
||||
}
|
||||
if !strings.Contains(out, "AC-1") {
|
||||
t.Error("output missing acceptance criteria numbering")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContainsEnablerTable(t *testing.T) {
|
||||
in := validInput()
|
||||
out := render(&in)
|
||||
|
||||
if !strings.Contains(out, "ENAB_KAFKA_001") {
|
||||
t.Error("output missing ENAB_KAFKA_001")
|
||||
}
|
||||
if !strings.Contains(out, "ENAB_S3_001") {
|
||||
t.Error("output missing ENAB_S3_001")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContainsNFRs(t *testing.T) {
|
||||
in := validInput()
|
||||
out := render(&in)
|
||||
|
||||
if !strings.Contains(out, "P99 < 200ms") {
|
||||
t.Error("output missing performance NFR")
|
||||
}
|
||||
if !strings.Contains(out, "TLS 1.3") {
|
||||
t.Error("output missing security NFR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContainsDependencies(t *testing.T) {
|
||||
in := validInput()
|
||||
out := render(&in)
|
||||
|
||||
if !strings.Contains(out, "ENAB_KAFKA_001") || !strings.Contains(out, "FEAT_OTA_001") {
|
||||
t.Error("output missing dependency pair")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeExecutionOrder(t *testing.T) {
|
||||
in := validInput()
|
||||
order := computeExecutionOrder(&in)
|
||||
|
||||
// Enablers should come before their dependent Features
|
||||
enablerIdx := map[string]int{}
|
||||
featureIdx := map[string]int{}
|
||||
for i, id := range order {
|
||||
if strings.HasPrefix(id, "ENAB_") {
|
||||
enablerIdx[id] = i
|
||||
} else if strings.HasPrefix(id, "FEAT_") {
|
||||
featureIdx[id] = i
|
||||
}
|
||||
}
|
||||
|
||||
if enablerIdx["ENAB_KAFKA_001"] >= featureIdx["FEAT_OTA_001"] {
|
||||
t.Error("ENAB_KAFKA_001 should come before FEAT_OTA_001")
|
||||
}
|
||||
if enablerIdx["ENAB_S3_001"] >= featureIdx["FEAT_OTA_002"] {
|
||||
t.Error("ENAB_S3_001 should come before FEAT_OTA_002")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeExecutionOrderNoDeps(t *testing.T) {
|
||||
in := validInput()
|
||||
in.Dependencies = nil
|
||||
order := computeExecutionOrder(&in)
|
||||
|
||||
if len(order) != 4 {
|
||||
t.Errorf("expected 4 items, got %d", len(order))
|
||||
}
|
||||
// Enablers first, then Features (stable order)
|
||||
if order[0] != "ENAB_KAFKA_001" || order[1] != "ENAB_S3_001" {
|
||||
t.Errorf("enablers should come first, got: %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallEndToEnd(t *testing.T) {
|
||||
tool := New(0, nil)
|
||||
in := validInput()
|
||||
data, _ := json.Marshal(in)
|
||||
|
||||
result, err := tool.Call(context.Background(), string(data))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(result, "PI 规划架构蓝图") {
|
||||
t.Error("output missing title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallInvalidInput(t *testing.T) {
|
||||
tool := New(0, nil)
|
||||
_, err := tool.Call(context.Background(), "not json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallMissingRequiredField(t *testing.T) {
|
||||
tool := New(0, nil)
|
||||
in := validInput()
|
||||
in.PIVision = ""
|
||||
data, _ := json.Marshal(in)
|
||||
|
||||
_, err := tool.Call(context.Background(), string(data))
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameAndDescription(t *testing.T) {
|
||||
tool := New(0, nil)
|
||||
if tool.Name() != "publish_pi_plan" {
|
||||
t.Errorf("unexpected name: %s", tool.Name())
|
||||
}
|
||||
if tool.Description() == "" {
|
||||
t.Error("description should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxOutputTruncation(t *testing.T) {
|
||||
tool := New(100, nil)
|
||||
in := validInput()
|
||||
data, _ := json.Marshal(in)
|
||||
|
||||
result, err := tool.Call(context.Background(), string(data))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) > 100 {
|
||||
t.Errorf("output should be truncated to 100 chars, got %d", len(result))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user