Files
LaodingBot/tools/giteaticket/giteaticket.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

329 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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