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"` } // 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, }) }) } // 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, }) }) }