Files
LaodingBot/tools/giteaticket/giteaticket.go

337 lines
10 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
}
if log != nil {
log.Infof("giteaticket tool initialized base_url=%q owner=%q repo=%q token_set=%v",
baseURL, strings.TrimSpace(cfg.Owner), strings.TrimSpace(cfg.Repo), strings.TrimSpace(cfg.Token) != "")
}
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 == "" {
if t.log != nil {
t.log.Errorf("giteaticket config missing: base_url=%q token_set=%v owner=%q repo=%q",
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"
}
}