Tema
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.
| Funzione | Tipo | Cosa fa |
|---|---|---|
jiraIssues | Source | Le issue di una JQL, pagina dopo pagina, in colonne |
jiraIssueLookup | LookupResolver | Le issue per chiave o per id, in un nodo lookup |
jiraChangelogLookup | LookupResolver | Il changelog completo di una issue, come lista |
jiraCommentsLookup | LookupResolver | Tutti i commenti di una issue, come lista |
jiraWorklogsLookup | LookupResolver | Tutti i worklog di una issue, come lista |
jiraCreateIssue | Action | Crea una issue e prosegue con id e key |
jiraUpdateIssue | Action | Aggiorna i campi di una issue, idempotente |
jiraTransition | Action | Porta una issue in uno stato, idempotente |
jiraUpsertIssue | Action | Crea o aggiorna una issue per ID esterno |
jiraAddComment | Action | Aggiunge un commento, senza doppioni fra le run |
jiraAddWorklog | Action | Registra un worklog, senza doppioni fra le run |
jiraLinkIssues | Action | Collega due issue, senza duplicare il link |
adfInTesto | utilità | Un documento ADF in testo semplice |
markdownInAdf | utilità | Un testo in markdown in un documento ADF |
loadJiraRegistry | registry | I siti configurati, per istanza |
loadJiraConfig | config | Un solo sito, da variabili con prefisso (JIRA_) |
loadJiraInstance | config | Un solo sito del registry, senza gli altri |
istanzaJiraPerIlPiano | piano | Il sito per il piano, con la sua ricaduta |
istanzaJiraDichiarata | env check | Il sito da dichiarare in environment(env) |
trasportoJira | config | La config del trasporto http per un sito Jira |
MANIFEST_JIRA | config | Le variabili di un sito, per il piano |
dettaglioCredenzialeJira | descriptor | La 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.
| Variabile | Default | Note |
|---|---|---|
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_MS | 30000 | tempo 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 gatewayhttps://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();| Opzione | Default | Note |
|---|---|---|
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é |
raw | false | aggiunge raw, i campi come li manda Jira, per id |
incremental | — | accende la modalità incrementale: vedi sotto |
pageSize | 100 | issue per pagina, fra 1 e 5000: Jira la tratta come un tetto |
retry | vedi Errori | retry di una lettura sugli errori transitori |
name | jira-issues | nome del source, negli errori e nei log |
Il record: una colonna per campo
Un record per issue, appiattito. Quattro colonne ci sono sempre:
| Colonna | Cosa contiene |
|---|---|
id | l'id numerico, come stringa: è la chiave stabile, non cambia se la issue si sposta |
key | la chiave leggibile, ABC-42: cambia se la issue cambia progetto |
self | l'URL API della issue |
updated | l'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
statusnon nasconde il campo di sistema; - un nome che non esiste è un
ConfigErrorche lo nomina, e se esiste con altre maiuscole lo suggerisce; - un nome portato da due campi è un
ConfigErrorche 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 Jira | Colonna |
|---|---|
| utente (assegnatario, autore…) | { accountId, displayName }, mai l'email |
| lista di utenti | lista 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, resolution | il nome |
| date | il giorno, 2026-10-20 |
| datetime | ISO 8601 in UTC, 2026-10-16T15:54:22.485Z |
| testo lungo in ADF (descrizione…) | testo semplice, vedi sotto |
| number, string, liste di stringhe | come 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
-e1.(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
nextPageTokenfino aisLast, 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
pageSizenon chiude la lettura. Jira restituisce meno issue del richiesto quando i campi pesano: la fine la dice soloisLast. - 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 soloid. Un jolly (*all,*navigable) o un'esclusione (-summary) sono unConfigError: 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
});| Opzione | Default | Note |
|---|---|---|
incremental.overlapMs | 120000 (2 minuti) | millisecondi riletti a ogni run; 0 = nessuno, sconsigliato: può perdere issue |
incremental.initial | 1970-01-01T00:00:00.000Z | il watermark della prima run, ISO 8601; senza fuso è UTC |
Come funziona:
- all'avvio il runner legge il watermark salvato (o
initialalla prima run) e ne toglie la sovrapposizione; - 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; - ogni issue segnala il suo
updated, e il runner ne tiene il massimo; - 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();| Opzione | Default | Note |
|---|---|---|
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é |
raw | false | aggiunge raw, i campi come li manda Jira, per id |
retry | vedi Errori | retry di ogni chiamata sugli errori transitori, come per il source |
name | jira-issue-lookup | nome 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. ConbatchSize: 100un round-trip del nodo è una chiamata sola; ilrateLimitdel 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
onNotFounddel 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-42eABC-42trovanoABC-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.
| Opzione | Default | Note |
|---|---|---|
config | — | il sito, da loadJiraRegistry().get(id) |
instanceId | — | l'id dell'istanza, mostrato nel DAG |
fieldIds | tutti | solo changelog: al più 10 id di campo, ["status"] |
raw | false | solo commenti e worklog: la voce come la manda Jira |
retry | vedi Errori | retry di ogni chiamata sugli errori transitori |
name | jira-changelog-lookup, jira-comments-lookup, jira-worklogs-lookup | nome 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.
| Lista | Campi della voce |
|---|---|
| changelog | id, data, autore, campo, campoId, da, a, daId, aId |
| commenti | id, autore, testo (il corpo da ADF a testo), creato, aggiornato, raw? |
| worklog | id, 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
ide la stessadata. La chiave di una voce è quindiidpiùcampo.daeasono i valori leggibili (In corso),daIdeaIdgli 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 conadfInTesto; conraw: trueil commento grezzo ha anche l'ADF. - Worklog:
secondiè il tempo registrato come numero, non il testo1h 30m.testoè il commento del worklog, oppurenullse 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/bulkfetcha lotti di 1000 issue, il massimo di Jira, seguendonextPageTokenfino all'ultima pagina. Una issue può tornare in più pagine, e le sue voci si accodano nell'ordine di arrivo.fieldIdsfiltra 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}/commenta pagine di 100,…/workloga pagine di 1000. SeguestartAtfinché una pagina è vuota o arriva atotal. 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 dallostartAtchiesto 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
batchSizedi 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. IlrateLimitdel 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
retrydel nodo non serve. - I lookup leggono soltanto: rieseguire la run non scrive niente su Jira.
Errori
| Caso | Errore | Si ritenta |
|---|---|---|
JQL vuota, fields vuoti, jolly, pageSize fuori range | ConfigError alla costruzione | — |
Un campo chiamato id, key, self o raw | ConfigError alla costruzione | — |
Incrementale: ORDER BY, overlapMs o initial non validi | ConfigError alla costruzione | — |
| Incrementale eseguito fuori dal runner, senza watermark | ExtractError prima di leggere | no |
| Un nome di campo che non esiste o che è ambiguo | ConfigError prima della ricerca | — |
| Variabili mancanti o non valide | ConfigError al caricamento | — |
| 429, 502, 503, 504, timeout, rete | ExtractError transitorio | sì |
| 400 (JQL o campi non validi) | ExtractError | no |
| Un valore che non ha la forma del suo tipo | ExtractError | no |
| 401 (credenziali rifiutate) | ExtractError | no |
| 403 (permessi insufficienti) | ExtractError | no |
| 404 (API non trovata) | ExtractError | no |
| Risposta oltre il tetto di byte, non JSON o fuori contratto | ExtractError | no |
isLast: false senza nextPageToken, o un token ripetuto | ExtractError | no |
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.
| Caso | Errore | Si ritenta |
|---|---|---|
fields vuoti, jolly, un campo id/key/self/raw | ConfigError alla costruzione | — |
| Un nome di campo che non esiste o che è ambiguo | ConfigError prima del bulkfetch | — |
| Una chiave che non esiste o che non si può vedere | nessuno: «non trovato» | — |
| 429, 502, 503, 504, timeout, rete | LookupError transitorio | sì |
issueErrors nella risposta (issue non restituite da Jira) | LookupError transitorio | sì |
| 400, 401, 403, 404 | LookupError con un suggerimento | no |
| Risposta oltre il tetto di byte, non JSON o fuori contratto | LookupError | no |
| Un valore che non ha la forma del suo tipo | LookupError che nomina la colonna | no |
| Annullamento della run | si 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.
| Caso | Errore | Si ritenta |
|---|---|---|
fieldIds con più di 10 campi, doppioni o un campo vuoto | ConfigError alla costruzione | — |
| Una issue che non esiste o che non si può vedere | nessuno: «non trovato» | — |
| Una issue senza voci | nessuno: lista vuota | — |
| 429, 502, 503, 504, timeout, rete | LookupError transitorio | sì |
| 400, 401, 403, 404 del bulkfetch | LookupError con un suggerimento | no |
| Commenti o worklog: 404 su una issue appena trovata | LookupError che dice perché | no |
Una pagina che non parte dallo startAt chiesto | LookupError | no |
Una voce senza id, una data che non è una data, un field | LookupError che nomina il campo | no |
nextPageToken ripetuto (changelog) | LookupError | no |
| Risposta oltre il tetto di byte, non JSON o fuori contratto | LookupError | no |
| Annullamento della run | si 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:
| Label | Valore |
|---|---|
sito | l'origine del sito, con la variabile JIRA_<ID>_BASE_URL |
gateway | solo con un token con scope: il gateway con il cloud id |
jql | la JQL su una riga, troncata a 200 caratteri |
campi | i campi com'è dichiarati, per id o per nome, updated compreso |
raw | solo con raw: true: sì |
watermark | solo in incrementale: updated (timestamp), sovrapposizione 2 min |
configurazione | solo 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();| Opzione | Default | Note |
|---|---|---|
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 |
retry | vedi «Retry» | al più 4 tentativi; su jiraCreateIssue è un ConfigError |
name | jira-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 richiesta | Che cosa fa |
|---|---|
un campo in fields | lo sostituisce, multivalore compresi: labels: ["a"] lascia la sola a |
un campo assente o undefined | non lo tocca |
un campo null | lo 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 opzioni | Che cosa fa |
|---|---|
stato nelle opzioni | lo stato di arrivo di ogni record; è quello che mostra il descriptor |
stato nel record | sostituisce quello delle opzioni; senza nessuno dei due è un errore del record |
fields | i campi della schermata della transizione, per id; undefined non si manda |
testoRicco nelle opzioni | i campi custom della schermata in cui una stringa è markdown, come negli update |
Che cosa fa per ogni record:
GET /rest/api/3/issue/{issue}?fields=status: se la issue è già nello stato, l'esito ènoope non parte altro. Lo stato attuale non si ricava dalle transizioni: quelle globali portano anche allo stato in cui la issue è già;GET /rest/api/3/issue/{issue}/transitions?expand=transitions.fields: le transizioni disponibili adesso, con i campi delle loro schermate;- 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);
POST /rest/api/3/issue/{issue}/transitionscon la transizione e ifields.
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
noopnon 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'ID | Che cosa fa | Esito |
|---|---|---|
| 0 | crea, con soloInCreazione, fields, add e l'ID esterno nel suo campo | creata |
| 1 | aggiorna, con la semantica di jiraUpdateIssue: fields, add, remove, hash | aggiornata o noop |
| più di una | niente: non sa quale scegliere | ok: 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 richiesta | Che cosa fa |
|---|---|
idEsterno | l'identificativo del record, senza gli spazi intorno: si cerca e, in creazione, si scrive |
fields | in creazione e in aggiornamento, come in jiraUpdateIssue |
soloInCreazione | solo in creazione: project, issuetype, e quanto non va toccato su una issue che c'è già |
add | in creazione è il valore del campo; in aggiornamento si aggiunge |
remove, hash | solo 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 opzioni | Che cosa fa |
|---|---|
deduplica nelle opzioni | la chiave del record, senza gli spazi intorno; vuota, non una stringa o oltre 255 caratteri è un errore |
issue | la 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 soloexpand=renderedBody): per ogni pagina il nodo le chiede conPOST /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 conisLast: 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 (
startAtignorato) ferma il record, perché la lettura girerebbe in tondo. Questi due errori sonoActionErrornon 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 opzioni | Che cosa fa |
|---|---|
tipo nelle opzioni | il nome del tipo di link (Blocks, Relates, Duplicate…); vuoto è un ConfigError |
partenza | la issue da cui il link esce, per chiave o per id: con Blocks, «partenza blocks arrivo» |
arrivo | la issue a cui il link arriva: «arrivo is blocked by partenza» |
Che cosa fa:
- 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'è è unConfigErrorche lo nomina, con i tipi disponibili: ferma il record anche cononError: "continua", perché è configurazione, e resta per tutta la run. Una lettura del catalogo che fallisce dopo i retry è unActionErrorche ferma il record, e il record dopo la rifà. Il catalogo non conta nelrateLimitdel nodo, e il suo retry si vede nel log comejira-lookup.retry; quello dell'operazione comejira-action.retry; - 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 ènoope non parte altro; - 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.
Il verso del link
Jira chiama i due capi in due modi che si incrociano, ed è il punto da non sbagliare:
| Dove | La partenza («blocks») | L'arrivo («is blocked by») |
|---|---|---|
corpo di POST /issueLink | inwardIssue | outwardIssue |
issuelinks della partenza | — (è la issue letta) | outwardIssue |
issuelinks dell'arrivo | inwardIssue | — (è 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'è; nullrestanull, e svuota il campo;- lo stesso testo dà sempre lo stesso ADF: gli id delle liste di task, che
marklassiangenera 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
}onError | Cosa 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
| Caso | jiraCreateIssue | jiraUpdateIssue, jiraTransition, jiraAddComment, jiraAddWorklog, jiraLinkIssues |
|---|---|---|
| 429, 502, 503, 504, timeout, rete | ActionError, nessun retry | si ritenta l'operazione intera |
| 409 (aggiornamento in conflitto) | ActionError, nessun retry | si ritenta: è transitorio |
| 400 (campi rifiutati) | ActionError, campi nomati | ActionError, campi nomati |
| 401, 403, 404 | ActionError, con un suggerimento | ActionError, 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 opzioni | ConfigError alla costruzione | sostituisce 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:
jiraUpdateIssuelo è, e con un hash evita anche la chiamata;jiraTransitionlo è: la seconda volta trova la issue già nello stato;jiraUpsertIssuelo è: rileggere lo stesso record trova la issue e la aggiorna;jiraAddCommentejiraAddWorkloglo sono, purché la chiave di deduplica dipenda solo dal record (non dall'ora della run): la seconda volta trovano la chiave e dannonoop;jiraLinkIssueslo è: la seconda volta trova il link e dànoop;jiraCreateIssueno: a valle di una source incrementale creerebbe una issue per ogni rilettura. Lì servejiraUpsertIssue, 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:
| Label | Valore |
|---|---|
sito | l'origine del sito, con la variabile JIRA_<ID>_BASE_URL |
gateway | solo con un token con scope: il gateway con il cloud id |
operazione | crea 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 errore | fallisci o continua |
idempotenza | non idempotente: nessun retry, o idempotente: retry sui transitori, 409 compreso |
limite | le chiamate al secondo, 3 chiamate/s di default |
ID esterno | solo l'upsert: il campo com'è configurato, con la variabile JIRA_<ID>_EXTERNAL_ID_FIELD, o non impostato |
creazione | solo l'upsert: mai ritentata: ripeterla creerebbe una seconda issue; per l'upsert la riga idempotenza vale per la ricerca e l'aggiornamento |
deduplica | solo commenti e worklog: chiave nella proprietà noeva-etl.deduplica, cercata fra i commenti della issue prima di scrivere (o i worklog) |
verso | solo jiraLinkIssues: partenza → arrivo, nel verso outward del tipo; il verso opposto è un altro link |
testo ricco | i campi in cui una stringa è markdown |
configurazione | solo 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
jiraLinkIssuescrea 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.
jiraIssueLookupla trova anche con la chiave vecchia, ma restituisce quella nuova inkey; 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,jiraTransitionejiraUpsertIssuenon 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.

