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 }