Skip to content

Commit 40c0989

Browse files
committed
feat: jira.js/webhooks types what Jira posts to you
Everything else in this library calls Jira. A webhook is the other direction, and until now there was no way to say what arrives — which is all issue #294 ever asked for. Fifty-seven events as a union discriminated by `webhookEvent`, sixteen payload shapes behind it, and the headers Jira attaches. Types only: the subpath compiles to `export {}` and the browser bundle does not move. No parser and no signature check. A webhook body is shaped by the site that sent it — custom fields under generated keys, whatever an installed app adds — so a schema strict enough to be worth having would reject bodies that are valid elsewhere. Verifying `x-hub-signature` is the caller's, because a signature check that compares the wrong way quietly is worse than none and there is nothing here to test one against. How much of this Atlassian documents is written into the types. One complete payload is published, the one for issue events; that group is built from it and from a capture of a real delivery, and is the only one whose entity is required. Every other group names its entity optionally, after the entity the event concerns, and says so where you hover it. Headers are lower-cased, as Node delivers them, and every value is typed as the string an HTTP header is — the retry count included, which the issue typed as a number. check:consumers learns that a subpath can be types only: an empty runtime namespace is now the expected answer for jira.js/webhooks and a populated one is the failure. Its type probe imports both types under bundler and nodenext resolution.
1 parent d38bac6 commit 40c0989

15 files changed

Lines changed: 847 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,28 @@ Three long-standing requests, all of them the same shape: the client had no seam
7676

7777
OAuth 2.0 is absent from the config type on purpose: the API refuses it, as it refuses Forge apps, and a compile error says so earlier than a 401 would. A deleted team answers **410** rather than 404 on the next read — the id stays known and reports itself as gone.
7878

79+
* **`jira.js/webhooks` types what Jira posts to you.** Everything else in this library calls Jira; a webhook is the other direction, and until now there was no way to say what arrives. Fifty-seven events as a union discriminated by `webhookEvent`, sixteen payload shapes, and the headers Jira attaches. Closes [#294](https://github.com/MrRefactoring/jira.js/issues/294).
80+
81+
```ts
82+
import type { WebhookHeaders, WebhookPayload } from 'jira.js/webhooks';
83+
84+
app.post('/jira', (request, response) => {
85+
const payload = request.body as WebhookPayload;
86+
87+
switch (payload.webhookEvent) {
88+
case 'jira:issue_created':
89+
console.log(payload.issue.key);
90+
break;
91+
}
92+
93+
response.sendStatus(200);
94+
});
95+
```
96+
97+
Types only — the subpath compiles to `export {}` and adds nothing to a bundle. There is no parser and no signature check: a webhook body is shaped by the site that sent it, custom fields and installed apps included, so a schema strict enough to be worth having would reject bodies that are perfectly valid elsewhere.
98+
99+
How much is documented is worth stating plainly, because the types say it too. Atlassian publishes one complete payload, the one for issue events; that group is written from it and from a capture of a real delivery, and is the only one whose entity is required. Every other group names its entity optionally, after the entity the event concerns. The headers are lower-cased, as they arrive, and every value is typed as the string an HTTP header is — the retry count included.
100+
79101
* **Six more types and schemas are exported from `jira.js/core`.** `authBasicSchema`, `authBearerSchema` and `authOAuth2ServerSchema` beside the `authSchema` that was already there, and the types `CommonClientConfig`, `ParsedClientConfig` and `ErrorKind`. Each was already part of a public type's definition while being unreachable from outside the package — you could receive a `ClientConfig` but not name what it was built from.
80102

81103
* **`getTenantContext` resolves a site's `cloudId`, `orgId` and host name.** Atlassian publishes no REST endpoint for any of the three, and `orgId` in particular names the organization above your site rather than the site itself. The call goes to the GraphQL gateway, which is the documented way to ask, and it takes the client you already built, so it inherits its proxy, retries and custom `fetch`.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ The documentation includes:
141141
- **Jira Service Management Data Center API**: self-hosted requests, queues and organizations, from `createServiceDeskServerClient`
142142
- **Assets API**: objects, schemas, types and AQL — `createAssetsClient` on Cloud, `createAssetsServerClient` self-hosted
143143
- **Teams API**: teams, their members and external links, at organization level — `createTeamsClient`
144+
- **Webhook types**: the events, payloads and headers Jira posts to *you*`jira.js/webhooks`, types only, no client
144145

145146
There is one platform surface, generated from Jira's v3 specification. `Version2Client` and `Version3Client` are gone — the difference between them was never the endpoints, it was rich text. Rich-text fields still accept a wiki-markup **string**: that write is routed through Jira's v2 endpoint, which parses the markup server-side, and the result is read back so what you get is a real [Atlassian Document Format](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) document.
146147

context7.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"Replace the transport with `fetch` in the client configuration \u2014 `(url, init) => Promise<Response>` \u2014 to log, trace, proxy or record. It is used for the OAuth 2.0 token calls too, so a wrapper that logs request bodies will see `client_secret`. `headers` in the same configuration adds constant headers; note it overrides the `Authorization` the client derives from `auth`.",
3838
"An expired or revoked API token does not always fail the request: roughly a quarter of Jira operations can be reached anonymously, and there Jira answers 2xx as the anonymous user, reporting the refusal only in `X-Seraph-LoginReason`. Since 6.3 the client throws `AuthError` whenever that header says the credentials were refused, whatever the status, and `error.status` records the status that actually arrived.",
3939
"Teams are organization-level, not site-level, so `createTeamsClient` operations take an `orgId` parameter rather than reading it from the client configuration \u2014 one account can administer several organizations. Resolve it with `getTenantContext(client)` from `jira.js/core`, which returns `{ cloudId, orgId, hostName }`; Atlassian publishes no REST endpoint for any of the three. The Teams API refuses OAuth 2.0 and Forge apps, so its config type accepts only basic and bearer auth. `getTenantContext` is Cloud-only and throws `ConfigError` under OAuth 2.0 (3LO), where there is no fixed host to ask about.",
40+
"Webhooks go the other way \u2014 Jira posts to a server of yours \u2014 so `jira.js/webhooks` is types only and compiles to nothing: `WebhookPayload` is a union of fifty-seven events discriminated by `webhookEvent`, and `WebhookHeaders` types the headers in lower case, as Node delivers them, with every value a string including the retry count. Cast the parsed body and headers; there is no parser and no signature verification, because a webhook body is shaped by the site that sent it. Atlassian documents only the issue payload, so that is the one group whose entity is required \u2014 everywhere else the entity field is optional.",
4041
"Request parameter types live one level below the surface: `import type { CreateIssue } from 'jira.js/cloud/parameters'`. Response types come from the surface itself (`jira.js/cloud`). They are split because a parameter and a model occasionally share a name."
4142
],
4243
"url": "https://context7.com/mrrefactoring/jira.js",

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const guideSidebar = (prefix = '') => [
2323
{ text: 'Assets', link: `${prefix}/guide/assets` },
2424
{ text: 'Teams', link: `${prefix}/guide/teams` },
2525
{ text: prefix ? 'Контекст тенанта' : 'Tenant Context', link: `${prefix}/guide/tenant-context` },
26+
{ text: prefix ? 'Вебхуки' : 'Webhooks', link: `${prefix}/guide/webhooks` },
2627
{ text: prefix ? 'Обработка ошибок' : 'Error Handling', link: `${prefix}/guide/error-handling` },
2728
{ text: prefix ? 'Валидация ответов' : 'Response Validation', link: `${prefix}/guide/response-validation` },
2829
{ text: 'Tree-Shaking', link: `${prefix}/guide/tree-shaking` },

docs/guide/webhooks.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Webhooks
2+
3+
Everything else in this library calls Jira. A webhook is Jira calling you: you register a URL, something happens on the
4+
site, and a `POST` arrives at a server of yours. There is nothing here to call and no client to build — what was
5+
missing was the shape of what arrives, and that is what `jira.js/webhooks` is.
6+
7+
```typescript
8+
import type { WebhookHeaders, WebhookPayload } from 'jira.js/webhooks';
9+
10+
app.post('/jira', (request, response) => {
11+
const headers = request.headers as WebhookHeaders;
12+
const payload = request.body as WebhookPayload;
13+
14+
switch (payload.webhookEvent) {
15+
case 'jira:issue_created':
16+
console.log(payload.issue.key, 'created by', payload.user?.displayName);
17+
break;
18+
19+
case 'sprint_started':
20+
console.log(payload.sprint?.name, 'started');
21+
break;
22+
}
23+
24+
response.sendStatus(200);
25+
});
26+
```
27+
28+
The subpath is types only. It compiles to `export {}`, adds nothing to a bundle, and works the same with Express,
29+
Fastify, Hono, a Lambda handler or a bare `node:http` server.
30+
31+
## The payload
32+
33+
`WebhookPayload` is a union discriminated by `webhookEvent`, so a `switch` narrows each branch to exactly one payload.
34+
Handle every case and the `default` narrows to `never`, which is how you make an unhandled event a compile error:
35+
36+
```typescript
37+
default: {
38+
const unhandled: never = payload;
39+
40+
throw new Error(`unhandled webhook event: ${JSON.stringify(unhandled)}`);
41+
}
42+
```
43+
44+
Fifty-seven events across sixteen groups: issue, issue property, worklog, comment, attachment, issue link, issue type,
45+
project, version, filter, user, the site-wide `option_*` toggles, sprint, board, the two app-access refusals, and a
46+
failed Jira expression. Each group has its own exported type — `IssueWebhookPayload`, `SprintWebhookPayload` and so on
47+
— if you want to name one directly.
48+
49+
Every payload carries three things:
50+
51+
| | |
52+
|---|---|
53+
| `timestamp` | when Jira raised the event, in milliseconds since the epoch |
54+
| `webhookEvent` | the event, and the field to switch on |
55+
| `matchedWebhookIds` | which registrations this delivery answered — only on webhooks registered through the REST API |
56+
57+
## What is documented, and what is not
58+
59+
Worth knowing before you trust a field: **Atlassian publishes one complete payload**, the one for issue events. Of
60+
everything else it says only that a callback carries "information about the entity associated with the event".
61+
62+
So the issue payload here is written from that example and from a capture of a real delivery. It is the only one with
63+
a required entity:
64+
65+
```typescript
66+
case 'jira:issue_updated':
67+
payload.issue; // Issue — always there
68+
payload.issue_event_type_name; // 'issue_updated', 'issue_commented', 'issue_generic'…
69+
payload.changelog; // what changed
70+
payload.comment; // set when the update was someone commenting
71+
break;
72+
```
73+
74+
`issue_event_type_name` is finer-grained than `webhookEvent` — an edit, a comment and a transition all arrive as
75+
`jira:issue_updated` and are told apart only there. It is typed as a plain string on purpose: a site administrator can
76+
add issue events, so the set is not closed.
77+
78+
Every other group names its entity **optionally**, after the entity the event concerns rather than from a
79+
specification: `sprint` on a sprint event, `board` on a board event, `worklog`, `attachment`, `project`, `version`,
80+
`filter`. Nothing here could verify those against Atlassian's documentation, so the type makes you check, and the
81+
declaration says as much where you hover it.
82+
83+
## The headers
84+
85+
Lower-cased, because that is how they arrive — Node lower-cases every incoming header name and so does every framework
86+
built on it. Every value is a string, including the retry count: an HTTP header has no numbers in it.
87+
88+
| header | |
89+
|---|---|
90+
| `x-atlassian-webhook-identifier` | unique for this delivery within the site, and unchanged across retries — record it to recognise a webhook you have already handled |
91+
| `x-atlassian-webhook-flow` | `Primary` for the event itself, thirty seconds; `Secondary` for the fallout of a bulk or cascading change, fifteen minutes |
92+
| `x-atlassian-webhook-retry` | how many retries so far; absent on the first attempt |
93+
| `x-atlassian-webhook-trace` | whatever a Connect app attached to the request that caused the event |
94+
| `x-hub-signature` | `sha256=…`, present only on a webhook registered with a secret |
95+
96+
Deleting an issue is the clearest illustration of the flow header: `jira:issue_deleted` goes out as `Primary`, and
97+
every dependent `comment_deleted`, `attachment_deleted` and `issuelink_deleted` follows as `Secondary`, possibly
98+
minutes later.
99+
100+
## What this does not do
101+
102+
**It does not parse.** The casts above are the interface, deliberately. A webhook body is shaped by the site that sent
103+
it — custom fields under generated keys in `issue.fields`, whatever an installed app adds, a Data Center release that
104+
differs from Cloud — and a schema strict enough to be worth having would reject bodies that are perfectly valid
105+
somewhere else. Elsewhere in this library a response is validated because the API documents it; here there is nothing
106+
to validate against.
107+
108+
**It does not verify signatures.** `x-hub-signature` is the only thing that proves a request came from Jira rather
109+
than from whoever found your URL, and checking it is yours to do. This library does not, because a signature check
110+
that quietly compares the wrong way is worse than none, and there is nothing here to test one against.
111+
112+
## Registering one
113+
114+
Two ways, and they behave differently:
115+
116+
- **The admin page**, `https://your-domain.atlassian.net/plugins/servlet/webhooks`. What most people mean by a Jira
117+
webhook. Registered by a person, lives until someone removes it.
118+
- **The REST API**, `POST /rest/api/3/webhook` — Connect and OAuth 2.0 apps only, and the registration expires after
119+
thirty days unless `refreshWebhooks` extends it. These are the deliveries that carry `matchedWebhookIds`, and this
120+
library covers the endpoints: `jira.webhooks.registerDynamicWebhooks`, `getDynamicWebhooksForApp`,
121+
`refreshWebhooks`, `deleteWebhookById`.
122+
123+
Atlassian's own reference is
124+
[Webhooks](https://developer.atlassian.com/cloud/jira/platform/webhooks/) on the Jira Cloud platform.

0 commit comments

Comments
 (0)