- 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
404 lines
10 KiB
Go
404 lines
10 KiB
Go
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")
|
|
}
|
|
}
|