d4558667f6
UI:
- Menu avatar dropdown: 'Importar de Jira' -> 'Jira' (renombrado).
- ImportJiraModal.tsx eliminado. Sustituido por JiraModal.tsx con Mantine Tabs:
* 'Importar de Jira': UI heredada del modal anterior intacta.
* 'Comprobar columnas': nueva. Lista cards linked y muestra desincronizadas
(kanban col vs Jira status actual). Por cada row: kanban col + expected jira
status + jira status real. Checkbox multi-select + boton 'Sincronizar' que
empuja Jira al status correcto (kanban gana).
Backend:
- GET /api/jira/check-columns: walk cards.jira_key != ''. Por cada uno GET
/rest/api/3/issue/{key}?fields=status. Compara status real vs status_map.
Devuelve {rows[], total, mismatches, in_sync, status_map, reverse_map}.
- POST /api/jira/reconcile-columns {card_ids[], direction:'kanban-wins'}:
reusa jiraHandler.transitionToStatus para empujar cada issue al status del
status_map de su columna kanban actual + actualiza cards.jira_last_status.
- Helper listLinkedCardsForCheck en jira_import.go.
Direction='kanban-wins' default. Reverse direction (jira-wins) no soportado
por ahora: mover cards desde el server tiene efectos colaterales (eventos,
notificaciones, timers) que no quiero disparar masivos sin pensar.
570 lines
18 KiB
Go
570 lines
18 KiB
Go
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,
|
|
})
|
|
})
|
|
}
|