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:
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user