Files
LaodingBot/tools/giteaticket/giteaticket_test.go
Ding, Shuo 8dc5354fa4 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
2026-03-11 17:58:19 +08:00

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")
}
}