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