package main import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "fn-registry/functions/infra" ) // jiraImportRequest is the body shape for POST /api/jira/import. type jiraImportRequest struct { IssueKeys []string `json:"issue_keys"` FallbackColumnID string `json:"fallback_column_id"` // optional: where to land issues whose status has no kanban mapping } // jiraIssueOut is what we return in GET /api/jira/issues for the frontend // import picker. We deliberately keep this small — clicking a row redirects // to Jira for full detail. type jiraIssueOut struct { Key string `json:"key"` Summary string `json:"summary"` StatusName string `json:"status_name"` IssueType string `json:"issue_type"` Assignee string `json:"assignee"` Updated string `json:"updated"` URL string `json:"url"` AlreadyImported bool `json:"already_imported"` MappedColumnID string `json:"mapped_column_id,omitempty"` IssueTypeIcon string `json:"issue_type_icon,omitempty"` } // linkedCardForCheck is the projection used by the check-columns endpoint. // We only need fields visible in the report table. type linkedCardForCheck struct { ID string Title string JiraKey string ColumnID string ColumnName string } func listLinkedCardsForCheck(db *DB) ([]linkedCardForCheck, error) { rows, err := db.conn.Query(` SELECT c.id, c.title, c.jira_key, c.column_id, col.name 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 `) if err != nil { return nil, err } defer rows.Close() out := []linkedCardForCheck{} for rows.Next() { var c linkedCardForCheck if err := rows.Scan(&c.ID, &c.Title, &c.JiraKey, &c.ColumnID, &c.ColumnName); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // activeJiraModule returns the first enabled Jira module + its decoded config, // or an error if no module is configured. The handlers below need both the // credentials and the status_map to operate. func activeJiraModule(db *DB) (Module, jiraConfig, error) { mods, err := db.listModulesEnabled() if err != nil { return Module{}, jiraConfig{}, err } for _, m := range mods { if m.Kind != "jira" { continue } cfg, perr := parseJiraConfig(m) if perr != nil { return Module{}, jiraConfig{}, perr } return m, cfg, nil } return Module{}, jiraConfig{}, fmt.Errorf("no enabled jira module configured") } // jiraGET performs an authenticated GET against the configured Jira API and // decodes the JSON response into out. func jiraGET(ctx context.Context, cfg jiraConfig, path string, out interface{}) (int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.BaseURL+path, nil) if err != nil { return 0, err } req.Header.Set("Accept", "application/json") if cfg.Email != "" && cfg.APIToken != "" { basic := base64.StdEncoding.EncodeToString([]byte(cfg.Email + ":" + cfg.APIToken)) req.Header.Set("Authorization", "Basic "+basic) } resp, err := http.DefaultClient.Do(req) if err != nil { return 0, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if resp.StatusCode >= 400 { return resp.StatusCode, fmt.Errorf("jira GET %s: %d %s", path, resp.StatusCode, truncate(body, 240)) } if out != nil { if err := json.Unmarshal(body, out); err != nil { return resp.StatusCode, fmt.Errorf("decode %s: %w", path, err) } } return resp.StatusCode, nil } // extractADFText walks an Atlassian Document Format JSON node and collects the // text content. Returns "" when the input is not a valid ADF doc. Used to // pre-populate the kanban card description on import — operators can edit it // later, the Jira link is the source of truth for rich content. func extractADFText(raw json.RawMessage) string { if len(raw) == 0 || string(raw) == "null" { return "" } var node struct { Type string `json:"type"` Text string `json:"text"` Content []json.RawMessage `json:"content"` } if err := json.Unmarshal(raw, &node); err != nil { return "" } var buf bytes.Buffer collectADFText(&buf, node.Type, node.Text, node.Content) return strings.TrimSpace(buf.String()) } func collectADFText(buf *bytes.Buffer, nodeType, text string, content []json.RawMessage) { if text != "" { buf.WriteString(text) } for _, c := range content { var inner struct { Type string `json:"type"` Text string `json:"text"` Content []json.RawMessage `json:"content"` } if err := json.Unmarshal(c, &inner); err != nil { continue } collectADFText(buf, inner.Type, inner.Text, inner.Content) } // Paragraph / list-item / heading boundaries get a newline so the result // is roughly readable in the kanban card body. switch nodeType { case "paragraph", "heading", "listItem", "bulletList", "orderedList": buf.WriteString("\n") } } // reverseStatusMap inverts the kanban-col-name -> jira-status-name mapping so // importers can land an issue in the column whose status matches. Lower-cased // keys for case-insensitive lookup. func reverseStatusMap(cfg jiraConfig) map[string]string { out := make(map[string]string, len(cfg.StatusMap)) for col, status := range cfg.StatusMap { out[strings.ToLower(status)] = col } return out } // handleListJiraIssues fetches up to `limit` issues from the configured Jira // board and annotates each with whether it is already imported into kanban // and (if mappable) the kanban column it would land in. func handleListJiraIssues(db *DB) http.HandlerFunc { return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) { _, cfg, err := activeJiraModule(db) if err != nil { badRequest(w, err.Error()) return } if cfg.BoardID <= 0 { badRequest(w, "module is missing board_id") return } limit := 100 if v := r.URL.Query().Get("limit"); v != "" { if n, perr := strconv.Atoi(v); perr == nil && n > 0 && n <= 200 { limit = n } } showImported := r.URL.Query().Get("include_imported") == "true" q := url.Values{} q.Set("maxResults", strconv.Itoa(limit)) q.Set("fields", "summary,status,assignee,updated,issuetype,description") path := fmt.Sprintf("/rest/agile/1.0/board/%d/issue?%s", cfg.BoardID, q.Encode()) var page struct { Issues []struct { Key string `json:"key"` Fields struct { Summary string `json:"summary"` Status struct { Name string `json:"name"` } `json:"status"` Assignee *struct { DisplayName string `json:"displayName"` } `json:"assignee"` Updated string `json:"updated"` IssueType struct { Name string `json:"name"` IconURL string `json:"iconUrl"` } `json:"issuetype"` } `json:"fields"` } `json:"issues"` } ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout) defer cancel() if _, err := jiraGET(ctx, cfg, path, &page); err != nil { serverError(w, err) return } // Build lookup: jira_key -> bool (already imported) importedKeys, err := db.listImportedJiraKeys() if err != nil { serverError(w, err) return } colByName, err := db.listColumnsByName() if err != nil { serverError(w, err) return } statusToCol := reverseStatusMap(cfg) out := make([]jiraIssueOut, 0, len(page.Issues)) for _, iss := range page.Issues { already := importedKeys[iss.Key] if already && !showImported { continue } row := jiraIssueOut{ Key: iss.Key, Summary: iss.Fields.Summary, StatusName: iss.Fields.Status.Name, IssueType: iss.Fields.IssueType.Name, Updated: iss.Fields.Updated, URL: cfg.BaseURL + "/browse/" + iss.Key, AlreadyImported: already, IssueTypeIcon: iss.Fields.IssueType.IconURL, } if iss.Fields.Assignee != nil { row.Assignee = iss.Fields.Assignee.DisplayName } if colName, ok := statusToCol[strings.ToLower(iss.Fields.Status.Name)]; ok { if col, ok := colByName[colName]; ok { row.MappedColumnID = col.ID } } out = append(out, row) } infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{ "issues": out, "board_id": cfg.BoardID, "project_key": cfg.ProjectKey, "status_to_column": statusToCol, "include_imported": showImported, }) }) } // jiraCheckRow is one row of the check-columns report. type jiraCheckRow struct { CardID string `json:"card_id"` JiraKey string `json:"jira_key"` Title string `json:"title"` KanbanColumnID string `json:"kanban_column_id"` KanbanColumnName string `json:"kanban_column_name"` JiraStatusName string `json:"jira_status_name"` ExpectedKanbanCol string `json:"expected_kanban_col"` // kanban col that matches the current Jira status (reverse status_map) ExpectedJiraStat string `json:"expected_jira_status"` // jira status that matches the current kanban col (status_map) Mismatch bool `json:"mismatch"` IssueURL string `json:"issue_url"` } // handleCheckJiraColumns walks every linked card, fetches its current Jira // status, and reports whether the kanban column ↔ Jira status mapping is in // sync. Used by the "Comprobar columnas" tab in the Jira modal. // // Performance note: one Jira REST call per linked card. With 127 cards that // is ~127 round-trips — slow (≈30s end-to-end) but tolerable as an admin op. // A future optimisation could batch via /search/jql with key IN (...) and a // fields=status projection. func handleCheckJiraColumns(db *DB) http.HandlerFunc { return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) { _, cfg, err := activeJiraModule(db) if err != nil { badRequest(w, err.Error()) return } cards, err := listLinkedCardsForCheck(db) if err != nil { serverError(w, err) return } statusToCol := reverseStatusMap(cfg) ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*5) defer cancel() rows := make([]jiraCheckRow, 0, len(cards)) var mismatches int for _, c := range cards { var iss struct { Fields struct { Status struct { Name string `json:"name"` } `json:"status"` } `json:"fields"` } if _, err := jiraGET(ctx, cfg, "/rest/api/3/issue/"+url.PathEscape(c.JiraKey)+"?fields=status", &iss); err != nil { rows = append(rows, jiraCheckRow{ CardID: c.ID, JiraKey: c.JiraKey, Title: c.Title, KanbanColumnID: c.ColumnID, KanbanColumnName: c.ColumnName, JiraStatusName: "(fetch failed: " + err.Error() + ")", Mismatch: true, IssueURL: cfg.BaseURL + "/browse/" + c.JiraKey, }) mismatches++ continue } expectedCol := statusToCol[strings.ToLower(iss.Fields.Status.Name)] expectedStat := cfg.StatusMap[c.ColumnName] mm := !strings.EqualFold(iss.Fields.Status.Name, expectedStat) if mm { mismatches++ } rows = append(rows, jiraCheckRow{ CardID: c.ID, JiraKey: c.JiraKey, Title: c.Title, KanbanColumnID: c.ColumnID, KanbanColumnName: c.ColumnName, JiraStatusName: iss.Fields.Status.Name, ExpectedKanbanCol: expectedCol, ExpectedJiraStat: expectedStat, Mismatch: mm, IssueURL: cfg.BaseURL + "/browse/" + c.JiraKey, }) } infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{ "rows": rows, "total": len(rows), "mismatches": mismatches, "in_sync": len(rows) - mismatches, "status_map": cfg.StatusMap, "reverse_map": statusToCol, }) }) } // reconcileRequest is the body shape for POST /api/jira/reconcile-columns. // direction=kanban-wins → push Jira to match kanban (the only mode for now; // reverse is risky because moving cards in kanban can trigger downstream // notifications/timers). type reconcileRequest struct { CardIDs []string `json:"card_ids"` Direction string `json:"direction"` // currently only "kanban-wins" } // handleReconcileJiraColumns transitions each requested issue so its status // matches the current kanban column (kanban as source of truth). Reuses // the dispatcher's transitionToStatus helper for consistency with the // regular card.moved path. Per-card result. func handleReconcileJiraColumns(db *DB) http.HandlerFunc { return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) { var body reconcileRequest if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { badRequest(w, err.Error()) return } if len(body.CardIDs) == 0 { badRequest(w, "card_ids required") return } if body.Direction == "" { body.Direction = "kanban-wins" } if body.Direction != "kanban-wins" { badRequest(w, "only direction=kanban-wins is supported") return } _, cfg, err := activeJiraModule(db) if err != nil { badRequest(w, err.Error()) return } h := &jiraHandler{} ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*time.Duration(len(body.CardIDs)+1)) defer cancel() results := make([]map[string]interface{}, 0, len(body.CardIDs)) for _, cid := range body.CardIDs { res := map[string]interface{}{"card_id": cid} card, cerr := db.getCardForJira(cid) if cerr != nil { res["status"] = "error" res["error"] = cerr.Error() results = append(results, res) continue } if card.JiraKey == "" { res["status"] = "skipped" res["error"] = "card has no jira_key" results = append(results, res) continue } if _, ok := cfg.StatusMap[card.ColumnName]; !ok { res["status"] = "skipped" res["error"] = "no status_map entry for column " + card.ColumnName results = append(results, res) continue } status, terr := h.transitionToStatus(ctx, cfg, card.JiraKey, card.ColumnName) if terr != nil { res["status"] = "error" res["error"] = terr.Error() res["http"] = status results = append(results, res) continue } now := time.Now().UTC().Format(time.RFC3339) _ = db.updateCardJiraSync(cid, cfg.StatusMap[card.ColumnName], now, "") res["status"] = "fixed" res["jira_key"] = card.JiraKey res["jira_status"] = cfg.StatusMap[card.ColumnName] results = append(results, res) } infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{"results": results}) }) } // handleImportJiraIssues creates a kanban card for each requested issue_key // and links it to the existing Jira issue (sets jira_key directly, so the // dispatcher will treat any future kanban edits as updates instead of trying // to create a duplicate). The card lands in the column whose status_map entry // matches the issue's current status; falls back to FallbackColumnID when // unmappable. func handleImportJiraIssues(db *DB) http.HandlerFunc { return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) { uid, _ := infra.UserIDFromContext(r.Context(), userCtxKey) var body jiraImportRequest if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { badRequest(w, err.Error()) return } if len(body.IssueKeys) == 0 { badRequest(w, "issue_keys required") return } _, cfg, err := activeJiraModule(db) if err != nil { badRequest(w, err.Error()) return } colByName, err := db.listColumnsByName() if err != nil { serverError(w, err) return } statusToCol := reverseStatusMap(cfg) results := make([]map[string]interface{}, 0, len(body.IssueKeys)) ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*time.Duration(len(body.IssueKeys)+1)) defer cancel() for _, key := range body.IssueKeys { res := map[string]interface{}{"key": key} // Skip if already imported. if existing, _ := db.findCardByJiraKey(key); existing != "" { res["status"] = "skipped" res["error"] = "already imported (card " + existing + ")" results = append(results, res) continue } // Fetch issue detail to get summary + description + status. var iss struct { Fields struct { Summary string `json:"summary"` Status struct { Name string `json:"name"` } `json:"status"` Description json.RawMessage `json:"description"` Assignee *struct { DisplayName string `json:"displayName"` } `json:"assignee"` } `json:"fields"` } if _, err := jiraGET(ctx, cfg, "/rest/api/3/issue/"+url.PathEscape(key), &iss); err != nil { res["status"] = "error" res["error"] = err.Error() results = append(results, res) continue } // Determine target column. columnID := body.FallbackColumnID if colName, ok := statusToCol[strings.ToLower(iss.Fields.Status.Name)]; ok { if col, ok := colByName[colName]; ok { columnID = col.ID } } if columnID == "" { res["status"] = "error" res["error"] = fmt.Sprintf("no column mapping for status %q and no fallback_column_id", iss.Fields.Status.Name) results = append(results, res) continue } requester := "" if iss.Fields.Assignee != nil { requester = iss.Fields.Assignee.DisplayName } description := extractADFText(iss.Fields.Description) if description == "" { description = "Imported from Jira " + key } else { description = description + "\n\n— Imported from Jira " + key } card, cerr := db.CreateCard(columnID, requester, iss.Fields.Summary, description, uid) if cerr != nil { res["status"] = "error" res["error"] = cerr.Error() results = append(results, res) continue } // Link to existing Jira issue + seed sync state so the indicator // renders green immediately. if err := db.setCardJiraKey(card.ID, key); err != nil { res["status"] = "error" res["error"] = "card created but link failed: " + err.Error() results = append(results, res) continue } now := time.Now().UTC().Format(time.RFC3339) _ = db.updateCardJiraSync(card.ID, iss.Fields.Status.Name, now, "") res["status"] = "imported" res["card_id"] = card.ID res["column_id"] = columnID results = append(results, res) } infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{ "results": results, }) }) }