Files
kanban/backend/resync_jira.go
T
egutierrez c5113f75a5 feat(jira): issue_type=Epic + AssigneeMap + CLI resync-jira-fields
Cambios:
- jiraConfig: nuevo campo AssigneeMap (kanban_user_id -> jira_accountId).
- jiraHandler.create() y update(): aplican fields.assignee={accountId} cuando
  card.AssigneeID esta en el map. NO se borra el assignee de Jira cuando no
  hay mapeo (evita pisar asignaciones manuales).
- resolveJiraAssignee: helper compartido.
- seed-jira-data: cambio issue_type default Tarea Tecnica -> Epic (board 33
  filtra issuetype=Epic). assignee_map inyectada con 3 mapeos confirmados:
    egutierrez (Enmaa)  -> 712020:2cf3b82f-... (Enmanuel Gutierrez Perez)
    amassaguer (alfon)  -> 712020:3f3ca9e1-... (Alfonso Massaguer Gomez)
    ntajuelo   (Nat)    -> 712020:feb5f7c5-... (Natalia Tajuelo Gomez)
- Nueva CLI 'kanban resync-jira-fields' con flags
    --set-issuetype/--set-assignee/--set-labels/--dry-run/--limit/--batch-size/--pause-sec
  Idempotente. PUT /rest/api/3/issue/{key} con los fields del config actual.
  Usado para patchear las 127 issues ya creadas con Tarea Tecnica -> Epic +
  assignee (donde mapea).
- Ejecutado: 127/127 OK, 0 fail. Board 33 ahora muestra 219 issues totales
  (92 Epics previas + 127 nuevas). Sample verificado contra Jira REST API.
2026-05-29 14:52:48 +02:00

193 lines
6.1 KiB
Go

package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// runResyncJiraFields patches every Jira issue currently linked to a kanban
// card so its issuetype / assignee / labels reflect the *latest* module
// configuration. Use cases:
//
// - We changed issue_type in the module (e.g. "Tarea Técnica" → "Epic") and
// need the backfilled issues to match.
// - We added/changed the assignee_map and want existing issues to pick up
// the mapping retroactively.
// - We renamed kanban columns and need labels re-applied.
//
// The CLI is idempotent: running it twice on the same set is a no-op for
// fields that already match. Batching mirrors `backfill-jira` so we stay
// under Jira's REST quota.
func runResyncJiraFields(args []string) error {
fs := flag.NewFlagSet("kanban resync-jira-fields", flag.ContinueOnError)
dbPath := fs.String("db", "operations.db", "SQLite database path")
batchSize := fs.Int("batch-size", 10, "Issues per batch before pausing")
pauseSec := fs.Int("pause-sec", 5, "Seconds to sleep between batches")
limit := fs.Int("limit", 0, "Maximum issues to patch (0 = no limit)")
doIssueType := fs.Bool("set-issuetype", true, "Set issuetype to module.issue_type")
doAssignee := fs.Bool("set-assignee", true, "Set assignee from module.assignee_map (or clear when no mapping)")
doLabels := fs.Bool("set-labels", false, "Re-apply labels from module.labels_map (off by default; labels were already correct after backfill)")
dryRun := fs.Bool("dry-run", false, "Print the planned PATCH for each issue and exit")
if err := fs.Parse(args); err != nil {
return err
}
db, err := openDB(*dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
_, cfg, err := activeJiraModule(db)
if err != nil {
return fmt.Errorf("module config: %w", err)
}
cards, err := listLinkedJiraCards(db, *limit)
if err != nil {
return err
}
if len(cards) == 0 {
fmt.Println("no linked cards to resync")
return nil
}
fmt.Printf("resync plan: %d issues; batch=%d pause=%ds dry_run=%v\n",
len(cards), *batchSize, *pauseSec, *dryRun)
fmt.Printf("ops: issuetype=%v(%q) assignee=%v(%d mappings) labels=%v\n",
*doIssueType, cfg.IssueType, *doAssignee, len(cfg.AssigneeMap), *doLabels)
fmt.Println()
var ok, failed, noop int
for i, c := range cards {
if i > 0 && i%*batchSize == 0 {
fmt.Printf("--- batch boundary (%d/%d) — sleeping %ds ---\n", i, len(cards), *pauseSec)
time.Sleep(time.Duration(*pauseSec) * time.Second)
}
fields := map[string]interface{}{}
if *doIssueType && cfg.IssueType != "" {
fields["issuetype"] = map[string]string{"name": cfg.IssueType}
}
if *doAssignee {
acct := cfg.AssigneeMap[c.AssigneeID]
if acct != "" {
fields["assignee"] = map[string]string{"accountId": acct}
}
// We intentionally do NOT clear the assignee when the card has no
// mapping — that would overwrite a manual Jira assignment with
// nothing. To explicitly clear, the operator can remove the card's
// kanban assignee and trigger a card.updated event.
}
if *doLabels {
labels := cfg.LabelsMap[c.ColumnName]
if labels == nil {
labels = []string{}
}
fields["labels"] = labels
}
if len(fields) == 0 {
noop++
fmt.Printf("[%4d/%4d] NOOP %s (no fields to patch)\n", i+1, len(cards), c.JiraKey)
continue
}
if *dryRun {
b, _ := json.Marshal(fields)
fmt.Printf("[%4d/%4d] PLAN %s fields=%s\n", i+1, len(cards), c.JiraKey, b)
continue
}
status, err := jiraPUTFields(context.Background(), cfg, c.JiraKey, fields)
if err != nil {
failed++
fmt.Printf("[%4d/%4d] FAIL %s http=%d err=%s\n",
i+1, len(cards), c.JiraKey, status, truncateInline(err.Error(), 100))
continue
}
ok++
fmt.Printf("[%4d/%4d] OK %s\n", i+1, len(cards), c.JiraKey)
}
fmt.Println()
fmt.Printf("done: %d ok · %d noop · %d failed · %d total\n", ok, noop, failed, len(cards))
if failed > 0 {
return fmt.Errorf("%d issues failed to patch", failed)
}
return nil
}
// linkedJiraCard is the projection used by the resync CLI. We also pull
// assignee_id so the assignee_map lookup works without re-fetching cards.
type linkedJiraCard struct {
ID string
JiraKey string
ColumnName string
AssigneeID string
}
func listLinkedJiraCards(db *DB, limit int) ([]linkedJiraCard, error) {
q := `
SELECT c.id, c.jira_key, col.name, COALESCE(c.assignee_id, '')
FROM cards c
JOIN columns col ON col.id = c.column_id
WHERE c.jira_key != ''
AND c.deleted_at IS NULL
ORDER BY c.jira_key ASC
`
args := []interface{}{}
if limit > 0 {
q += " LIMIT ? "
args = append(args, limit)
}
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("list linked cards: %w", err)
}
defer rows.Close()
out := []linkedJiraCard{}
for rows.Next() {
var c linkedJiraCard
if err := rows.Scan(&c.ID, &c.JiraKey, &c.ColumnName, &c.AssigneeID); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// jiraPUTFields is a thin wrapper around PUT /rest/api/3/issue/{key} that
// returns the HTTP status code + error. We do not need the response body —
// Jira returns 204 No Content on success.
func jiraPUTFields(ctx context.Context, c jiraConfig, key string, fields map[string]interface{}) (int, error) {
body := map[string]interface{}{"fields": fields}
raw, err := json.Marshal(body)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
c.BaseURL+"/rest/api/3/issue/"+key, bytes.NewReader(raw))
if err != nil {
return 0, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
basic := base64.StdEncoding.EncodeToString([]byte(c.Email + ":" + c.APIToken))
req.Header.Set("Authorization", "Basic "+basic)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return resp.StatusCode, fmt.Errorf("jira PUT %s: %d %s",
key, resp.StatusCode, truncateInline(strings.TrimSpace(string(respBody)), 240))
}
return resp.StatusCode, nil
}