Skip to content

Connettore: Jira Cloud (issue da una JQL o per chiave, liste di una issue, creazione, aggiornamento, transizioni, upsert, commenti, worklog e link) ​

Legge le issue di Jira Cloud dentro il grafo: jiraIssues è un Source che restituisce le issue di una JQL, dalla prima all'ultima, con i campi in colonne normalizzate, in modalità piena o incrementale; jiraIssueLookup arricchisce in un nodo lookup i record che hanno già una chiave (ABC-42), con le issue nella stessa forma. jiraChangelogLookup, jiraCommentsLookup e jiraWorklogsLookup danno a ogni issue la sua lista completa di modifiche, commenti e worklog. jiraCreateIssue, jiraUpdateIssue, jiraTransition e jiraUpsertIssue sono nodi action che scrivono una issue e proseguono con l'esito; l'upsert la trova per ID esterno, l'identificativo del record nell'altro sistema. jiraAddComment e jiraAddWorklog aggiungono commenti e worklog senza doppioni, con una chiave di deduplica. jiraLinkIssues collega due issue senza duplicare il link. Si importa dal subpath @noeva/etl-core/connectors/jira.

Solo Jira Cloud con l'API REST v3: Data Center e Server restano fuori. Il connettore non ha un trasporto suo: parla con richiestaHttp, il trasporto condiviso del connettore http.

FunzioneTipoCosa fa
jiraIssuesSourceLe issue di una JQL, pagina dopo pagina, in colonne
jiraIssueLookupLookupResolverLe issue per chiave o per id, in un nodo lookup
jiraChangelogLookupLookupResolverIl changelog completo di una issue, come lista
jiraCommentsLookupLookupResolverTutti i commenti di una issue, come lista
jiraWorklogsLookupLookupResolverTutti i worklog di una issue, come lista
jiraCreateIssueActionCrea una issue e prosegue con id e key
jiraUpdateIssueActionAggiorna i campi di una issue, idempotente
jiraTransitionActionPorta una issue in uno stato, idempotente
jiraUpsertIssueActionCrea o aggiorna una issue per ID esterno
jiraAddCommentActionAggiunge un commento, senza doppioni fra le run
jiraAddWorklogActionRegistra un worklog, senza doppioni fra le run
jiraLinkIssuesActionCollega due issue, senza duplicare il link
adfInTestoutilitàUn documento ADF in testo semplice
markdownInAdfutilitàUn testo in markdown in un documento ADF
loadJiraRegistryregistryI siti configurati, per istanza
loadJiraConfigconfigUn solo sito, da variabili con prefisso (JIRA_)
loadJiraInstanceconfigUn solo sito del registry, senza gli altri
istanzaJiraPerIlPianopianoIl sito per il piano, con la sua ricaduta
istanzaJiraDichiarataenv checkIl sito da dichiarare in environment(env)
trasportoJiraconfigLa config del trasporto http per un sito Jira
MANIFEST_JIRAconfigLe variabili di un sito, per il piano
dettaglioCredenzialeJiradescriptorLa riga credenziale, per un nodo Jira

Configurazione ​

loadJiraRegistry(env?) risolve i siti: con JIRA_INSTANCES=supporto,interno ogni sito ha le sue JIRA_<ID>_*, senza lista valgono le variabili base JIRA_* come istanza default. Più clienti convivono così nello stesso worker, ognuno con le sue credenziali.

VariabileDefaultNote
JIRA_INSTANCES—elenco dei siti; senza, l'istanza default dalle variabili base
JIRA_<ID>_BASE_URL—obbligatoria; il sito https://<sito>.atlassian.net, senza percorso
JIRA_<ID>_EMAIL—obbligatoria; l'utente del token, in forma di email
JIRA_<ID>_API_TOKEN—obbligatoria; segreta
JIRA_<ID>_CLOUD_ID—facoltativa; il cloud id del sito (un UUID), con un token con scope: vedi sotto
JIRA_<ID>_EXTERNAL_ID_FIELD—facoltativa; il custom field «ID esterno», per id o per nome, senza gli spazi intorno (di soli spazi è un ConfigError): serve a jiraUpsertIssue
JIRA_<ID>_TIMEOUT_MS30000tempo massimo di una richiesta, in millisecondi

Con variabili mancanti il ConfigError le elenca tutte, per nome e mai con i valori, anche fra più siti. BASE_URL vuole https; http è ammesso solo verso la macchina locale, per un server di test.

Per etl env check, istanzaJiraDichiarata(id) è il sito da passare ad ambienteDellaPipeline nel membro environment(env) della pipeline: con lo stesso manifest della run il check vede anche JIRA_<ID>_EMAIL e JIRA_<ID>_API_TOKEN, che il piano non mostra, e non dà le variabili del sito per sconosciute.

Token classico o token con scope ​

L'autenticazione è sempre Basic, email:api-token. Cambia l'indirizzo delle API:

  • token classico, senza CLOUD_ID: le API stanno sul sito, https://<sito>.atlassian.net;
  • token con scope, con CLOUD_ID: le API passano dal gateway https://api.atlassian.com/ex/jira/<cloudId>, l'unico indirizzo che un token con scope accetta. Sul sito risponderebbe 401.

Il consiglio è un token con scope di un utente di servizio dedicato («ETL»), con i soli scope che servono (read:jira-work per leggere, write:jira-work per scrivere). Il cloud id del sito si legge da https://<sito>.atlassian.net/_edge/tenant_info.

jiraIssues — le issue di una JQL ​

ts
import { arraySink, graph } from "@noeva/etl-core";
import { jiraIssues, loadJiraRegistry } from "@noeva/etl-core/connectors/jira";

const jira = loadJiraRegistry();

graph("issue-supporto")
  .source(
    "issue",
    jiraIssues({
      config: jira.get("supporto"),
      instanceId: "supporto",
      jql: "project = SUP AND created >= -30d ORDER BY key ASC",
      fields: ["summary", "status", "assignee", "Cliente", "customfield_10050"],
    }),
  )
  .sink("issue", arraySink("out"))
  .build();
OpzioneDefaultNote
config—il sito, da loadJiraRegistry().get(id)
instanceId—l'id dell'istanza, mostrato nel DAG; in incrementale si salva accanto al watermark, e se cambia la run fallisce (vedi il runbook dello stato incrementale)
jql—obbligatoria e non vuota: Jira rifiuta una ricerca non limitata
fields—obbligatori, per id o per nome; updated si aggiunge da sé
rawfalseaggiunge raw, i campi come li manda Jira, per id
incremental—accende la modalità incrementale: vedi sotto
pageSize100issue per pagina, fra 1 e 5000: Jira la tratta come un tetto
retryvedi Erroriretry di una lettura sugli errori transitori
namejira-issuesnome del source, negli errori e nei log

Il record: una colonna per campo ​

Un record per issue, appiattito. Quattro colonne ci sono sempre:

ColonnaCosa contiene
idl'id numerico, come stringa: è la chiave stabile, non cambia se la issue si sposta
keyla chiave leggibile, ABC-42: cambia se la issue cambia progetto
selfl'URL API della issue
updatedl'ultimo aggiornamento, ISO 8601 in UTC: 2026-10-16T15:54:22.485Z

Poi una colonna per ogni campo dichiarato, con il nome com'è scritto in fields: "Cliente" dà la colonna Cliente, customfield_10050 la colonna customfield_10050. Con raw: true c'è anche raw, un oggetto con i campi grezzi per id, per i tipi che il connettore non normalizza.

Campi per id o per nome ​

Un campo si dichiara per id (summary, customfield_10042) o per nome ("Cliente"). A ogni run, prima della prima pagina, il source legge una volta il catalogo dei campi del sito (GET /rest/api/3/field) e risolve i nomi in id:

  • prima si cerca l'id, poi il nome esatto: un custom field che si chiama status non nasconde il campo di sistema;
  • un nome che non esiste è un ConfigError che lo nomina, e se esiste con altre maiuscole lo suggerisce;
  • un nome portato da due campi è un ConfigError che elenca gli id: va dichiarato l'id;
  • l'errore elenca tutti i campi che non si risolvono, non solo il primo.

Così un custom field rinominato ferma la run con un messaggio chiaro, invece di dare una colonna vuota. Per i campi di sistema conviene l'id: Jira dà i loro nomi nella lingua del profilo dell'utente del token («Riepilogo», «Stato»), e cambiare lingua cambierebbe la risoluzione.

id, key, self e raw non si dichiarano: sono colonne fisse del record, e sono un ConfigError alla costruzione. updated invece si può dichiarare, ed è la stessa colonna.

I valori normalizzati ​

Il valore di ogni colonna dipende dal tipo del campo nel catalogo:

Tipo di JiraColonna
utente (assegnatario, autore…){ accountId, displayName }, mai l'email
lista di utentilista di { accountId, displayName }
option (select, radio)il valore, "Oro"
multi-option (select multipla, check)la lista dei valori, ["A", "B"]
status{ nome, categoria }, con la categoria new, indeterminate o done
priority, issuetype, resolutionil nome
dateil giorno, 2026-10-20
datetimeISO 8601 in UTC, 2026-10-16T15:54:22.485Z
testo lungo in ADF (descrizione…)testo semplice, vedi sotto
number, string, liste di stringhecome sono
ogni altro tipo (progetto, sprint…)com'è, grezzo; un ADF diventa comunque testo

Un campo vuoto, o che Jira non manda, è null. displayName è null se Jira non lo dà per le impostazioni di privacy dell'utente. La categoria dello stato è la chiave, non il nome, perché il nome cambia con la lingua del profilo.

Un valore che non ha la forma del suo tipo (uno status senza categoria, un datetime che non è un datetime) è un ExtractError che nomina la colonna, l'id e il tipo, mai il valore. Vale anche per updated: senza un datetime valido l'incrementale non avrebbe su che cosa appoggiarsi.

Da ADF a testo ​

I testi lunghi dell'API v3 sono ADF, un albero di nodi. Il connettore li converte in testo con un convertitore suo, adfInTesto, esportato anche per le pipeline:

  • paragrafi e blocchi separati da una riga vuota, titoli come testo, --- per una riga orizzontale;
  • liste con - e 1. (dal numero di partenza della lista), annidate con un rientro;
  • un link come testo (indirizzo), o l'indirizzo se il testo è quello; una smart card come l'indirizzo;
  • il codice com'è, le menzioni con il nome che mostra Jira (@Mario Rossi);
  • citazioni con > , tabelle una riga per riga con le celle separate da |, task con [x] e [ ], date come giorno, status con il loro testo.

Grassetto, colori, pannelli ed espansioni si perdono: resta il testo. Un nodo che il convertitore non conosce non è mai un errore: diventa il testo dei suoi figli, o il suo attrs.text, o niente. Per l'ADF completo c'è raw: true.

Come legge ​

  • POST /rest/api/3/search/jql. La vecchia /rest/api/3/search è deprecata e in rimozione.
  • Le pagine seguono nextPageToken fino a isLast, in sequenza: Jira non le serve in parallelo e non dà un totale. In memoria c'è una pagina per volta, e la successiva si chiede solo quando la precedente è stata consumata.
  • Una pagina più corta di pageSize non chiude la lettura. Jira restituisce meno issue del richiesto quando i campi pesano: la fine la dice solo isLast.
  • Prima della ricerca legge il catalogo dei campi, GET /rest/api/3/field, una volta per run. Una run nuova lo rilegge, così un campo rinominato nel frattempo si vede.
  • fields è sempre nella richiesta, per id, perché il default di Jira è il solo id. Un jolly (*all, *navigable) o un'esclusione (-summary) sono un ConfigError: il nodo dichiara i campi che usa.

Modalità piena ​

Senza incremental il source è in modalità piena: ogni run rilegge tutto ciò che la JQL restituisce, senza stato, e non legge né scrive il watermark. Serve per il primo caricamento e per i backfill. L'idempotenza sta nel sink: usa un upsert sulla chiave id, così rieseguire la run non duplica righe.

Modalità incrementale ​

Con incremental ogni run legge solo le issue cambiate dall'ultima run riuscita, con il watermark dello stato di pipeline su updated:

ts
jiraIssues({
  config: jira.get("supporto"),
  instanceId: "supporto",
  jql: "project = SUP", // niente ORDER BY: lo aggiunge il connettore
  fields: ["summary", "status"],
  incremental: { overlapMs: 120_000 }, // o `true`, con i default
});
OpzioneDefaultNote
incremental.overlapMs120000 (2 minuti)millisecondi riletti a ogni run; 0 = nessuno, sconsigliato: può perdere issue
incremental.initial1970-01-01T00:00:00.000Zil watermark della prima run, ISO 8601; senza fuso è UTC

Come funziona:

  1. all'avvio il runner legge il watermark salvato (o initial alla prima run) e ne toglie la sovrapposizione;
  2. il source manda (<jql>) AND updated >= <ms> ORDER BY updated ASC, con <ms> epoch in millisecondi: un numero senza virgolette non dipende dal fuso del profilo dell'utente del token, mentre una data in stringa sarebbe al minuto e nel suo fuso;
  3. ogni issue segnala il suo updated, e il runner ne tiene il massimo;
  4. il massimo si committa solo a run conclusa e riuscita. Una run fallita a metà, anche dopo che le prime pagine sono arrivate al sink, lascia il watermark dov'era: la run successiva rilegge da lì.

Il watermark salvato è l'updated ISO del record (2026-10-16T15:54:22.485Z), non l'epoch: lo stato, il report e un override per un backfill (params.watermark.<nodeId>, anche "2026-09-01T00:00:00Z") si leggono senza conversioni. Reset e backfill: runbook dello stato incrementale.

I duplicati della finestra sono attesi. La sovrapposizione rilegge a ogni run le issue aggiornate negli ultimi minuti prima del watermark, e il confine è >=. Serve perché la precisione al millisecondo di updated nella JQL non è confermata e la ricerca è a consistenza eventuale: una issue aggiornata un attimo prima della fine della run può comparire solo dopo. Per questo una source incrementale scrive solo verso sink idempotenti su id (un upsert o un MERGE sulla chiave), che assorbono le issue rilette senza righe in più. La chiave è id e non key: la key cambia quando una issue si sposta di progetto.

Niente ORDER BY nella JQL. In incrementale il connettore mette la JQL fra parentesi e ci aggiunge il filtro e il suo ordinamento: un ORDER BY fra parentesi non è JQL valida, e lo rifiuta alla costruzione con un ConfigError che lo spiega. Un ORDER BY dentro un testo fra virgolette (summary ~ "order by") non conta. In modalità piena l'ORDER BY resta ammesso.

jiraIssueLookup — le issue per chiave ​

Un LookupResolver per i record che hanno già una chiave Jira, ABC-42: un ordine con il suo ticket, una riga di fattura con la sua issue. Invece di una JQL per record legge le issue in blocco con POST /rest/api/3/issue/bulkfetch, e le restituisce nella stessa forma del source: id, key, self, updated, una colonna normalizzata per campo dichiarato e raw se chiesto.

ts
import {
  jiraIssueLookup,
  loadJiraRegistry,
  type JiraIssue,
} from "@noeva/etl-core/connectors/jira";

const jira = loadJiraRegistry();

graph("ordini-con-ticket")
  .source("ordini", ordini)
  .lookup<Ordine, OrdineConTicket>(
    "ordini",
    "ticket",
    {
      resolver: jiraIssueLookup({
        config: jira.get("supporto"),
        instanceId: "supporto",
        fields: ["summary", "status", "Cliente"],
      }),
      key: (o) => o.chiaveTicket,
      merge: (rows, o) => {
        const issue = rows[0] as JiraIssue | undefined;
        return {
          ...o,
          titolo: issue?.["summary"] ?? null,
          stato: issue?.["status"] ?? null,
        };
      },
      onNotFound: "error", // una chiave che Jira non ha va nella dead-letter
      batchSize: 100,
    },
    { deadLetter: scarti },
  )
  .sink("ticket", out)
  .build();
OpzioneDefaultNote
config—il sito, da loadJiraRegistry().get(id)
instanceId—l'id dell'istanza, mostrato nel DAG
fields—obbligatori, per id o per nome, come in jiraIssues; updated da sé
rawfalseaggiunge raw, i campi come li manda Jira, per id
retryvedi Erroriretry di ogni chiamata sugli errori transitori, come per il source
namejira-issue-lookupnome del resolver, negli errori e nei log

Come risolve un batch del nodo:

  • Al più 100 chiavi per chiamata. Jira ne accetta fino a 1000 solo se i campi chiesti rispettano certe condizioni, e oltre il limite risponde 400: 100 vale sempre. Un batch del nodo più grande (il default di batchSize è 200) si spezza in più chiamate, in sequenza. Con batchSize: 100 un round-trip del nodo è una chiamata sola; il rateLimit del nodo conta i round-trip, non le singole chiamate né la lettura del catalogo.
  • Ogni chiave trovata ha una riga, la sua issue. Una chiave che non esiste, o che l'utente del token non può vedere, non ha voce: è «non trovato», e decide onNotFound del nodo ("null" la fa passare con zero righe, "error" la manda nella dead-letter). Jira la lascia fuori dalla risposta senza errore, e non distingue i due casi.
  • Le chiavi tornano esattamente come sono state passate (contratto di LookupResolver). Si confrontano senza spazi e in maiuscolo: abc-42 e ABC-42 trovano ABC-42, e a Jira va una chiave sola. Anche un id numerico (10042) vale come chiave.
  • La chiave vecchia di una issue spostata di progetto trova la issue, con la chiave nuova in key: Jira la risolve da sé. Siccome la restituisce con la chiave nuova, e le issue escono in ordine di id, il connettore la riabbina chiedendo da sole le chiavi rimaste senza issue, e solo quando il lotto ha restituito una issue che nessuna chiave indica. Di solito non costa niente.
  • Una stringa che non è né una chiave né un id (non una chiave, ABC) è «non trovato» senza partire: non può essere una issue, e una chiave sporca non manda in errore il batch.
  • Il catalogo dei campi si legge una volta per run, al primo batch che ha chiavi da cercare, e serve tutti i batch della run; una run nuova lo rilegge. Se la lettura fallisce non resta in memoria: il batch dopo ci riprova.
  • Il lookup legge soltanto: rieseguire la run non scrive niente su Jira.

Il retry sta nel resolver, per singola chiamata: un lotto fallito per un errore transitorio si ritenta da solo, senza rileggere i lotti già arrivati. La policy retry del nodo non serve, e se c'è rifà l'intero batch.

Le liste di una issue — changelog, commenti e worklog ​

Tre LookupResolver per le liste complete legate a una issue: jiraChangelogLookup per il changelog (cycle time, lead time), jiraCommentsLookup per i commenti, jiraWorklogsLookup per il tempo registrato.

Le liste della ricerca non sono complete, e il connettore non le usa mai come se lo fossero. La ricerca e il bulkfetch delle issue danno al più i primi 20 commenti, le ultime voci del changelog e una pagina di worklog, a seconda dell'endpoint. Un campo comment o worklog dichiarato in jiraIssues arriva com'è, una pagina troncata con il suo total, e non va caricato come se fosse la lista intera. Questi lookup leggono invece ogni lista fino all'ultima pagina: lo verifica un test con 45 commenti, a pagine di 20.

Una riga per issue, la sua lista ​

Ogni chiave trovata ha una riga, cioè la lista delle sue voci. Una issue senza commenti, senza worklog o mai modificata ha una lista vuota e non è «non trovato»: per il core, «non trovato» è una chiave senza righe. Così onNotFound: "error" scarta solo le chiavi che Jira non ha. Il fan-out in righe, una per voce, è un nodo explode a valle, visibile nel DAG:

ts
import { explode } from "@noeva/etl-core";
import {
  jiraCommentsLookup,
  jiraIssues,
  loadJiraRegistry,
  type CommentoJira,
  type JiraIssue,
} from "@noeva/etl-core/connectors/jira";

interface IssueConCommenti {
  readonly issue: string;
  readonly commenti: readonly CommentoJira[];
}

const jira = loadJiraRegistry();

graph("commenti-jira")
  .source(
    "issue",
    jiraIssues({
      config: jira.get("supporto"),
      jql: "project = ABC",
      fields: ["summary"],
    }),
  )
  .lookup<JiraIssue, IssueConCommenti>("issue", "commenti", {
    resolver: jiraCommentsLookup({
      config: jira.get("supporto"),
      instanceId: "supporto",
    }),
    key: (i) => i.id,
    merge: (rows, i) => ({
      issue: i.key,
      commenti: (rows[0] as readonly CommentoJira[] | undefined) ?? [],
    }),
    onNotFound: "error",
  })
  .transform(
    "commenti",
    "un-commento-per-riga",
    explode<IssueConCommenti, "commenti", "commento">("un-commento-per-riga", {
      field: "commenti",
      as: "commento",
    }),
  )
  .sink("un-commento-per-riga", commentiInTabella)
  .build();

Una issue con la lista vuota esce da explode senza righe: è il comportamento di explode, e non un errore. Se servono anche le issue senza voci, conviene contare la lista in merge prima di espanderla. Il changelog e i worklog si usano allo stesso modo, con VoceChangelogJira e WorklogJira.

OpzioneDefaultNote
config—il sito, da loadJiraRegistry().get(id)
instanceId—l'id dell'istanza, mostrato nel DAG
fieldIdstuttisolo changelog: al più 10 id di campo, ["status"]
rawfalsesolo commenti e worklog: la voce come la manda Jira
retryvedi Erroriretry di ogni chiamata sugli errori transitori
namejira-changelog-lookup, jira-comments-lookup, jira-worklogs-lookupnome del resolver, negli errori e nei log

Le chiavi valgono come in jiraIssueLookup. Si confrontano senza spazi e in maiuscolo, e tornano esattamente come sono state passate. Un id numerico vale come chiave, e una stringa che non può essere una issue è «non trovato» senza partire. Dove la chiave può cambiare, conviene passare l'id della issue, come nell'esempio.

Le voci ​

Le date sono in ISO 8601 in UTC, come nel source. L'autore è { accountId, displayName }, mai l'email, e vale null per un'automazione o per un utente cancellato.

ListaCampi della voce
changelogid, data, autore, campo, campoId, da, a, daId, aId
commentiid, autore, testo (il corpo da ADF a testo), creato, aggiornato, raw?
worklogid, autore, inizio, secondi, testo (o null), creato, aggiornato, raw?
  • Changelog: una voce per campo cambiato. Una modifica che cambia stato e assegnatario insieme dà due voci, con lo stesso id e la stessa data. La chiave di una voce è quindi id più campo. da e a sono i valori leggibili (In corso), daId e aId gli id, per i campi che ne hanno uno. Un valore vuoto è null. Le voci arrivano dalla più vecchia.
  • Commenti dal più vecchio (orderBy=created): un commento scritto durante la lettura finisce in fondo, e non sposta le pagine già lette. testo è il corpo convertito con adfInTesto ; con raw: true il commento grezzo ha anche l'ADF.
  • Worklog: secondi è il tempo registrato come numero, non il testo 1h 30m. testo è il commento del worklog, oppure null se non c'è.

Come legge ​

  • Prima le issue delle chiavi. Tutte e tre le liste risolvono le chiavi nelle loro issue con il bulkfetch delle issue, come jiraIssueLookup: 100 chiavi per chiamata, con lo stesso riabbinamento delle issue spostate, e senza leggere il catalogo dei campi. Una chiave senza issue è «non trovato» senza altre chiamate. Poi le liste si leggono per id. Le ragioni sono tre:
    • il changelog in blocco risponde solo per id, e tace sulle issue mai modificate;
    • il 404 dei commenti non distingue una issue che non c'è da un sito configurato male;
    • il 404 dei worklog vale anche per il time tracking disattivato. Preso per «non trovato», un sito senza time tracking sembrerebbe un sito senza issue.
  • Changelog. POST /rest/api/3/changelog/bulkfetch a lotti di 1000 issue, il massimo di Jira, seguendo nextPageToken fino all'ultima pagina. Una issue può tornare in più pagine, e le sue voci si accodano nell'ordine di arrivo. fieldIds filtra lato Jira.
  • Commenti e worklog. Jira non ha un endpoint in blocco, quindi il lookup legge una issue alla volta: GET /rest/api/3/issue/{issue}/comment a pagine di 100, …/worklog a pagine di 1000. Segue startAt finché una pagina è vuota o arriva a total. Una pagina più corta del chiesto non vuol dire fine, perché la dimensione della pagina Jira la decide anche da sé. Una pagina che non parte dallo startAt chiesto ferma la lettura, che altrimenti girerebbe in tondo.
  • Un 404 su una issue appena trovata è un errore, non «non trovato». Per i commenti vuol dire che la issue non è più visibile al token. Per i worklog, quasi sempre, che il time tracking è spento. L'errore fa fallire il batch del nodo: con una dead-letter i record ci finiscono tutti, senza la run si ferma. È voluto: un sito senza time tracking non deve caricare zero worklog in silenzio.
  • Ogni issue si legge una volta per batch, anche se più record la indicano, per chiave o per id. Le chiamate vanno in sequenza. Con il batchSize di default (200), un round-trip del nodo dei commenti fa fino a 200 letture di commenti, più una chiamata ogni 100 chiavi per trovare le issue. Il rateLimit del nodo conta i round-trip, non le chiamate.
  • Il retry sta nel resolver, per singola chiamata o pagina. Una pagina fallita per un errore transitorio si ritenta da sola, senza rileggere le precedenti, e la policy retry del nodo non serve.
  • I lookup leggono soltanto: rieseguire la run non scrive niente su Jira.

Errori ​

CasoErroreSi ritenta
JQL vuota, fields vuoti, jolly, pageSize fuori rangeConfigError alla costruzione—
Un campo chiamato id, key, self o rawConfigError alla costruzione—
Incrementale: ORDER BY, overlapMs o initial non validiConfigError alla costruzione—
Incrementale eseguito fuori dal runner, senza watermarkExtractError prima di leggereno
Un nome di campo che non esiste o che è ambiguoConfigError prima della ricerca—
Variabili mancanti o non valideConfigError al caricamento—
429, 502, 503, 504, timeout, reteExtractError transitoriosì
400 (JQL o campi non validi)ExtractErrorno
Un valore che non ha la forma del suo tipoExtractErrorno
401 (credenziali rifiutate)ExtractErrorno
403 (permessi insufficienti)ExtractErrorno
404 (API non trovata)ExtractErrorno
Risposta oltre il tetto di byte, non JSON o fuori contrattoExtractErrorno
isLast: false senza nextPageToken, o un token ripetutoExtractErrorno

Gli errori di 429, 5xx, rete e status valgono anche per il catalogo dei campi: il messaggio dice «catalogo dei campi» invece di «ricerca delle issue», e la ricerca non parte. Una pagina che fallisce per un errore transitorio si ritenta dal suo token, non da capo. La policy di default segue le raccomandazioni di Atlassian: Retry-After come attesa minima, backoff da 2 secondi con jitter, al più 4 tentativi. Ogni attesa ha un tetto di 30 secondi (maxMs), Retry-After compreso: se Jira chiede di aspettare di più, si ritenta dopo 30 secondi, e se è ancora fermo la pagina fallisce invece di tenere ferma la run. I campi indicati in retry sostituiscono quelli di default, uno per uno.

Niente dati negli errori. Il messaggio porta metodo, endpoint, status e un suggerimento fisso su che cosa controllare. Mai la JQL, che può contenere dati, mai il token, mai il corpo della risposta: i testi di Jira (errorMessages, errors) restano leggibili solo da codice con rispostaErroreHttp(err). L'annullamento della run si propaga com'è.

Errori di jiraIssueLookup ​

Il resolver dà gli errori della tabella. Il nodo lookup li avvolge tutti, anche un ConfigError o un annullamento, in un LookupError del nodo («Lookup "ticket" fallito su N chiavi») con l'originale come cause, e seguono la semantica del nodo: dead-letter se c'è, ramo catch sotto un tryCatch, altrimenti fail-fast. Un errore fa fallire tutto il batch del nodo, come un round-trip SQL: con batchSize: 1 resta sul singolo record. Per questo un nome di campo sbagliato, con una dead-letter, manda nella dead-letter ogni record invece di fermare la run: va controllato il primo record scartato.

CasoErroreSi ritenta
fields vuoti, jolly, un campo id/key/self/rawConfigError alla costruzione—
Un nome di campo che non esiste o che è ambiguoConfigError prima del bulkfetch—
Una chiave che non esiste o che non si può vederenessuno: «non trovato»—
429, 502, 503, 504, timeout, reteLookupError transitoriosì
issueErrors nella risposta (issue non restituite da Jira)LookupError transitoriosì
400, 401, 403, 404LookupError con un suggerimentono
Risposta oltre il tetto di byte, non JSON o fuori contrattoLookupErrorno
Un valore che non ha la forma del suo tipoLookupError che nomina la colonnano
Annullamento della runsi propaga com'è—

I messaggi del resolver portano metodo, endpoint e status, mai le chiavi: una chiave può essere un dato del cliente, e finisce nel report. Con onNotFound: "error" invece è il core a scrivere le chiavi del record nei details del suo errore «Nessuna riga trovata», che arriva alla dead-letter: vale per ogni lookup. issueErrors si conta, ma i suoi messaggi non si leggono. Un'issue restituita che nessuna chiave indica, nemmeno chiesta da sola, si scarta, e il log jira-lookup.non-abbinate la conta, senza chiavi.

Errori dei lookup delle liste ​

Come per jiraIssueLookup, il nodo lookup avvolge ogni errore del resolver in un LookupError del nodo, con l'originale come cause.

CasoErroreSi ritenta
fieldIds con più di 10 campi, doppioni o un campo vuotoConfigError alla costruzione—
Una issue che non esiste o che non si può vederenessuno: «non trovato»—
Una issue senza vocinessuno: lista vuota—
429, 502, 503, 504, timeout, reteLookupError transitoriosì
400, 401, 403, 404 del bulkfetchLookupError con un suggerimentono
Commenti o worklog: 404 su una issue appena trovataLookupError che dice perchéno
Una pagina che non parte dallo startAt chiestoLookupErrorno
Una voce senza id, una data che non è una data, un fieldLookupError che nomina il campono
nextPageToken ripetuto (changelog)LookupErrorno
Risposta oltre il tetto di byte, non JSON o fuori contrattoLookupErrorno
Annullamento della runsi propaga com'è—

I messaggi portano metodo, endpoint e status: l'endpoint è con {issue} al posto della chiave (/rest/api/3/issue/{issue}/comment). Mai le chiavi, i token di pagina, i testi o i valori: una voce fuori contratto nomina il campo («commento: il campo created non ha la forma attesa»), mai il suo valore, che è testo di un utente.

Descriptor ​

kind: "jira-source", con l'istanza in connection e questi dettagli, in ordine:

LabelValore
sitol'origine del sito, con la variabile JIRA_<ID>_BASE_URL
gatewaysolo con un token con scope: il gateway con il cloud id
jqlla JQL su una riga, troncata a 200 caratteri
campii campi com'è dichiarati, per id o per nome, updated compreso
rawsolo con raw: true: sì
watermarksolo in incrementale: updated (timestamp), sovrapposizione 2 min
configurazionesolo nel piano, per un valore non valido
credenziale«token classico» o «token con scope», e lo stato: impostato o le variabili che mancano

Mai il token, l'email o un header di autenticazione.

jiraIssueLookup ha kind: "jira-lookup", con l'istanza in connection e, in ordine, sito, gateway (con un token con scope), operazione (issue per chiave), campi com'è dichiarati con updated, raw (solo con raw: true), configurazione (solo nel piano) e credenziale. Le colonne del nodo lookup le dà l'outputSchema del nodo, perché la forma la decide merge.

I lookup delle liste hanno lo stesso kind: "jira-lookup", con operazione che porta l'endpoint: changelog per issue (POST /rest/api/3/changelog/bulkfetch), commenti per issue (GET /rest/api/3/issue/{issue}/comment), worklog per issue (GET /rest/api/3/issue/{issue}/worklog). Poi campi con i fieldIds (solo per il changelog, se filtra) e raw (con raw: true).

Le colonne (columns) del source jiraIssues sono quelle del record: id (l'id numerico, la chiave del record), key, self, updated, una per campo dichiarato e raw se chiesto. Il tipo di una colonna è noto senza il sito solo per i campi di sistema più comuni (summary, status, assignee, created, labels…); per un custom field o un nome il tipo lo sa solo il catalogo del sito, e la colonna è unknown nel piano.

La JQL invece si pubblica: compare nel DAG, nella UI e negli snapshot del piano committati nel repository. Va scritta come configurazione, senza dati personali (un nome, un'email, un codice cliente): per quelli serve un filtro a valle nel grafo. Per lo stesso motivo gli errori non la riportano mai.

Scritture ​

Le scritture sono nodi action sul modello di httpAction: scrivono un record alla volta e proseguono con l'esito, per esempio la chiave ABC-42 appena creata. Hanno tutte la stessa forma, che riusano anche le scritture successive della spec: commenti, worklog e link.

ts
import {
  jiraCreateIssue,
  jiraUpdateIssue,
  loadJiraRegistry,
} from "@noeva/etl-core/connectors/jira";

const jira = loadJiraRegistry();

graph("segnalazioni")
  .source("segnalazioni", segnalazioni)
  .action(
    "segnalazioni",
    "ticket",
    jiraCreateIssue({
      config: jira.get("supporto"),
      instanceId: "supporto",
      request: (s) => ({
        fields: {
          project: { key: "SUP" },
          issuetype: { name: "Task" },
          summary: s.titolo,
          description: s.testo, // markdown: diventa ADF
        },
      }),
      merge: (s, esito) => ({ ...s, chiave: esito.ok ? esito.key : null }),
    }),
  )
  .sink("ticket", archivio)
  .build();
OpzioneDefaultNote
config—il sito, da loadJiraRegistry().get(id)
instanceId—l'id dell'istanza, mostrato nel DAG
request—che cosa scrivere, ricavato dal record; si valuta una volta sola
merge—che cosa prosegue nel grafo, dato il record e l'esito
onError"fallisci""fallisci" o "continua": vedi sotto
testoRicco—i campi custom di testo ricco, per id; description ed environment lo sono sempre
rateLimit{ perSecond: 3 }chiamate al secondo del nodo, letture e ogni tentativo compresi
retryvedi «Retry»al più 4 tentativi; su jiraCreateIssue è un ConfigError
namejira-create-issue …nome dell'action, negli errori e nei log

I campi si scrivono per id (summary, customfield_10042), con i valori come li vuole l'API v3: { project: { key: "SUP" } }, { priority: { name: "High" } }, ["etl", "sync"] per le label. Un campo undefined non si manda.

jiraCreateIssue — creare ​

POST /rest/api/3/issue con fields. L'esito riuscito porta ciò che Jira ha creato:

ts
{ ok: true, esito: "creata", status: 201, id: "10042", key: "SUP-42", self: "https://…" }

⚠️ Creare non è idempotente. Rieseguire la run crea una seconda issue: per questo la creazione non si ritenta mai, nemmeno su un 429 o un 409, e una retry nelle opzioni è un ConfigError alla costruzione del grafo. Per scrivere senza duplicati serve l'upsert per ID esterno, jiraUpsertIssue.

jiraUpdateIssue — aggiornare ​

PUT /rest/api/3/issue/{issue}, con la issue per chiave (ABC-42) o per id (10042). È idempotente per costruzione: ripetere lo stesso aggiornamento lascia la issue com'era.

ts
jiraUpdateIssue({
  config: jira.get("supporto"),
  instanceId: "supporto",
  request: (r) => ({
    issue: r.chiave,
    fields: {
      summary: r.titolo,
      labels: r.etichette,
      duedate: r.scadenza ?? null,
    },
    add: { components: [{ name: "Backend" }] },
    hash: { nuovo: r.hash, attuale: r.hashSuJira },
  }),
  merge: (r, esito) => ({ ...r, esito }),
});
Nella richiestaChe cosa fa
un campo in fieldslo sostituisce, multivalore compresi: labels: ["a"] lascia la sola a
un campo assente o undefinednon lo tocca
un campo nulllo svuota
add: { campo: [valori] }aggiunge i valori a un multivalore, senza toccare gli altri
remove: { campo: [valori] }toglie i valori da un multivalore
hash: { nuovo, attuale }se sono uguali, noop senza scrivere niente

Un campo non può stare sia in fields sia in add/remove: è un errore del record che nomina il campo, e non parte niente. add e remove restano idempotenti sui multivalore di Jira (label, componenti, versioni), che non ammettono doppioni.

Gli esiti riusciti:

ts
{ ok: true, esito: "aggiornata", status: 204, issue: "ABC-42" }
{ ok: true, esito: "noop", issue: "ABC-42", motivo: "hash invariato" }       // o "niente da aggiornare"

Il nodo non legge la issue prima di scrivere: confrontarla raddoppierebbe le chiamate e consumerebbe i limiti per issue. Un noop per hash c'è solo se la pipeline passa l'hash del contenuto nuovo (per esempio da hashRow) e quello che Jira ha già, che deve sapere lei: per esempio da un custom field «hash ETL» che la pipeline scrive insieme ai campi e rilegge a monte.

jiraTransition — portare in uno stato ​

Porta una issue in uno stato di arrivo, indicato per nome ("In corso") o per id dello stato ({ id: "10001" }), non per transizione: i nomi delle transizioni non sono unici in un workflow, quelli degli stati sì. Il nome si confronta senza badare a maiuscole.

ts
jiraTransition({
  config: jira.get("supporto"),
  instanceId: "supporto",
  stato: "Fatto", // per ogni record; il record può indicarne un altro
  request: (r) => ({
    issue: r.chiave,
    fields: { resolution: { name: "Fatto" } }, // i campi della schermata
  }),
  merge: (r, esito) => ({ ...r, esito }),
});
Nella richiesta o nelle opzioniChe cosa fa
stato nelle opzionilo stato di arrivo di ogni record; è quello che mostra il descriptor
stato nel recordsostituisce quello delle opzioni; senza nessuno dei due è un errore del record
fieldsi campi della schermata della transizione, per id; undefined non si manda
testoRicco nelle opzionii campi custom della schermata in cui una stringa è markdown, come negli update

Che cosa fa per ogni record:

  1. GET /rest/api/3/issue/{issue}?fields=status: se la issue è già nello stato, l'esito è noop e non parte altro. Lo stato attuale non si ricava dalle transizioni: quelle globali portano anche allo stato in cui la issue è già;
  2. GET /rest/api/3/issue/{issue}/transitions?expand=transitions.fields: le transizioni disponibili adesso, con i campi delle loro schermate;
  3. fra quelle che portano allo stato, sceglie la prima che ha nel record tutti i campi obbligatori della schermata (un campo con un valore di default non serve);
  4. POST /rest/api/3/issue/{issue}/transitions con la transizione e i fields.

Gli esiti:

ts
{ ok: true, esito: "transitata", status: 204, issue: "ABC-42",
  transizione: { id: "41", nome: "Chiudi" }, stato: { id: "10001", nome: "Fatto" } }
{ ok: true, esito: "noop", issue: "ABC-42", motivo: "già nello stato", stato: { id: "10001", nome: "Fatto" } }
{ ok: false, motivo: "…nessuna transizione disponibile porta allo stato richiesto (stati raggiungibili: Backlog, In corso)",
  campiRifiutati: [], statiRaggiungibili: ["Backlog", "In corso"] }
{ ok: false, motivo: "…campi obbligatori mancanti nella schermata della transizione: resolution (Risoluzione)",
  campiRifiutati: [], campiMancanti: [{ id: "resolution", nome: "Risoluzione" }] }

Una transizione che non c'è e un campo obbligatorio che manca non partono: li decide il connettore prima del POST, e seguono onError come un errore di Jira. Con "fallisci" sono un ActionError con statiRaggiungibili o campiMancanti nei details, che finisce in dead-letter: è il caso tipico da instradare a un umano. Quando Jira non ha transizioni verso lo stato restituisce una lista vuota, non un errore: l'esito è lo stesso, con gli stati raggiungibili. Lo stato chiesto non va nel motivo, perché può venire dal record; gli stati raggiungibili e i nomi dei campi sì, perché sono la configurazione del sito.

È idempotente: rieseguita, trova la issue già arrivata e dà noop. Una run ripetuta costa una lettura per record e nessuna scrittura. Tre casi da sapere:

  • un noop non dice chi ha spostato la issue. Se un POST riesce ma la risposta si perde (un 503 dopo aver scritto, un timeout), il retry rilegge lo stato e dà noop, «già nello stato», anche se la transizione l'ha fatta questa run;
  • una post-function o un'automazione che sposta subito la issue oltre lo stato chiesto rende quel retry un ok: false, «nessuna transizione», anche se la transizione è avvenuta;
  • una issue spostata da qualcun altro fra la lettura delle transizioni e il POST dà un 400 di Jira, non transitorio: va in dead-letter, e la run dopo la trova nello stato giusto o la ritenta.

jiraUpsertIssue — upsert per ID esterno ​

La primitiva della sincronizzazione: un record dell'altro sistema corrisponde a una issue tramite un custom field «ID esterno», e rieseguire non duplica. Il nodo cerca le issue con l'ID del record:

Issue con l'IDChe cosa faEsito
0crea, con soloInCreazione, fields, add e l'ID esterno nel suo campocreata
1aggiorna, con la semantica di jiraUpdateIssue: fields, add, remove, hashaggiornata o noop
più di unaniente: non sa quale scegliereok: false, «ID esterno duplicato», con le chiavi
ts
jiraUpsertIssue({
  config: jira.get("supporto"), // con JIRA_SUPPORTO_EXTERNAL_ID_FIELD
  instanceId: "supporto",
  request: (o) => ({
    idEsterno: o.numero,
    fields: { summary: `Ordine ${o.numero}`, description: o.note },
    soloInCreazione: { project: { key: "SUP" }, issuetype: { name: "Task" } },
    add: { labels: ["ordini"] },
    hash: { nuovo: o.hash, attuale: o.hashSuJira },
  }),
  merge: (o, esito) => ({ ...o, chiave: esito.ok ? esito.key : null }),
});
Nella richiestaChe cosa fa
idEsternol'identificativo del record, senza gli spazi intorno: si cerca e, in creazione, si scrive
fieldsin creazione e in aggiornamento, come in jiraUpdateIssue
soloInCreazionesolo in creazione: project, issuetype, e quanto non va toccato su una issue che c'è già
addin creazione è il valore del campo; in aggiornamento si aggiunge
remove, hashsolo in aggiornamento

Un campo in due posti fra fields, soloInCreazione e add è un errore del record che lo nomina. Il campo dell'ID esterno nei campi del record, per id, per il nome del catalogo o com'è configurato, è un errore: lo scrive l'upsert. Un ID che non è una stringa, vuoto, con caratteri di controllo (a capo, tabulazione), senza lettere né cifre o più lungo di 255 caratteri è un errore del record che non lo ripete, e non parte niente.

Gli esiti riusciti portano sempre l'id e la key della issue:

ts
{ ok: true, esito: "creata", status: 201, id: "10101", key: "SUP-101", self: "https://…" }
{ ok: true, esito: "aggiornata", status: 204, id: "10007", key: "SUP-7" }
{ ok: true, esito: "noop", id: "10007", key: "SUP-7", motivo: "hash invariato" }
{ ok: false, motivo: "Jira, upsert per ID esterno: ID esterno duplicato: lo portano 2 issue", campiRifiutati: [], chiavi: ["SUP-7", "SUP-9"] }

Con onError: "fallisci" il duplicato è un ActionError con le chiavi nei details, e il messaggio non ripete l'ID. Le issue duplicate vanno riconciliate a mano: l'upsert non ne sceglie una.

Il campo «ID esterno». Un custom field di testo a una riga («Testo breve»), configurato per istanza con JIRA_<ID>_EXTERNAL_ID_FIELD, per id (customfield_10100) o per nome (ID esterno). Senza la variabile jiraUpsertIssue non si costruisce: è un ConfigError che la nomina. Fa eccezione l'esempio del piano, che non esegue: lì il nodo si costruisce e il descriptor dice non impostato. Il campo si risolve nel catalogo del sito una volta per run, al primo record, e deve essere un custom field a una riga (customfieldtypes:textfield). Un campo di sistema, un paragrafo (textarea, che nell'API v3 è un ADF e il confronto esatto non riconoscerebbe mai) o un campo di un altro tipo sono un ConfigError, e così il campo dichiarato in testoRicco. Il campo è configurazione, non il record: un campo che non si risolve, o un catalogo che non si legge, fermano il record anche con onError: "continua". Un ConfigError del campo vale per tutta la run, senza rileggere il catalogo a ogni record; un catalogo che non si legge si riprova al record dopo.

Come cerca. POST /rest/api/3/search/jql con cf[10100] ~ "\"<id>\"", chiedendo solo quel campo, tutte le pagine. In JQL un campo di testo si cerca solo per testo (~), e la frase fra virgolette è il modo di chiedere le parole in fila: è una ricerca larga, che per ORD-1 trova anche ORD-1-bis, e non bada alle maiuscole. Il connettore tiene solo le issue con il valore identico, maiuscole comprese, senza gli spazi intorno, e conta quelle. Oltre 10 pagine (1000 issue trovate per testo) l'ID non è abbastanza distintivo: è un errore del record, e non si crea niente.

⚠️ La ricerca di testo non trova ciò che l'analizzatore di Jira scarta: le parole vuote della lingua di indicizzazione (a, the, e, di…). Un ID fatto solo di parole così non si trova mai, e l'upsert crea a ogni run. L'ID esterno deve essere un codice (ORD-1, un UUID, un numero lungo), non una parola o una sigla di due lettere. Il comportamento esatto dell'analizzatore va verificato sul sito reale (ETL-272).

Retry. Si ritentano la ricerca e l'aggiornamento, con il retry di jiraUpdateIssue: l'operazione intera, ricerca compresa, e il 409 è transitorio solo per l'aggiornamento. La creazione non si ritenta mai: Jira non ha una chiave d'idempotenza sulla creazione, e una creazione ripetuta duplica. Se una creazione finisce con un esito incerto (un 5xx, un timeout, un errore di rete, una risposta fuori contratto: la issue può esserci o no), gli upsert successivi dello stesso ID nella stessa run falliscono con «esito incerto» invece di creare ancora. Un 4xx vuol dire che Jira non l'ha creata, e lo stesso ID può riprovare. Un campo che non si converte in ADF è un errore del record prima della richiesta, non un esito incerto.

La ricerca conta nel rateLimit del nodo, come le letture delle altre action: un upsert fa una ricerca (o più pagine) e una scrittura. Il catalogo dei campi, letto una volta per run fuori dall'action, no; il suo retry si vede nel log come jira-lookup.retry, quello dell'operazione come jira-action.retry.

Stessa run, stesso ID. La ricerca di Jira è a consistenza eventuale: una issue appena creata può non comparire per secondi. Due upsert dello stesso ID nella stessa run sono quindi serializzati per ID, e l'upsert si ricorda per la durata della run l'issue di ogni ID che ha creato o aggiornato: il secondo record aggiorna la issue del primo senza cercarla. Due record con lo stesso ID nello stesso batch fanno una sola creazione. Un aggiornamento fallito non si ricorda, e il record dopo cerca di nuovo.

La memoria e la coda sono del nodo: due nodi jiraUpsertIssue distinti nella stessa run (due rami di un route, due istanze di una sub-pipeline) non le condividono, e lo stesso ID scritto da entrambi ha il rischio della consistenza eventuale anche dentro la run. Un ID esterno va scritto da un nodo solo.

Fra run diverse: il rischio residuo. La memoria è per run. Se la run successiva parte prima che la ricerca veda la issue creata dalla precedente, l'upsert non la trova e ne crea una seconda. Il rischio è piccolo (di solito secondi, minuti dopo una modifica di massa) e accettato. Si riduce non schedulando la stessa pipeline a pochi secondi di distanza dalla precedente, e si vede: la run dopo la seconda creazione dà «ID esterno duplicato» con le due chiavi, invece di aggiornarne una a caso. La mitigazione possibile è una riconciliazione periodica, reconcileIssues, che cerca gli ID esterni portati da più issue e li segnala o li fonde. Non esiste ancora: oggi le issue duplicate si riconciliano a mano, partendo dalle chiavi dell'esito.

Con onError: "continua" l'esito ok: false porta in jira i testi di Jira, che per una ricerca rifiutata possono ripetere la JQL, e quindi l'ID esterno: un merge che inoltra l'esito intero lo porta a valle, e fino al report se una colonna lo seleziona. Conviene passare a valle motivo, status e chiavi, non jira.

jiraAddComment e jiraAddWorklog — commenti e worklog senza doppioni ​

Aggiungono un commento o un worklog a una issue, e una rerun non li duplica. La pipeline dichiara una chiave di deduplica per record, nell'opzione deduplica del nodo: identifica la scrittura fra le run, per esempio ordine-123:sollecito-1. È obbligatoria: un nodo senza è un ConfigError alla costruzione del grafo, perché senza chiave l'action non è idempotente.

ts
jiraAddComment({
  config: jira.get("supporto"),
  instanceId: "supporto",
  deduplica: (s) => `${s.ordine}:sollecito-${s.numero}`,
  request: (s) => ({ issue: s.chiave, corpo: `Sollecito **${s.numero}**` }), // markdown
  merge: (s, esito) => ({ ...s, commento: esito.ok ? esito.id : null }),
});

jiraAddWorklog({
  config: jira.get("supporto"),
  instanceId: "supporto",
  deduplica: (o) => `${o.foglio}:${o.riga}`,
  request: (o) => ({
    issue: o.chiave,
    inizio: o.inizio, // una Date, o ISO 8601 con il fuso
    secondi: o.minuti * 60,
    commento: o.nota, // facoltativo
  }),
  merge: (o, esito) => ({ ...o, worklog: esito.ok ? esito.id : null }),
});
Nella richiesta o nelle opzioniChe cosa fa
deduplica nelle opzionila chiave del record, senza gli spazi intorno; vuota, non una stringa o oltre 255 caratteri è un errore
issuela issue, per chiave (ABC-42) o per id (10042)
corpo (commento)il testo: una stringa è markdown e diventa ADF, un ADF già costruito passa com'è; vuoto è un errore
inizio (worklog)quando è cominciato il lavoro: una Date, o un datetime ISO 8601 con il fuso; senza è un errore
secondi (worklog)il tempo registrato, un intero positivo
commento (worklog)facoltativo, come corpo; undefined non si manda

Dove sta la chiave. In una proprietà del commento o del worklog, noeva-etl.deduplica (PROPRIETA_DEDUPLICA), con valore { chiave }. Le proprietà di Jira gli utenti non le vedono, e si scrivono nello stesso POST della creazione (properties nel corpo): una chiamata sola, quindi nessun commento senza chiave se la run si ferma a metà.

Come cerca. Prima di scrivere legge tutte le voci della issue, pagina dopo pagina, finché una pagina è vuota o si arriva al totale; si ferma alla prima voce che porta la chiave.

  • Commenti: GET /rest/api/3/issue/{issue}/comment?orderBy=created, 100 per pagina. Questa lista non dà le proprietà (la doc ammette solo expand=renderedBody): per ogni pagina il nodo le chiede con POST /rest/api/3/comment/list?expand=properties, con gli id della pagina. Sono due chiamate ogni 100 commenti. Se Jira ne restituisce meno di quelli chiesti con isLast: false, il nodo richiede quelli che mancano; una risposta così senza nessun commento ferma il record.
  • Una pagina che non parte da dove si è chiesta (startAt ignorato) ferma il record, perché la lettura girerebbe in tondo. Questi due errori sono ActionError non transitori: non si ritentano.
  • Worklog: GET /rest/api/3/issue/{issue}/worklog?expand=properties, 1000 per pagina: una chiamata ogni 1000 worklog.

Se una voce porta la chiave, l'esito è noop con il suo id, e non si scrive niente. Altrimenti POST /rest/api/3/issue/{issue}/comment (o /worklog) con il corpo e la proprietà:

ts
{ ok: true, esito: "creato", status: 201, issue: "ABC-42", id: "10200" }
{ ok: true, esito: "noop", issue: "ABC-42", id: "10200", motivo: "già presente" }

La chiave conta per issue: la stessa chiave su due issue sono due scritture. Basta quindi che sia unica fra i commenti (o i worklog) della issue. Non finisce mai in un messaggio d'errore, in un log o nel DAG. Una voce scritta a mano o da un altro strumento, senza la proprietà, non conta: la deduplica riconosce solo ciò che ha scritto il connettore. Per lo stesso motivo cambiare il formato della chiave in una pipeline in produzione fa riscrivere tutto alla run dopo.

Il worklog aggiorna la stima residua della issue come fa Jira di default (adjustEstimate non si manda), solo alla creazione: un noop non la tocca. Sul sito il time tracking deve essere attivo, altrimenti Jira rifiuta il worklog; il 404 lo suggerisce fra le cause.

Sono idempotenti e si ritentano per intero sui transitori, 409 compreso: un POST riuscito ma perso per strada (un 503 dopo aver scritto, un timeout) al tentativo dopo rilegge le voci, trova la chiave e dà noop, invece di un doppione. Come per jiraTransition, quel noop non dice che la scrittura l'ha fatta questa run.

jiraLinkIssues — collegare due issue ​

Collega due issue con un tipo di link indicato per nome, senza duplicare un link che c'è già.

ts
jiraLinkIssues({
  config: jira.get("supporto"),
  instanceId: "supporto",
  tipo: "Blocks", // per nome, senza badare a maiuscole
  request: (d) => ({ partenza: d.bloccante, arrivo: d.bloccata }),
  merge: (d, esito) => ({ ...d, esito }),
});
Nella richiesta o nelle opzioniChe cosa fa
tipo nelle opzioniil nome del tipo di link (Blocks, Relates, Duplicate…); vuoto è un ConfigError
partenzala issue da cui il link esce, per chiave o per id: con Blocks, «partenza blocks arrivo»
arrivola issue a cui il link arriva: «arrivo is blocked by partenza»

Che cosa fa:

  1. una volta per run, GET /rest/api/3/issueLinkType: il catalogo dei tipi del sito, in cui cerca il nome, prima esatto poi senza badare a maiuscole. Un nome che non c'è è un ConfigError che lo nomina, con i tipi disponibili: ferma il record anche con onError: "continua", perché è configurazione, e resta per tutta la run. Una lettura del catalogo che fallisce dopo i retry è un ActionError che ferma il record, e il record dopo la rifà. Il catalogo non conta nel rateLimit del nodo, e il suo retry si vede nel log come jira-lookup.retry; quello dell'operazione come jira-action.retry;
  2. per ogni record, GET /rest/api/3/issue/{partenza}?fields=issuelinks: se fra i link della partenza c'è già un link dello stesso tipo e nello stesso verso, verso l'arrivo (per chiave, senza badare a maiuscole, o per id), l'esito è noop e non parte altro;
  3. altrimenti POST /rest/api/3/issueLink.

Gli esiti:

ts
{ ok: true, esito: "collegate", status: 201, partenza: "ABC-1", arrivo: "ABC-2",
  tipo: { id: "10000", nome: "Blocks", inward: "is blocked by", outward: "blocks" } }
{ ok: true, esito: "noop", partenza: "ABC-1", arrivo: "ABC-2", motivo: "link già presente",
  tipo: { … }, link: "13638" }

Il POST non restituisce l'id del link creato, quindi collegate non lo porta; noop porta l'id del link che c'era. Una chiave non valida e una issue collegata a sé stessa sono errori del record, prima di ogni chiamata sulle issue.

Jira chiama i due capi in due modi che si incrociano, ed è il punto da non sbagliare:

DoveLa partenza («blocks»)L'arrivo («is blocked by»)
corpo di POST /issueLinkinwardIssueoutwardIssue
issuelinks della partenza— (è la issue letta)outwardIssue
issuelinks dell'arrivoinwardIssue— (è la issue letta)

Il connettore nasconde la differenza dietro partenza e arrivo. Fonti: la doc di POST /issueLink chiama la partenza «from (outward) issue» senza dire in quale campo vada; il tool di Atlassian sopra lo stesso endpoint dice che inwardIssue è la issue che blocca; e sul sito i issuelinks di una issue che ne blocca un'altra portano l'altra in outwardIssue. Va confermato contro il sandbox.

Un link nel verso opposto non è lo stesso link: con «ABC-2 blocks ABC-1» già presente, chiedere «ABC-1 blocks ABC-2» lo crea. Vale anche per un tipo simmetrico come Relates, che per Jira resta un link con una partenza e un arrivo: se il verso non conta, il grafo deve scegliere una volta per tutte quale issue è la partenza, per esempio la chiave minore.

È idempotente: rieseguita, trova il link e dà noop; una run ripetuta costa una lettura per record e nessuna scrittura. Un POST riuscito ma perso per strada (un 503 dopo aver scritto) diventa un noop al tentativo dopo. Anche Jira, a un POST che duplica un link, risponde 201 senza duplicarlo (lo dice la doc di POST /issueLink; da confermare sul sandbox con ETL-272): la lettura serve a dire noop, e a non spendere una scrittura. Due casi da sapere:

  • una chiave cambiata (la issue di arrivo spostata in un altro progetto) non si riconosce fra i link per chiave: parte un POST, che Jira tratta come il link che c'è. Dove la chiave può invecchiare, conviene l'id;
  • niente cancellazioni: un link tolto dalla sorgente resta su Jira.

Testo in markdown ​

Nei campi di testo ricco una stringa è markdown e diventa ADF con marklassian: paragrafi, titoli, grassetto, corsivo, barrato, link, codice, liste anche annidate e di task, citazioni, tabelle. description ed environment lo sono sempre; i campi custom a più righe si dichiarano in testoRicco, perché il tipo lo sa solo il sito.

  • un ADF già costruito ({ type: "doc", version: 1, content }) passa com'è;
  • null resta null, e svuota il campo;
  • lo stesso testo dà sempre lo stesso ADF: gli id delle liste di task, che marklassian genera a caso, il connettore li sostituisce con id stabili, così un aggiornamento ripetuto non cambia la issue;
  • l'ADF prodotto è validato con un sottoinsieme Zod prima di partire: un nodo fuori dal sottoinsieme è un errore che nomina il campo, mai il testo.

markdownInAdf è esportata per le pipeline che vogliono l'ADF a mano, eDocumentoAdf per riconoscere un ADF già costruito, e CAMPI_TESTO_RICCO_DI_SISTEMA elenca i campi di sistema che lo sono sempre. marklassian è fissata a una versione esatta: il sottoinsieme validato è l'elenco dei nodi che quella versione produce, e un aggiornamento della libreria va provato prima di entrare.

L'esito è un dato ​

merge riceve un'unione discriminata. Un fallimento ha la stessa forma per tutte le scritture:

ts
{
  ok: false,
  status?: 400,                    // assente se Jira non ha risposto (rete, timeout)
  motivo: "Jira, aggiornamento della issue: PUT /rest/api/3/issue/{issue}: il servizio ha risposto 400 (campi rifiutati: priority)",
  campiRifiutati: ["priority"],    // le chiavi di `errors`
  jira?: { errorMessages: [...], errors: { priority: "…" } },  // i testi di Jira, per un umano
}
onErrorCosa succede
"fallisci"Default. Lancia un ActionError: dead-letter del nodo, o ramo catch.
"continua"L'esito ok: false arriva a merge e il grafo prosegue.

⚠️ Con "continua" il ramo che riceve un esito fallito deve fare qualcosa di visibile — notificare, contare, instradare in revisione. E come per httpAction, un ok: false con status non vuol dire che la scrittura non sia avvenuta: un 500 dopo aver creato, o un 201 con un corpo fuori contratto, sono fallimenti per noi ma issue create per Jira.

Niente dati negli errori. Il messaggio, il motivo, la dead-letter e il report portano operazione, metodo, endpoint con {issue} al posto della chiave, status e i nomi dei campi rifiutati. I testi di Jira, che possono ripetere i valori rifiutati, stanno solo in jira dell'esito ok: false e non finiscono mai in un log. Un errore del codice della pipeline (in request o in merge) non è un fallimento della scrittura: propaga anche con "continua".

Retry e rate limit ​

CasojiraCreateIssuejiraUpdateIssue, jiraTransition, jiraAddComment, jiraAddWorklog, jiraLinkIssues
429, 502, 503, 504, timeout, reteActionError, nessun retrysi ritenta l'operazione intera
409 (aggiornamento in conflitto)ActionError, nessun retrysi ritenta: è transitorio
400 (campi rifiutati)ActionError, campi nomatiActionError, campi nomati
401, 403, 404ActionError, con un suggerimentoActionError, con un suggerimento
chiave della issue non valida—ActionError, nessuna richiesta sulla issue (jiraLinkIssues ha già letto il catalogo dei tipi)
transizione assente, campi mancanti—ActionError o ok: false, mai ritentato
chiave di deduplica, corpo, inizio o secondi non validi—ActionError o ok: false, nessuna richiesta, mai ritentato
deduplica assente nelle opzioni—ConfigError alla costruzione (commenti e worklog)
tipo di link inesistente—ConfigError, anche con "continua"; resta per la run
413 (troppi link sulla issue)—ActionError, con un suggerimento
retry nelle opzioniConfigError alla costruzionesostituisce i default

jiraUpsertIssue si comporta come jiraUpdateIssue sulla ricerca e sull'aggiornamento (il 409 è transitorio solo per l'aggiornamento), e come jiraCreateIssue sulla creazione: mai ritentata, e dopo un esito incerto mai rifatta nella stessa run (vedi sopra).

Per jiraLinkIssues il catalogo dei tipi si legge prima dell'action, una volta per run, con lo stesso retry ma fuori dall'operazione: un catalogo che non si legge ferma il record, anche con "continua", come il catalogo dei campi dell'upsert. Il caso peggiore per un record è di 4 tentativi da 2 chiamate (i link della partenza e il POST), più il catalogo al primo record.

Il retry di default è quello raccomandato da Atlassian, lo stesso delle letture: Retry-After come attesa minima, backoff da 2 secondi con jitter, al più 4 tentativi, un tetto di 30 secondi su ogni attesa. request si valuta una volta prima del primo tentativo, e ogni retry rimanda lo stesso corpo. Si ritenta l'operazione intera, non la singola chiamata: per jiraTransition vuol dire rileggere lo stato, così un POST riuscito ma perso per strada (un 503 dopo aver scritto) diventa un noop al tentativo dopo invece di un secondo POST. Il log jira-action.retry porta metodo ed endpoint della chiamata fallita, senza la chiave. retry: { attempts: 1 } lo spegne; più di 4 tentativi è un ConfigError alla costruzione. Per jiraTransition il caso peggiore è di 4 tentativi da 3 chiamate, 12 chiamate per un record, tutte dentro il rateLimit del nodo. Per jiraAddComment ogni tentativo costa due chiamate ogni 100 commenti della issue più il POST, per jiraAddWorklog una ogni 1000 worklog più il POST: su una issue con molti commenti la lettura domina, e il rateLimit la rallenta.

Il rateLimit di default, DEFAULT_JIRA_WRITE_RATE_LIMIT, è 3 chiamate al secondo per nodo, ogni tentativo compreso, e conta anche le letture di un'action (lo stato e le transizioni di jiraTransition): Atlassian ammette al più 20 scritture ogni 2 secondi e 100 ogni 30 secondi sulla stessa issue, e le action girano una alla volta, quindi anche un grafo che aggiorna sempre la stessa issue resta sotto. Per un backfill su molte issue diverse si alza, restando sotto il burst di 50 scritture al secondo.

A valle di una source incrementale ​

Una jiraIssues incrementale rilegge a ogni run la finestra di sovrapposizione (2 minuti di default, vedi «Modalità incrementale»): un grafo «Jira → action» vede le stesse issue più volte, anche fra run consecutive riuscite. Le scritture a valle devono quindi essere idempotenti:

  • jiraUpdateIssue lo è, e con un hash evita anche la chiamata;
  • jiraTransition lo è: la seconda volta trova la issue già nello stato;
  • jiraUpsertIssue lo è: rileggere lo stesso record trova la issue e la aggiorna;
  • jiraAddComment e jiraAddWorklog lo sono, purché la chiave di deduplica dipenda solo dal record (non dall'ora della run): la seconda volta trovano la chiave e danno noop;
  • jiraLinkIssues lo è: la seconda volta trova il link e dà noop;
  • jiraCreateIssue no: a valle di una source incrementale creerebbe una issue per ogni rilettura. Lì serve jiraUpsertIssue, o una creazione protetta da un controllo a monte.

Lo stesso vale per una scrittura verso un altro sistema: deve riconoscere un record già scritto, per id della issue.

Descriptor delle scritture ​

kind: "jira-action", con l'istanza in connection e questi dettagli, in ordine:

LabelValore
sitol'origine del sito, con la variabile JIRA_<ID>_BASE_URL
gatewaysolo con un token con scope: il gateway con il cloud id
operazionecrea issue (POST /rest/api/3/issue), aggiorna issue (PUT /rest/api/3/issue/{issue}), transizione → In corso (POST /rest/api/3/issue/{issue}/transitions), upsert per ID esterno (…) con i tre endpoint, commento (POST /rest/api/3/issue/{issue}/comment), worklog (POST /rest/api/3/issue/{issue}/worklog), collega issue «Blocks» (POST /rest/api/3/issueLink); senza stato nelle opzioni transizione → stato dal record (…), per id transizione → stato 10001 (…)
su errorefallisci o continua
idempotenzanon idempotente: nessun retry, o idempotente: retry sui transitori, 409 compreso
limitele chiamate al secondo, 3 chiamate/s di default
ID esternosolo l'upsert: il campo com'è configurato, con la variabile JIRA_<ID>_EXTERNAL_ID_FIELD, o non impostato
creazionesolo l'upsert: mai ritentata: ripeterla creerebbe una seconda issue; per l'upsert la riga idempotenza vale per la ricerca e l'aggiornamento
deduplicasolo commenti e worklog: chiave nella proprietà noeva-etl.deduplica, cercata fra i commenti della issue prima di scrivere (o i worklog)
versosolo jiraLinkIssues: partenza → arrivo, nel verso outward del tipo; il verso opposto è un altro link
testo riccoi campi in cui una stringa è markdown
configurazionesolo nel piano, per un valore non valido
credenziale«token classico» o «token con scope», e lo stato

Mai il token, l'email, un header di autenticazione o i valori dei campi.

Limiti noti ​

  • Consistenza eventuale. La ricerca può non vedere subito le modifiche più recenti: di solito secondi, minuti dopo una modifica di massa. Per jiraUpsertIssue è il rischio residuo di una seconda creazione fra run diverse: vedi «Fra run diverse».
  • Niente cancellazioni. La JQL non vede le issue cancellate: una riga caricata resta nel sink. E jiraLinkIssues crea link, non li toglie.
  • Commenti, worklog e changelog non sono completi nella ricerca. Per averli tutti ci sono i lookup delle liste: jiraCommentsLookup, jiraWorklogsLookup, jiraChangelogLookup.
  • Commenti e worklog si leggono una issue alla volta, perché Jira non ha un endpoint in blocco: su migliaia di issue sono migliaia di chiamate, tutte in sequenza.
  • La chiave cambia quando una issue si sposta. jiraIssueLookup la trova anche con la chiave vecchia, ma restituisce quella nuova in key; se nello stesso lotto ci sono sia la vecchia sia la nuova, Jira restituisce la issue una volta e la vecchia resta «non trovato». Dove la chiave può invecchiare, conviene salvare e cercare l'id.
  • Limiti di Atlassian. Con un token valgono i limiti di burst (GET 100 al secondo, scritture 50 al secondo), quelli di scrittura per issue (20 ogni 2 secondi, 100 ogni 30 secondi) e un 429 con Retry-After; le quote orarie a punti valgono solo per le app Forge, Connect e OAuth.
  • Campi per id nelle scritture. jiraCreateIssue, jiraUpdateIssue, jiraTransition e jiraUpsertIssue non risolvono i nomi dei campi nel catalogo, come fa il source: customfield_10042, non "Cliente". Fa eccezione il solo campo dell'ID esterno, che si configura anche per nome.
  • Solo Jira Cloud, API v3. Data Center, Server, OAuth 2.0 (3LO) e allegati sono fuori.

La ricerca con le fonti su API, paginazione, date JQL e rate limit è in docs/research/2026-10-16-jira-cloud-api.md.

Noeva è un marchio registrato di 4D S.R.L.