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.
This commit is contained in:
@@ -65,6 +65,16 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subcommand `kanban resync-jira-fields` patches existing linked issues
|
||||||
|
// so their issuetype/assignee/labels reflect the current module config.
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "resync-jira-fields" {
|
||||||
|
if err := runResyncJiraFields(os.Args[2:]); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "kanban resync-jira-fields: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
|
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
|
||||||
port := flags.Int("port", 8095, "HTTP port")
|
port := flags.Int("port", 8095, "HTTP port")
|
||||||
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
||||||
|
|||||||
+26
-8
@@ -652,14 +652,15 @@ func cutoffOK(db *DB, m Module, ev Event) bool {
|
|||||||
type jiraHandler struct{}
|
type jiraHandler struct{}
|
||||||
|
|
||||||
type jiraConfig struct {
|
type jiraConfig struct {
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
APIToken string `json:"api_token"`
|
APIToken string `json:"api_token"`
|
||||||
ProjectKey string `json:"project_key"`
|
ProjectKey string `json:"project_key"`
|
||||||
BoardID int `json:"board_id"`
|
BoardID int `json:"board_id"`
|
||||||
IssueType string `json:"issue_type"` // Jira issuetype name applied on create
|
IssueType string `json:"issue_type"` // Jira issuetype name applied on create
|
||||||
StatusMap map[string]string `json:"status_map"` // kanban_column_name -> Jira status name
|
StatusMap map[string]string `json:"status_map"` // kanban_column_name -> Jira status name
|
||||||
LabelsMap map[string][]string `json:"labels_map,omitempty"` // kanban_column_name -> Jira labels (replaces every sync)
|
LabelsMap map[string][]string `json:"labels_map,omitempty"` // kanban_column_name -> Jira labels (replaces every sync)
|
||||||
|
AssigneeMap map[string]string `json:"assignee_map,omitempty"` // kanban_user_id -> Jira accountId
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseJiraConfig(m Module) (jiraConfig, error) {
|
func parseJiraConfig(m Module) (jiraConfig, error) {
|
||||||
@@ -801,6 +802,9 @@ func (h *jiraHandler) create(ctx context.Context, db *DB, c jiraConfig, ev Event
|
|||||||
if labels := c.LabelsMap[card.ColumnName]; len(labels) > 0 {
|
if labels := c.LabelsMap[card.ColumnName]; len(labels) > 0 {
|
||||||
fields["labels"] = labels
|
fields["labels"] = labels
|
||||||
}
|
}
|
||||||
|
if acct := resolveJiraAssignee(c, card); acct != "" {
|
||||||
|
fields["assignee"] = map[string]string{"accountId": acct}
|
||||||
|
}
|
||||||
body := map[string]interface{}{"fields": fields}
|
body := map[string]interface{}{"fields": fields}
|
||||||
status, resp, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue", body)
|
status, resp, err := h.jiraRequest(ctx, c, http.MethodPost, "/rest/api/3/issue", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -851,11 +855,25 @@ func (h *jiraHandler) update(ctx context.Context, db *DB, c jiraConfig, ev Event
|
|||||||
labels = []string{}
|
labels = []string{}
|
||||||
}
|
}
|
||||||
fields["labels"] = labels
|
fields["labels"] = labels
|
||||||
|
if acct := resolveJiraAssignee(c, card); acct != "" {
|
||||||
|
fields["assignee"] = map[string]string{"accountId": acct}
|
||||||
|
}
|
||||||
body := map[string]interface{}{"fields": fields}
|
body := map[string]interface{}{"fields": fields}
|
||||||
status, _, err := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+card.JiraKey, body)
|
status, _, err := h.jiraRequest(ctx, c, http.MethodPut, "/rest/api/3/issue/"+card.JiraKey, body)
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveJiraAssignee maps the kanban card's assignee_id to a Jira accountId
|
||||||
|
// via the module's assignee_map. Returns "" when the card has no assignee or
|
||||||
|
// the assignee is not mapped, signalling to the caller to omit the field
|
||||||
|
// (avoids accidentally CLEARING an existing Jira assignee on every sync).
|
||||||
|
func resolveJiraAssignee(c jiraConfig, card *cardForJira) string {
|
||||||
|
if card == nil || card.AssigneeID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.AssigneeMap[card.AssigneeID]
|
||||||
|
}
|
||||||
|
|
||||||
// transition uses the configured status_map to translate the kanban column
|
// transition uses the configured status_map to translate the kanban column
|
||||||
// to a Jira transition name. Kanban remains the source of truth even if
|
// to a Jira transition name. Kanban remains the source of truth even if
|
||||||
// Jira's current state differs.
|
// Jira's current state differs.
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+18
-8
@@ -70,16 +70,26 @@ func runSeedJiraData(args []string) error {
|
|||||||
labelsMap := map[string][]string{
|
labelsMap := map[string][]string{
|
||||||
"Bloqueadas": {"blocked"},
|
"Bloqueadas": {"blocked"},
|
||||||
}
|
}
|
||||||
|
// kanban user_id -> Jira accountId. Resolved via Jira /user/search; the
|
||||||
|
// three current data-team users keep stable IDs across sessions. New
|
||||||
|
// users added to the kanban must be added here (or the seed re-run with
|
||||||
|
// --pass-prefix overrides) so the dispatcher can route the assignee.
|
||||||
|
assigneeMap := map[string]string{
|
||||||
|
"6a75edc6e99d8405": "712020:2cf3b82f-47d6-4597-b0e9-ffaaf3a07cc3", // Enmaa -> Enmanuel Gutierrez Perez
|
||||||
|
"039c97acf1869393": "712020:3f3ca9e1-c86e-445e-979a-bc7b82a4f45d", // alfon -> Alfonso Massaguer Gómez
|
||||||
|
"9e91db261084d529": "712020:feb5f7c5-7643-4381-977c-d83c95ba4955", // Nat -> Natalia Tajuelo Gomez
|
||||||
|
}
|
||||||
|
|
||||||
cfg := JSONValue{
|
cfg := JSONValue{
|
||||||
"base_url": baseURL,
|
"base_url": baseURL,
|
||||||
"email": email,
|
"email": email,
|
||||||
"api_token": token,
|
"api_token": token,
|
||||||
"project_key": *project,
|
"project_key": *project,
|
||||||
"board_id": *board,
|
"board_id": *board,
|
||||||
"issue_type": "Tarea Técnica",
|
"issue_type": "Epic",
|
||||||
"status_map": statusMap,
|
"status_map": statusMap,
|
||||||
"labels_map": labelsMap,
|
"labels_map": labelsMap,
|
||||||
|
"assignee_map": assigneeMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert by name. Module name is the human-friendly identifier; we treat
|
// Upsert by name. Module name is the human-friendly identifier; we treat
|
||||||
|
|||||||
Reference in New Issue
Block a user