From 94dbdcc5164cfef4f8423c1606429b705ba34b10 Mon Sep 17 00:00:00 2001 From: yashodhah Date: Mon, 19 Oct 2026 20:34:29 +0800 Subject: [PATCH 1/9] chore(jira): record documented assumptions and fixtures Co-Authored-By: Claude Sonnet 5.5 --- packages/jira/ASSUMPTIONS.md | 31 ++++++++ .../test/fixtures/comment-created-by-bot.json | 59 +++++++++++++++ .../comment-created-mention-nested.json | 73 +++++++++++++++++++ .../fixtures/comment-created-mention.json | 59 +++++++++++++++ .../test/fixtures/comment-created-plain.json | 47 ++++++++++++ .../jira/test/fixtures/comment-updated.json | 59 +++++++++++++++ 6 files changed, 328 insertions(+) create mode 100644 packages/jira/ASSUMPTIONS.md create mode 100644 packages/jira/test/fixtures/comment-created-by-bot.json create mode 100644 packages/jira/test/fixtures/comment-created-mention-nested.json create mode 100644 packages/jira/test/fixtures/comment-created-mention.json create mode 100644 packages/jira/test/fixtures/comment-created-plain.json create mode 100644 packages/jira/test/fixtures/comment-updated.json diff --git a/packages/jira/ASSUMPTIONS.md b/packages/jira/ASSUMPTIONS.md new file mode 100644 index 0000000..543f948 --- /dev/null +++ b/packages/jira/ASSUMPTIONS.md @@ -0,0 +1,31 @@ +# Jira channel assumptions + +No live Jira site was used. Every item is unverified against a real site. `documented` = stated in Atlassian docs (URL given); `assumed` = inferred, confirm before relying on it. + +## (a) Signature +- Header `X-Hub-Signature`, value `method=signature`, e.g. `sha256=`; HMAC with the hash named by `method`, keyed by the webhook secret. Status: documented. https://developer.atlassian.com/cloud/jira/platform/webhooks/ +- Docs examples use sha256. Assumed: HMAC input is the raw request body bytes, hex lowercase. Status: assumed (raw body not stated explicitly in what was read). +- Accept only `sha256` as method; reject others. + +## (b) comment_created payload +- Fields: `webhookEvent` ("comment_created" / "comment_updated"), `timestamp` (epoch ms), `comment`, `issue`. Status: documented generically (page has no full example; comment object matches the REST comment shape). Exact `comment.*`/`issue.fields.summary` layout: assumed from REST API v3. +- `comment.body` is ADF (object, `version: 1`, `type: "doc"`) for Jira Cloud REST v3-style payloads. Status: assumed. Parser should also tolerate a plain string body (treat as text, no mentions). +- `comment.author` has `accountId`, `displayName`, `accountType` (`app` for bots, assumed). + +## (c) ADF mention node +- `{ "type": "mention", "attrs": { "id": "", "text": "@name", "accessLevel": "" } }` inline in a paragraph. Status: documented in ADF spec https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/mention/ (not re-fetched in this task; recalled). Mentions can occur nested in blockquote, list items, etc.; walk the tree recursively. +- The text after the mention (` /research`) is a separate `text` node in the same paragraph. + +## (d) Cloud id +- Payload carries no cloud id. Status: assumed (docs show none). The `site` option is the installation id supplied by configuration. + +## (e) Stable event id +- Documented: every webhook carries `X-Atlassian-Webhook-Identifier`, unique per tenant and constant across retries; `X-Atlassian-Webhook-Retry` is present on retries; `X-Atlassian-Webhook-Flow` is Primary/Secondary. Source: same webhooks URL. +- Plan: use the identifier header when present, else fall back to `comment.id` (plus `webhookEvent`). Note `comment_updated` for the same comment must not collide with its created event. + +## (f) REST calls +- `GET /rest/api/3/myself` returns `accountId`, `displayName`. Status: documented (Jira Cloud REST v3), not re-fetched here. +- `POST /rest/api/3/issue/{key}/comment` with body `{ "body": }`, one `paragraph` node per block; returns the created comment with `id`. Status: documented (REST v3), not re-fetched here. Basic auth (email + API token) assumed for the bot. + +## Fixtures +`test/fixtures/*.json` are hand-written. Bot: accountId `5b10ac8d82e05b22cc7d4ef5`, name `slopcodebot`. Human author: `557058a1b2c3d4e5f6a7b8c9` (fictional). Issue id `10042`, key `ABC-7`, summary `Add rate limiting`. Site `example.atlassian.net` is a placeholder. diff --git a/packages/jira/test/fixtures/comment-created-by-bot.json b/packages/jira/test/fixtures/comment-created-by-bot.json new file mode 100644 index 0000000..eed8339 --- /dev/null +++ b/packages/jira/test/fixtures/comment-created-by-bot.json @@ -0,0 +1,59 @@ +{ + "timestamp": 1792404930000, + "webhookEvent": "comment_created", + "comment": { + "self": "https://example.atlassian.net/rest/api/3/issue/10042/comment/10102", + "id": "10102", + "author": { + "self": "https://example.atlassian.net/rest/api/3/user?accountId=5b10ac8d82e05b22cc7d4ef5", + "accountId": "5b10ac8d82e05b22cc7d4ef5", + "displayName": "slopcodebot", + "accountType": "app", + "active": true + }, + "body": { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "mention", + "attrs": { + "id": "5b10ac8d82e05b22cc7d4ef5", + "text": "@slopcodebot", + "accessLevel": "" + } + }, + { + "type": "text", + "text": " /research" + }, + { + "type": "text", + "text": " (echo from the bot itself)" + } + ] + } + ] + }, + "updateAuthor": { + "self": "https://example.atlassian.net/rest/api/3/user?accountId=5b10ac8d82e05b22cc7d4ef5", + "accountId": "5b10ac8d82e05b22cc7d4ef5", + "displayName": "slopcodebot", + "accountType": "app", + "active": true + }, + "created": "2026-10-19T10:15:30.000+0000", + "updated": "2026-10-19T10:15:30.000+0000" + }, + "issue": { + "id": "10042", + "key": "ABC-7", + "self": "https://example.atlassian.net/rest/api/3/issue/10042", + "fields": { + "summary": "Add rate limiting" + } + } +} diff --git a/packages/jira/test/fixtures/comment-created-mention-nested.json b/packages/jira/test/fixtures/comment-created-mention-nested.json new file mode 100644 index 0000000..baeab5c --- /dev/null +++ b/packages/jira/test/fixtures/comment-created-mention-nested.json @@ -0,0 +1,73 @@ +{ + "timestamp": 1792404930000, + "webhookEvent": "comment_created", + "comment": { + "self": "https://example.atlassian.net/rest/api/3/issue/10042/comment/10103", + "id": "10103", + "author": { + "self": "https://example.atlassian.net/rest/api/3/user?accountId=557058a1b2c3d4e5f6a7b8c9", + "accountId": "557058a1b2c3d4e5f6a7b8c9", + "displayName": "Alex Example", + "accountType": "atlassian", + "active": true + }, + "body": { + "version": 1, + "type": "doc", + "content": [ + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "mention", + "attrs": { + "id": "5b10ac8d82e05b22cc7d4ef5", + "text": "@slopcodebot", + "accessLevel": "" + } + }, + { + "type": "text", + "text": " /research" + }, + { + "type": "text", + "text": " quoted" + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "see above" + } + ] + } + ] + }, + "updateAuthor": { + "self": "https://example.atlassian.net/rest/api/3/user?accountId=557058a1b2c3d4e5f6a7b8c9", + "accountId": "557058a1b2c3d4e5f6a7b8c9", + "displayName": "Alex Example", + "accountType": "atlassian", + "active": true + }, + "created": "2026-10-19T10:15:30.000+0000", + "updated": "2026-10-19T10:15:30.000+0000" + }, + "issue": { + "id": "10042", + "key": "ABC-7", + "self": "https://example.atlassian.net/rest/api/3/issue/10042", + "fields": { + "summary": "Add rate limiting" + } + } +} diff --git a/packages/jira/test/fixtures/comment-created-mention.json b/packages/jira/test/fixtures/comment-created-mention.json new file mode 100644 index 0000000..72f412a --- /dev/null +++ b/packages/jira/test/fixtures/comment-created-mention.json @@ -0,0 +1,59 @@ +{ + "timestamp": 1792404930000, + "webhookEvent": "comment_created", + "comment": { + "self": "https://example.atlassian.net/rest/api/3/issue/10042/comment/10100", + "id": "10100", + "author": { + "self": "https://example.atlassian.net/rest/api/3/user?accountId=557058a1b2c3d4e5f6a7b8c9", + "accountId": "557058a1b2c3d4e5f6a7b8c9", + "displayName": "Alex Example", + "accountType": "atlassian", + "active": true + }, + "body": { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "mention", + "attrs": { + "id": "5b10ac8d82e05b22cc7d4ef5", + "text": "@slopcodebot", + "accessLevel": "" + } + }, + { + "type": "text", + "text": " /research" + }, + { + "type": "text", + "text": " how should we do this?" + } + ] + } + ] + }, + "updateAuthor": { + "self": "https://example.atlassian.net/rest/api/3/user?accountId=557058a1b2c3d4e5f6a7b8c9", + "accountId": "557058a1b2c3d4e5f6a7b8c9", + "displayName": "Alex Example", + "accountType": "atlassian", + "active": true + }, + "created": "2026-10-19T10:15:30.000+0000", + "updated": "2026-10-19T10:15:30.000+0000" + }, + "issue": { + "id": "10042", + "key": "ABC-7", + "self": "https://example.atlassian.net/rest/api/3/issue/10042", + "fields": { + "summary": "Add rate limiting" + } + } +} diff --git a/packages/jira/test/fixtures/comment-created-plain.json b/packages/jira/test/fixtures/comment-created-plain.json new file mode 100644 index 0000000..8f88aeb --- /dev/null +++ b/packages/jira/test/fixtures/comment-created-plain.json @@ -0,0 +1,47 @@ +{ + "timestamp": 1792404930000, + "webhookEvent": "comment_created", + "comment": { + "self": "https://example.atlassian.net/rest/api/3/issue/10042/comment/10101", + "id": "10101", + "author": { + "self": "https://example.atlassian.net/rest/api/3/user?accountId=557058a1b2c3d4e5f6a7b8c9", + "accountId": "557058a1b2c3d4e5f6a7b8c9", + "displayName": "Alex Example", + "accountType": "atlassian", + "active": true + }, + "body": { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Looks good to me." + } + ] + } + ] + }, + "updateAuthor": { + "self": "https://example.atlassian.net/rest/api/3/user?accountId=557058a1b2c3d4e5f6a7b8c9", + "accountId": "557058a1b2c3d4e5f6a7b8c9", + "displayName": "Alex Example", + "accountType": "atlassian", + "active": true + }, + "created": "2026-10-19T10:15:30.000+0000", + "updated": "2026-10-19T10:15:30.000+0000" + }, + "issue": { + "id": "10042", + "key": "ABC-7", + "self": "https://example.atlassian.net/rest/api/3/issue/10042", + "fields": { + "summary": "Add rate limiting" + } + } +} diff --git a/packages/jira/test/fixtures/comment-updated.json b/packages/jira/test/fixtures/comment-updated.json new file mode 100644 index 0000000..62cddc3 --- /dev/null +++ b/packages/jira/test/fixtures/comment-updated.json @@ -0,0 +1,59 @@ +{ + "timestamp": 1792404930000, + "webhookEvent": "comment_updated", + "comment": { + "self": "https://example.atlassian.net/rest/api/3/issue/10042/comment/10100", + "id": "10100", + "author": { + "self": "https://example.atlassian.net/rest/api/3/user?accountId=557058a1b2c3d4e5f6a7b8c9", + "accountId": "557058a1b2c3d4e5f6a7b8c9", + "displayName": "Alex Example", + "accountType": "atlassian", + "active": true + }, + "body": { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "mention", + "attrs": { + "id": "5b10ac8d82e05b22cc7d4ef5", + "text": "@slopcodebot", + "accessLevel": "" + } + }, + { + "type": "text", + "text": " /research" + }, + { + "type": "text", + "text": " edited text" + } + ] + } + ] + }, + "updateAuthor": { + "self": "https://example.atlassian.net/rest/api/3/user?accountId=557058a1b2c3d4e5f6a7b8c9", + "accountId": "557058a1b2c3d4e5f6a7b8c9", + "displayName": "Alex Example", + "accountType": "atlassian", + "active": true + }, + "created": "2026-10-19T10:15:30.000+0000", + "updated": "2026-10-19T10:20:00.000+0000" + }, + "issue": { + "id": "10042", + "key": "ABC-7", + "self": "https://example.atlassian.net/rest/api/3/issue/10042", + "fields": { + "summary": "Add rate limiting" + } + } +} From 111ac41d3e868135ca7f544fdc6a14c2d0ceee5e Mon Sep 17 00:00:00 2001 From: yashodhah Date: Mon, 19 Oct 2026 20:37:53 +0800 Subject: [PATCH 2/9] feat(jira): webhook provider with signature verification Co-Authored-By: Claude Sonnet 5.5 --- bun.lock | 18 ++++ packages/jira/package.json | 27 +++++ packages/jira/src/JiraIdentity.ts | 10 ++ packages/jira/src/JiraWebhookProvider.ts | 55 +++++++++++ packages/jira/src/JiraWebhookSchemas.ts | 40 ++++++++ packages/jira/src/JiraWebhookSignature.ts | 68 +++++++++++++ packages/jira/src/index.ts | 4 + packages/jira/test/fixtures.ts | 28 ++++++ .../jira/test/jira-webhook-routing.vi.test.ts | 99 +++++++++++++++++++ .../test/jira-webhook-signature.vi.test.ts | 42 ++++++++ packages/jira/tsconfig.json | 4 + scripts/release/manifest.ts | 11 ++- 12 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 packages/jira/package.json create mode 100644 packages/jira/src/JiraIdentity.ts create mode 100644 packages/jira/src/JiraWebhookProvider.ts create mode 100644 packages/jira/src/JiraWebhookSchemas.ts create mode 100644 packages/jira/src/JiraWebhookSignature.ts create mode 100644 packages/jira/src/index.ts create mode 100644 packages/jira/test/fixtures.ts create mode 100644 packages/jira/test/jira-webhook-routing.vi.test.ts create mode 100644 packages/jira/test/jira-webhook-signature.vi.test.ts create mode 100644 packages/jira/tsconfig.json diff --git a/bun.lock b/bun.lock index de83528..154abc8 100644 --- a/bun.lock +++ b/bun.lock @@ -97,6 +97,22 @@ "effect": "catalog:", }, }, + "packages/jira": { + "name": "@humanlayer/channels-jira", + "version": "0.0.0", + "dependencies": { + "@humanlayer/channels-delivery": "workspace:*", + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "effect": "catalog:", + "typescript": "catalog:", + }, + "peerDependencies": { + "effect": "catalog:", + }, + }, "packages/linear": { "name": "@humanlayer/channels-linear", "version": "0.0.0", @@ -379,6 +395,8 @@ "@humanlayer/channels-github": ["@humanlayer/channels-github@workspace:packages/github"], + "@humanlayer/channels-jira": ["@humanlayer/channels-jira@workspace:packages/jira"], + "@humanlayer/channels-linear": ["@humanlayer/channels-linear@workspace:packages/linear"], "@humanlayer/channels-redis": ["@humanlayer/channels-redis@workspace:packages/redis"], diff --git a/packages/jira/package.json b/packages/jira/package.json new file mode 100644 index 0000000..4aab97e --- /dev/null +++ b/packages/jira/package.json @@ -0,0 +1,27 @@ +{ + "name": "@humanlayer/channels-jira", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": false, + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@humanlayer/channels-delivery": "workspace:*" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "effect": "catalog:", + "typescript": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" + } +} diff --git a/packages/jira/src/JiraIdentity.ts b/packages/jira/src/JiraIdentity.ts new file mode 100644 index 0000000..02dfd84 --- /dev/null +++ b/packages/jira/src/JiraIdentity.ts @@ -0,0 +1,10 @@ +import { Schema } from 'effect' + +export const JiraAccountId = Schema.NonEmptyString.pipe(Schema.brand('JiraAccountId')) +export type JiraAccountId = typeof JiraAccountId.Type + +export const JiraIssueId = Schema.NonEmptyString.pipe(Schema.brand('JiraIssueId')) +export type JiraIssueId = typeof JiraIssueId.Type + +export const jiraIssueResourceId = (input: { readonly issueId: JiraIssueId }) => + ['jira', 'v1', 'issue', input.issueId].map(encodeURIComponent).join(':') diff --git a/packages/jira/src/JiraWebhookProvider.ts b/packages/jira/src/JiraWebhookProvider.ts new file mode 100644 index 0000000..4fcd52c --- /dev/null +++ b/packages/jira/src/JiraWebhookProvider.ts @@ -0,0 +1,55 @@ +import { + DeliveryAdmission, + ProviderWebhookEvent, + ProviderWebhookIgnored, + WebhookAuthenticationError, + WebhookPayloadInvalidError, + type WebhookProvider, +} from '@humanlayer/channels-delivery' +import { Crypto, Effect, Redacted, Schema } from 'effect' + +import { jiraIssueResourceId } from './JiraIdentity' +import { JiraCommentCreatedWebhook, JiraWebhookEnvelope, JiraWebhookHeaders } from './JiraWebhookSchemas' +import { verifyJiraWebhookSignature } from './JiraWebhookSignature' + +export type JiraWebhookProviderOptions = { + readonly namespace: string + readonly webhookSecret: Redacted.Redacted + /** Jira site host used as the installation id; the payload carries no cloud id. */ + readonly site: string +} + +export const makeJiraWebhookProvider = (options: JiraWebhookProviderOptions): WebhookProvider => ({ + providerName: 'jira', + handle: (input) => + Effect.gen(function* () { + const headers = yield* Schema.decodeUnknownEffect(JiraWebhookHeaders)(input.headers).pipe( + Effect.mapError(() => WebhookAuthenticationError.make({ reason: 'invalid_signature_headers' })), + ) + yield* verifyJiraWebhookSignature({ + body: input.body, + signature: headers['x-hub-signature'], + webhookSecret: options.webhookSecret, + }) + const unknownPayload = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + new TextDecoder().decode(input.body), + ).pipe(Effect.mapError(() => WebhookPayloadInvalidError.make({ reason: 'invalid_json' }))) + const envelope = yield* Schema.decodeUnknownEffect(JiraWebhookEnvelope)(unknownPayload).pipe( + Effect.mapError(() => WebhookPayloadInvalidError.make({ reason: 'invalid_event_envelope' })), + ) + if (envelope.webhookEvent !== 'comment_created') return ProviderWebhookIgnored.make({}) + const webhook = yield* Schema.decodeUnknownEffect(JiraCommentCreatedWebhook)(unknownPayload).pipe( + Effect.mapError(() => WebhookPayloadInvalidError.make({ reason: 'invalid_comment_created' })), + ) + return ProviderWebhookEvent.make({ + event: DeliveryAdmission.make({ + namespace: options.namespace, + provider: 'jira', + installationId: options.site, + resourceId: jiraIssueResourceId({ issueId: webhook.issue.id }), + eventId: headers['x-atlassian-webhook-identifier'] ?? webhook.comment.id, + payload: webhook, + }), + }) + }), +}) diff --git a/packages/jira/src/JiraWebhookSchemas.ts b/packages/jira/src/JiraWebhookSchemas.ts new file mode 100644 index 0000000..9d46cb5 --- /dev/null +++ b/packages/jira/src/JiraWebhookSchemas.ts @@ -0,0 +1,40 @@ +import { Schema } from 'effect' + +import { JiraAccountId, JiraIssueId } from './JiraIdentity' + +export const JiraWebhookHeaders = Schema.Struct({ + 'x-hub-signature': Schema.NonEmptyString, + 'x-atlassian-webhook-identifier': Schema.optionalKey(Schema.NonEmptyString), +}) + +export const JiraWebhookEnvelope = Schema.Struct({ webhookEvent: Schema.NonEmptyString }) + +export const JiraUser = Schema.Struct({ + accountId: JiraAccountId, + displayName: Schema.optionalKey(Schema.String), + accountType: Schema.optionalKey(Schema.String), +}) +export type JiraUser = typeof JiraUser.Type + +/** `body` is an ADF document in Jira Cloud payloads and a plain string otherwise; callers parse it. */ +export const JiraComment = Schema.Struct({ + id: Schema.NonEmptyString, + author: JiraUser, + body: Schema.Unknown, +}) +export type JiraComment = typeof JiraComment.Type + +export const JiraIssue = Schema.Struct({ + id: JiraIssueId, + key: Schema.NonEmptyString, + fields: Schema.optionalKey(Schema.Struct({ summary: Schema.optionalKey(Schema.String) })), +}) +export type JiraIssue = typeof JiraIssue.Type + +export const JiraCommentCreatedWebhook = Schema.Struct({ + webhookEvent: Schema.Literal('comment_created'), + timestamp: Schema.optionalKey(Schema.Finite), + comment: JiraComment, + issue: JiraIssue, +}) +export type JiraCommentCreatedWebhook = typeof JiraCommentCreatedWebhook.Type diff --git a/packages/jira/src/JiraWebhookSignature.ts b/packages/jira/src/JiraWebhookSignature.ts new file mode 100644 index 0000000..466e068 --- /dev/null +++ b/packages/jira/src/JiraWebhookSignature.ts @@ -0,0 +1,68 @@ +import { WebhookAuthenticationError } from '@humanlayer/channels-delivery' +import { Crypto, Effect, Redacted, Schema } from 'effect' + +const JiraSignatureInput = Schema.Struct({ + body: Schema.Uint8Array, + signature: Schema.String, + webhookSecret: Schema.Redacted(Schema.String, { disallowJsonEncode: true }), +}) +type JiraSignatureInput = typeof JiraSignatureInput.Type + +const hexadecimal = /^[\da-f]+$/ +const hmacBlockSize = 64 +const textEncoder = new TextEncoder() + +const concatenateBytes = (left: Uint8Array, right: Uint8Array) => { + const output = new Uint8Array(left.length + right.length) + output.set(left) + output.set(right, left.length) + return output +} + +const hmacSha256 = Effect.fn('jira.crypto.hmac_sha256')(function* ( + secret: Redacted.Redacted, + data: Uint8Array, +) { + const crypto = yield* Crypto.Crypto + const encodedKey = textEncoder.encode(Redacted.value(secret)) + const key = encodedKey.length > hmacBlockSize ? yield* crypto.digest('SHA-256', encodedKey) : encodedKey + const padded = new Uint8Array(hmacBlockSize) + padded.set(key) + const inner = new Uint8Array(hmacBlockSize) + const outer = new Uint8Array(hmacBlockSize) + for (let index = 0; index < hmacBlockSize; index += 1) { + inner[index] = (padded.at(index) ?? 0) ^ 0x36 + outer[index] = (padded.at(index) ?? 0) ^ 0x5c + } + const innerDigest = yield* crypto.digest('SHA-256', concatenateBytes(inner, data)) + return yield* crypto.digest('SHA-256', concatenateBytes(outer, innerDigest)) +}) + +const decodeSignature = (signature: string) => { + if (!signature.startsWith('sha256=')) return undefined + const hexadecimalSignature = signature.slice(7) + if (hexadecimalSignature.length !== 64 || !hexadecimal.test(hexadecimalSignature)) return undefined + return Uint8Array.from({ length: 32 }, (_, index) => + Number.parseInt(hexadecimalSignature.slice(index * 2, index * 2 + 2), 16), + ) +} + +const constantTimeEqual = (left: Uint8Array, right: Uint8Array) => { + let difference = left.length ^ right.length + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + difference |= (left.at(index) ?? 0) ^ (right.at(index) ?? 0) + } + return difference === 0 +} + +export const verifyJiraWebhookSignature = Effect.fn('jira.signature.verify')(function* (input: JiraSignatureInput) { + const expected = decodeSignature(input.signature) + if (expected === undefined) return yield* WebhookAuthenticationError.make({ reason: 'invalid_signature' }) + const actual = yield* hmacSha256(input.webhookSecret, input.body).pipe( + Effect.tapError((error) => Effect.logError('Jira HMAC calculation failed', error)), + Effect.mapError(() => WebhookAuthenticationError.make({ reason: 'crypto' })), + ) + if (!constantTimeEqual(actual, expected)) { + return yield* WebhookAuthenticationError.make({ reason: 'invalid_signature' }) + } +}) diff --git a/packages/jira/src/index.ts b/packages/jira/src/index.ts new file mode 100644 index 0000000..8450383 --- /dev/null +++ b/packages/jira/src/index.ts @@ -0,0 +1,4 @@ +export * from './JiraIdentity' +export * from './JiraWebhookProvider' +export * from './JiraWebhookSchemas' +export * from './JiraWebhookSignature' diff --git a/packages/jira/test/fixtures.ts b/packages/jira/test/fixtures.ts new file mode 100644 index 0000000..db73bf7 --- /dev/null +++ b/packages/jira/test/fixtures.ts @@ -0,0 +1,28 @@ +import { createHmac } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import type { RawWebhookInput } from '@humanlayer/channels-delivery' +import { Headers } from 'effect/http' + +export const jiraTestKey = 'jira-test-key' + +export const readJiraFixture = (fixture: string) => readFileSync(join(import.meta.dirname, 'fixtures', fixture)) + +export const signedJiraBody = ( + body: Uint8Array, + secret: string, + extraHeaders: Readonly> = {}, +): RawWebhookInput => ({ + headers: Headers.fromInput({ + 'x-hub-signature': `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`, + ...extraHeaders, + }), + body, +}) + +export const signedJiraInput = ( + fixture: string, + secret: string, + extraHeaders: Readonly> = {}, +): RawWebhookInput => signedJiraBody(new Uint8Array(readJiraFixture(fixture)), secret, extraHeaders) diff --git a/packages/jira/test/jira-webhook-routing.vi.test.ts b/packages/jira/test/jira-webhook-routing.vi.test.ts new file mode 100644 index 0000000..61353b8 --- /dev/null +++ b/packages/jira/test/jira-webhook-routing.vi.test.ts @@ -0,0 +1,99 @@ +import { NodeCrypto } from '@effect/platform-node' +import { describe, it } from '@effect/vitest' +import { + ProviderWebhookIgnored, + type ProviderWebhookOutcome, + WebhookAuthenticationError, + WebhookPayloadInvalidError, +} from '@humanlayer/channels-delivery' +import { Effect, Match, Redacted } from 'effect' +import { Headers } from 'effect/http' + +import { JiraIssueId, jiraIssueResourceId } from '../src/JiraIdentity' +import { makeJiraWebhookProvider } from '../src/JiraWebhookProvider' +import { jiraTestKey, readJiraFixture, signedJiraBody, signedJiraInput } from './fixtures' + +const provider = makeJiraWebhookProvider({ + namespace: 'jira-test', + webhookSecret: Redacted.make(jiraTestKey), + site: 'example.atlassian.net', +}) +const handle = (input: Parameters[0]) => + provider.handle(input).pipe(Effect.provide(NodeCrypto.layer)) + +const admitted = (outcome: ProviderWebhookOutcome) => + Match.value(outcome).pipe( + Match.tag('Event', ({ event }) => event), + Match.orElse(() => { + throw new Error('Expected the webhook to be admitted') + }), + ) + +describe('Jira webhook routing', () => { + it('serves under the jira provider name', ({ expect }) => { + expect(provider.providerName).toBe('jira') + }) + + it.effect('admits comment_created with the issue resource and the delivery header as event id', ({ expect }) => + Effect.gen(function* () { + const input = () => + signedJiraInput('comment-created-mention.json', jiraTestKey, { + 'x-atlassian-webhook-identifier': 'delivery-abc', + }) + const first = admitted(yield* handle(input())) + const second = admitted(yield* handle(input())) + expect(first.provider).toBe('jira') + expect(first.namespace).toBe('jira-test') + expect(first.installationId).toBe('example.atlassian.net') + expect(first.resourceId).toBe(jiraIssueResourceId({ issueId: JiraIssueId.make('10042') })) + expect(first.resourceId).toBe('jira:v1:issue:10042') + expect(first.eventId).toBe('delivery-abc') + expect(second.eventId).toBe(first.eventId) + }), + ) + + it.effect('falls back to the comment id when the delivery header is absent', ({ expect }) => + Effect.gen(function* () { + const first = admitted(yield* handle(signedJiraInput('comment-created-mention.json', jiraTestKey))) + const second = admitted(yield* handle(signedJiraInput('comment-created-mention.json', jiraTestKey))) + expect(first.eventId).toBe('10100') + expect(second.eventId).toBe(first.eventId) + }), + ) + + it.effect('ignores comment_updated', ({ expect }) => + Effect.gen(function* () { + expect(yield* handle(signedJiraInput('comment-updated.json', jiraTestKey))).toEqual( + ProviderWebhookIgnored.make({}), + ) + }), + ) + + it.effect('ignores an unsupported webhookEvent', ({ expect }) => + Effect.gen(function* () { + const body = new TextEncoder().encode(JSON.stringify({ webhookEvent: 'jira:issue_updated', timestamp: 1 })) + expect(yield* handle(signedJiraBody(body, jiraTestKey))).toEqual(ProviderWebhookIgnored.make({})) + }), + ) + + it.effect('rejects invalid JSON', ({ expect }) => + Effect.gen(function* () { + const error = yield* handle(signedJiraBody(new TextEncoder().encode('{nope'), jiraTestKey)).pipe( + Effect.flip, + ) + expect(error).toEqual(WebhookPayloadInvalidError.make({ reason: 'invalid_json' })) + }), + ) + + it.effect('rejects a bad signature and a missing signature header', ({ expect }) => + Effect.gen(function* () { + const bad = yield* handle(signedJiraInput('comment-created-mention.json', 'other')).pipe(Effect.flip) + expect(bad).toEqual(WebhookAuthenticationError.make({ reason: 'invalid_signature' })) + const missing = yield* handle({ + headers: Headers.empty, + body: new Uint8Array(readJiraFixture('comment-updated.json')), + }).pipe(Effect.flip) + expect(missing).toBeInstanceOf(WebhookAuthenticationError) + }), + ) +}) diff --git a/packages/jira/test/jira-webhook-signature.vi.test.ts b/packages/jira/test/jira-webhook-signature.vi.test.ts new file mode 100644 index 0000000..c7ccd0e --- /dev/null +++ b/packages/jira/test/jira-webhook-signature.vi.test.ts @@ -0,0 +1,42 @@ +import { NodeCrypto } from '@effect/platform-node' +import { describe, it } from '@effect/vitest' +import { WebhookAuthenticationError } from '@humanlayer/channels-delivery' +import { Effect, Redacted } from 'effect' + +import { verifyJiraWebhookSignature } from '../src/JiraWebhookSignature' +import { signedJiraBody } from './fixtures' + +const body = new TextEncoder().encode('{"webhookEvent":"comment_created"}') +const verify = (signature: string, key = 'right-key') => + verifyJiraWebhookSignature({ body, signature, webhookSecret: Redacted.make(key) }).pipe( + Effect.provide(NodeCrypto.layer), + Effect.flip, + ) +const header = (secret: string) => String(signedJiraBody(body, secret).headers['x-hub-signature']) + +describe('Jira webhook signature', () => { + it.effect('accepts a body signed with the secret', () => + verifyJiraWebhookSignature({ + body, + signature: header('right-key'), + webhookSecret: Redacted.make('right-key'), + }).pipe(Effect.provide(NodeCrypto.layer)), + ) + + it.effect('rejects a signature made with another secret', ({ expect }) => + Effect.gen(function* () { + expect(yield* verify(header('wrong-key'))).toEqual( + WebhookAuthenticationError.make({ reason: 'invalid_signature' }), + ) + }), + ) + + it.effect('rejects malformed or non-sha256 headers', ({ expect }) => + Effect.gen(function* () { + const expected = WebhookAuthenticationError.make({ reason: 'invalid_signature' }) + expect(yield* verify('')).toEqual(expected) + expect(yield* verify('sha256=zz')).toEqual(expected) + expect(yield* verify(header('right-key').replace('sha256=', 'sha1='))).toEqual(expected) + }), + ) +}) diff --git a/packages/jira/tsconfig.json b/packages/jira/tsconfig.json new file mode 100644 index 0000000..a693f4b --- /dev/null +++ b/packages/jira/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "test"] +} diff --git a/scripts/release/manifest.ts b/scripts/release/manifest.ts index 0373cca..82e2626 100644 --- a/scripts/release/manifest.ts +++ b/scripts/release/manifest.ts @@ -6,7 +6,16 @@ export const scope = '@humanlayer' export const repository = { type: 'git', url: 'git+https://github.com/humanlayer/effect-channels.git' } /** Every directory in `packages/`, in publish order: a package comes after the packages it depends on. */ -export const libraries = ['delivery', 'slack', 'github', 'linear', 'sql', 'redis', 'alchemy-cloudflare'] as const +export const libraries = [ + 'delivery', + 'jira', + 'slack', + 'github', + 'linear', + 'sql', + 'redis', + 'alchemy-cloudflare', +] as const export const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ From 1dd6ef77608e96cb15e1e5bc11df08caf65ae26a Mon Sep 17 00:00:00 2001 From: yashodhah Date: Mon, 19 Oct 2026 20:44:14 +0800 Subject: [PATCH 3/9] feat(jira): mention detection, callbacks and event processor Parse ADF (or plain string) comment bodies for bot mentions, nested blocks included, and expose the text after the first mention. Route comments that mention the bot to onMentioned, ignoring the bot's own comments and plain comments; prepare the JiraIssue destination before the callback and re-run the saved callback on retries. Co-Authored-By: Claude Sonnet 5.5 --- packages/jira/src/JiraCallbackEvents.ts | 27 ++ packages/jira/src/JiraCallbacks.ts | 88 ++++++ packages/jira/src/JiraComment.ts | 100 +++++++ packages/jira/src/JiraDeliveryDestination.ts | 28 ++ packages/jira/src/JiraEventProcessor.ts | 251 ++++++++++++++++++ packages/jira/src/index.ts | 5 + packages/jira/test/jira-comment.vi.test.ts | 91 +++++++ .../test/jira-event-processing.vi.test.ts | 232 ++++++++++++++++ 8 files changed, 822 insertions(+) create mode 100644 packages/jira/src/JiraCallbackEvents.ts create mode 100644 packages/jira/src/JiraCallbacks.ts create mode 100644 packages/jira/src/JiraComment.ts create mode 100644 packages/jira/src/JiraDeliveryDestination.ts create mode 100644 packages/jira/src/JiraEventProcessor.ts create mode 100644 packages/jira/test/jira-comment.vi.test.ts create mode 100644 packages/jira/test/jira-event-processing.vi.test.ts diff --git a/packages/jira/src/JiraCallbackEvents.ts b/packages/jira/src/JiraCallbackEvents.ts new file mode 100644 index 0000000..954f49a --- /dev/null +++ b/packages/jira/src/JiraCallbackEvents.ts @@ -0,0 +1,27 @@ +import { Schema } from 'effect' + +import { JiraIssueId } from './JiraIdentity' + +/** The issue a delivery is about. `mailboxKey` names the delivery mailbox for that issue. */ +export const JiraIssueRef = Schema.Struct({ + id: JiraIssueId, + key: Schema.String, + mailboxKey: Schema.String, +}) +export type JiraIssueRef = typeof JiraIssueRef.Type + +/** + * A comment that mentions the bot. `body` is the whole comment as plain text; `afterMention` is the text + * after the bot's first mention, for the application to parse (the package knows nothing of commands). + */ +export const JiraMentioned = Schema.TaggedStruct('JiraMentioned', { + issue: JiraIssueRef, + comment: Schema.Struct({ + id: Schema.String, + body: Schema.String, + afterMention: Schema.String, + author: Schema.Struct({ accountId: Schema.String, displayName: Schema.String }), + }), + eventId: Schema.String, +}) +export type JiraMentioned = typeof JiraMentioned.Type diff --git a/packages/jira/src/JiraCallbacks.ts b/packages/jira/src/JiraCallbacks.ts new file mode 100644 index 0000000..df88b2a --- /dev/null +++ b/packages/jira/src/JiraCallbacks.ts @@ -0,0 +1,88 @@ +import type { DeliveryCallbackResult, DeliveryContext } from '@humanlayer/channels-delivery' +import { Cause, Effect, Option, Predicate, Schema } from 'effect' + +import type { JiraMentioned } from './JiraCallbackEvents' + +export const JiraCallbackName = Schema.Literals(['onMentioned']) +export type JiraCallbackName = typeof JiraCallbackName.Type + +export const JiraCallbackRetryability = Schema.Struct({ + retryability: Schema.Literals(['retryable', 'non_retryable']), +}) +export interface JiraCallbackRetryability extends Schema.Schema.Type {} + +const JiraCallbackRetryableFlag = Schema.Struct({ retryable: Schema.Boolean }) + +export class JiraCallbackError extends Schema.TaggedError()('JiraCallbackError', { + callback: JiraCallbackName, + retryable: Schema.Boolean, + reason: Schema.Literals(['failed', 'unexpected']), +}) {} + +/** + * One application callback. `delivery` names the delivery and can hand it to a remote worker; a callback + * that did so may return the `DeliveryHandoff`. The saved handoff, not the return value, is authoritative. + */ +export type JiraCallbackHandler = ( + event: A, + delivery: DeliveryContext, +) => Effect.Effect + +export type JiraCallbackHandlers = { + readonly onMentioned?: JiraCallbackHandler +} + +const retryableFromCause = (cause: Cause.Cause): boolean => + Option.match(Cause.findErrorOption(cause), { + onNone: () => true, + onSome: (error) => { + if (Schema.is(JiraCallbackRetryability)(error)) return error.retryability === 'retryable' + if (Schema.is(JiraCallbackRetryableFlag)(error)) return error.retryable + return true + }, + }) + +const narrowJiraCallbackCause = (input: { + readonly callback: JiraCallbackName + readonly cause: Cause.Cause +}) => { + if (Cause.hasInterrupts(input.cause)) { + const preservedReasons = input.cause.reasons.filter( + (reason) => Cause.isInterruptReason(reason) || Cause.isDieReason(reason), + ) + const preserveCause = Effect.failCause(Cause.fromReasons(preservedReasons)) + if (Cause.hasInterruptsOnly(input.cause)) return preserveCause + return Effect.logError('Jira application callback was interrupted with additional failures', input.cause).pipe( + Effect.annotateLogs({ callback: input.callback, classification: 'interrupted_with_failure' }), + Effect.andThen(preserveCause), + ) + } + const unexpected = Cause.hasDies(input.cause) + return Effect.logError('Jira application callback failed', input.cause).pipe( + Effect.annotateLogs({ callback: input.callback, classification: unexpected ? 'unexpected_defect' : 'failed' }), + Effect.andThen( + Effect.fail( + JiraCallbackError.make({ + callback: input.callback, + reason: unexpected ? 'unexpected' : 'failed', + retryable: unexpected ? true : retryableFromCause(input.cause), + }), + ), + ), + ) +} + +const wrapCallback = (callback: JiraCallbackName, handler: JiraCallbackHandler | undefined) => { + if (Predicate.isUndefined(handler)) return undefined + return Effect.fn(`jira.callbacks.${callback}`)((event: A, delivery: DeliveryContext) => + Effect.suspend(() => handler(event, delivery)).pipe( + Effect.catchCause((cause) => narrowJiraCallbackCause({ callback, cause })), + ), + ) +} + +export type JiraCallbackOperations = JiraCallbackHandlers + +export const makeJiraCallbacks = (handlers: JiraCallbackHandlers): JiraCallbackOperations => ({ + onMentioned: wrapCallback('onMentioned', handlers.onMentioned), +}) diff --git a/packages/jira/src/JiraComment.ts b/packages/jira/src/JiraComment.ts new file mode 100644 index 0000000..910c84a --- /dev/null +++ b/packages/jira/src/JiraComment.ts @@ -0,0 +1,100 @@ +/** + * Reading a Jira comment body: an Atlassian Document Format (ADF) tree in Jira Cloud payloads, or a plain + * string otherwise. A string body is treated as text with no mentions (see ASSUMPTIONS.md). The webhook + * schema keeps the body unparsed; decode it with {@link JiraCommentBody} before calling these functions. + */ +import { Predicate, Schema } from 'effect' + +/** The parts of an ADF node these functions read. Other node fields are ignored. */ +export interface JiraAdfNode { + readonly type: string + readonly text?: string + readonly attrs?: { readonly id?: string; readonly text?: string } + readonly content?: ReadonlyArray +} + +export const JiraAdfNode: Schema.Codec = Schema.Struct({ + type: Schema.String, + text: Schema.optionalKey(Schema.String), + attrs: Schema.optionalKey( + Schema.Struct({ id: Schema.optionalKey(Schema.String), text: Schema.optionalKey(Schema.String) }), + ), + content: Schema.optionalKey(Schema.Array(Schema.suspend((): Schema.Codec => JiraAdfNode))), +}) + +export const JiraCommentBody = Schema.Union([Schema.String, JiraAdfNode]) +export type JiraCommentBody = typeof JiraCommentBody.Type + +const inlineNodeTypes: ReadonlySet = new Set([ + 'text', + 'mention', + 'hardBreak', + 'emoji', + 'inlineCard', + 'date', + 'status', +]) + +const isInline = (node: JiraAdfNode) => inlineNodeTypes.has(node.type) + +const mentionLabel = (node: JiraAdfNode) => { + const text = node.attrs?.text + if (text !== undefined && text.length > 0) return text.startsWith('@') ? text : `@${text}` + return `@${node.attrs?.id ?? ''}` +} + +type Rendering = { text: string; mentionOffset: number | undefined } + +/** Append `node` as plain text; block siblings are separated by a newline, inline siblings are joined. */ +const renderNode = (node: JiraAdfNode, rendering: Rendering, accountId: string | undefined) => { + if (node.type === 'text') { + rendering.text += node.text ?? '' + return + } + if (node.type === 'hardBreak') { + rendering.text += '\n' + return + } + if (node.type === 'mention') { + rendering.text += mentionLabel(node) + if (rendering.mentionOffset === undefined && accountId !== undefined && node.attrs?.id === accountId) { + rendering.mentionOffset = rendering.text.length + } + return + } + ;(node.content ?? []).forEach((child, index) => { + if (index > 0 && !isInline(child)) rendering.text += '\n' + renderNode(child, rendering, accountId) + }) +} + +const render = (body: JiraCommentBody, accountId?: string): Rendering => { + const rendering: Rendering = { text: '', mentionOffset: undefined } + if (Predicate.isString(body)) rendering.text = body + else renderNode(body, rendering, accountId) + return rendering +} + +/** The comment as plain text, with each mention rendered as `@`. */ +export const commentPlainText = (body: JiraCommentBody): string => render(body).text + +/** The account ids mentioned anywhere in the body, nested blocks included, in document order without repeats. */ +export const mentionedAccountIds = (body: JiraCommentBody): ReadonlyArray => { + const ids: Array = [] + const walk = (node: JiraAdfNode) => { + const id = node.attrs?.id + if (node.type === 'mention' && id !== undefined && !ids.includes(id)) ids.push(id) + node.content?.forEach(walk) + } + if (!Predicate.isString(body)) walk(body) + return ids +} + +/** + * The plain text after the first mention of `accountId`, through the end of the comment, trimmed; or + * `undefined` when the comment does not mention that account. + */ +export const textAfterMention = (body: JiraCommentBody, accountId: string): string | undefined => { + const rendering = render(body, accountId) + return rendering.mentionOffset === undefined ? undefined : rendering.text.slice(rendering.mentionOffset).trim() +} diff --git a/packages/jira/src/JiraDeliveryDestination.ts b/packages/jira/src/JiraDeliveryDestination.ts new file mode 100644 index 0000000..b5473c1 --- /dev/null +++ b/packages/jira/src/JiraDeliveryDestination.ts @@ -0,0 +1,28 @@ +/** + * Where a Jira delivery's output goes, saved before the callback runs so a later attempt or a remote + * worker can reach the same issue. The delivery core stores it as opaque JSON; only Jira reads it. + */ +import type { DeliveryOperationKind } from '@humanlayer/channels-delivery' +import { Schema } from 'effect' + +import { JiraIssueRef } from './JiraCallbackEvents' + +/** The version of {@link JiraDeliveryDestination}. Bump when it changes shape. */ +export const jiraPresentationVersion = 1 + +/** The issue a delivery replies on. */ +export const JiraDeliveryDestination = Schema.TaggedUnion({ + JiraIssue: { issue: JiraIssueRef }, +}) +export type JiraDeliveryDestination = typeof JiraDeliveryDestination.Type + +/** JSON codec for storing the destination. */ +export const JiraDeliveryDestinationJson = Schema.toCodecJson(JiraDeliveryDestination) + +/** The output operations a Jira issue supports: comments only, no reactions or activity. */ +export const jiraSupportedOperations: ReadonlyArray = [ + 'PresentOutcome', + 'CreateMessage', + 'UpdateMessage', + 'DeleteMessage', +] diff --git a/packages/jira/src/JiraEventProcessor.ts b/packages/jira/src/JiraEventProcessor.ts new file mode 100644 index 0000000..82cd69a --- /dev/null +++ b/packages/jira/src/JiraEventProcessor.ts @@ -0,0 +1,251 @@ +/** Jira post-admission batch identity validation, mention detection, and callback routing. */ +import { + deliveryMailboxKey, + type DeliveryAdmission, + type DeliveryAdmissionBatch, + type DeliveryContext, + PreparedDeliveryCallback, + PreparedDeliveryInvocation, + type ProviderDeliveryExecution, + ProviderEventExecutionFailed, + ProviderEventHandled, + ProviderEventIgnored, + ProviderEventInvalid, + type ProviderEventProcessor, +} from '@humanlayer/channels-delivery' +import { Array as Arr, Effect, Option, Predicate, Schema } from 'effect' + +import { JiraIssueRef, JiraMentioned } from './JiraCallbackEvents' +import { + JiraCallbackName, + type JiraCallbackHandlers, + type JiraCallbackOperations, + makeJiraCallbacks, +} from './JiraCallbacks' +import { commentPlainText, JiraCommentBody, textAfterMention } from './JiraComment' +import { + JiraDeliveryDestination, + JiraDeliveryDestinationJson, + jiraPresentationVersion, + jiraSupportedOperations, +} from './JiraDeliveryDestination' +import { JiraAccountId, jiraIssueResourceId } from './JiraIdentity' +import { JiraCommentCreatedWebhook } from './JiraWebhookSchemas' + +export const JiraBotConfiguration = Schema.Struct({ accountId: JiraAccountId }) +export interface JiraBotConfiguration extends Schema.Schema.Type {} + +export type JiraEventProcessorOptions = { + readonly namespace: string + readonly bot: JiraBotConfiguration +} + +const invalidPayload = () => ProviderEventInvalid.make({ provider: 'jira', reason: 'invalid_payload' }) +const identityMismatch = () => ProviderEventInvalid.make({ provider: 'jira', reason: 'identity_mismatch' }) +const providerFailure = (safeCode: string) => + ProviderEventExecutionFailed.make({ provider: 'jira', retryable: true, safeCode }) +const nonRetryableFailure = (safeCode: string) => + ProviderEventExecutionFailed.make({ provider: 'jira', retryable: false, safeCode }) + +/** A stored comment webhook with its body parsed, or `None` when the body is neither ADF nor a string. */ +type JiraBatchComment = { + readonly admission: DeliveryAdmission + readonly webhook: JiraCommentCreatedWebhook + readonly body: Option.Option +} + +const decodeJiraComment = Effect.fn('jira.decode_comment')(function* (admission: DeliveryAdmission) { + const webhook = yield* Schema.decodeUnknownEffect(JiraCommentCreatedWebhook)(admission.payload).pipe( + Effect.tapError((error) => Effect.logError('Stored Jira webhook could not be decoded', error)), + Effect.mapError(invalidPayload), + ) + const body = Schema.decodeUnknownOption(JiraCommentBody)(webhook.comment.body) + if (Option.isNone(body)) { + yield* Effect.logWarning('Jira comment body is neither ADF nor text; treating it as no mention').pipe( + Effect.annotateLogs({ eventId: admission.eventId, commentId: webhook.comment.id }), + ) + } + return { admission, webhook, body } +}) + +/** Decode every admission and check that the whole batch is about one issue in this namespace. */ +const decodeJiraBatch = Effect.fn('jira.decode_event_batch')(function* ( + options: JiraEventProcessorOptions, + admissions: DeliveryAdmissionBatch, +) { + const first = admissions[0] + const comments: ReadonlyArray = yield* Effect.forEach(admissions, decodeJiraComment) + const mismatched = comments.some( + ({ admission, webhook }) => + admission.namespace !== options.namespace || + admission.provider !== 'jira' || + admission.installationId !== first.installationId || + admission.resourceId !== first.resourceId || + jiraIssueResourceId({ issueId: webhook.issue.id }) !== admission.resourceId, + ) + if (mismatched) return yield* identityMismatch() + return { first, comments } +}) + +/** A batch's comments not written by the bot, with the mentions of the bot among them, in mailbox order. */ +type JiraBatchEvents = { + readonly relevant: number + readonly mentions: ReadonlyArray +} + +const toMention = (comment: JiraBatchComment, mailboxKey: string, bot: JiraBotConfiguration) => + Option.flatMap(comment.body, (body) => { + const afterMention = textAfterMention(body, bot.accountId) + if (afterMention === undefined) return Option.none() + const { admission, webhook } = comment + return Option.some( + JiraMentioned.make({ + issue: JiraIssueRef.make({ id: webhook.issue.id, key: webhook.issue.key, mailboxKey }), + comment: { + id: webhook.comment.id, + body: commentPlainText(body), + afterMention, + author: { + accountId: webhook.comment.author.accountId, + displayName: webhook.comment.author.displayName ?? webhook.comment.author.accountId, + }, + }, + eventId: admission.eventId, + }), + ) + }) + +/** + * Loop prevention: comments written by the bot's account never count, even when they mention the bot. + * Of the rest, those that mention the bot become `JiraMentioned` events. + */ +const normalizeBatch = ( + first: DeliveryAdmission, + comments: ReadonlyArray, + bot: JiraBotConfiguration, +): JiraBatchEvents => { + const mailboxKey = deliveryMailboxKey(first) + const relevant = comments.filter(({ webhook }) => webhook.comment.author.accountId !== bot.accountId) + return { + relevant: relevant.length, + mentions: Arr.getSomes(relevant.map((comment) => toMention(comment, mailboxKey, bot))), + } +} + +/** + * The mention a batch delivers. When one delivery batches several mentions of the bot, `onMentioned` runs + * once, for the last: it is the newest request on the issue, and running one callback per delivery keeps + * a retry's saved choice simple. Earlier mentions in the batch are not delivered separately; they remain + * comments on the issue for the application to read. + */ +const buildMention = (batch: JiraBatchEvents): Option.Option => Arr.last(batch.mentions) + +/** Save the callback choice before any application code runs, so every retry runs the same callback. */ +const prepareInvocation = Effect.fn('jira.prepare_delivery')(function* ( + execution: ProviderDeliveryExecution, + event: JiraMentioned, +) { + const destination = yield* Schema.encodeEffect(JiraDeliveryDestinationJson)( + JiraDeliveryDestination.cases.JiraIssue.make({ issue: event.issue }), + ).pipe( + Effect.tapError((error) => Effect.logError('Jira delivery destination could not be encoded', error)), + Effect.mapError(() => nonRetryableFailure('delivery_destination_unencodable')), + ) + const step = PreparedDeliveryCallback.make({ + name: 'onMentioned', + presentationVersion: jiraPresentationVersion, + destination, + supportedOperations: jiraSupportedOperations, + reactionTargets: [], + }) + return yield* execution.prepare(PreparedDeliveryInvocation.make({ callbacks: [step] })).pipe( + Effect.tapError((error) => + Effect.logError('Jira delivery preparation failed', error).pipe( + Effect.annotateLogs({ deliveryId: execution.deliveryId, callbacks: [step.name] }), + ), + ), + Effect.catchTags({ + DeliveryPreparationUnavailable: () => Effect.fail(providerFailure('delivery_prepare_unavailable')), + DeliveryPreparationConflict: () => Effect.fail(nonRetryableFailure('delivery_prepare_conflict')), + }), + ) +}) + +/** Run the callback an earlier attempt saved, without choosing again. */ +const runPreparedInvocation = Effect.fn('jira.run_prepared_invocation')(function* (input: { + readonly callbacks: JiraCallbackOperations + readonly prepared: PreparedDeliveryInvocation + readonly callbackIndex: number + readonly batch: JiraBatchEvents + readonly delivery: DeliveryContext +}) { + const step = input.prepared.callbacks[input.callbackIndex] + if (Predicate.isUndefined(step)) return yield* nonRetryableFailure('prepared_callback_missing') + const annotations = { deliveryId: input.delivery.deliveryId, callback: step.name } + yield* Schema.decodeUnknownEffect(JiraCallbackName)(step.name).pipe( + Effect.tapError((error) => + Effect.logError('Prepared Jira callback is unknown', error).pipe(Effect.annotateLogs(annotations)), + ), + Effect.mapError(() => nonRetryableFailure('prepared_callback_missing')), + ) + const onMentioned = input.callbacks.onMentioned + if (Predicate.isUndefined(onMentioned)) { + yield* Effect.logError('Prepared Jira callback is no longer configured').pipe(Effect.annotateLogs(annotations)) + return yield* nonRetryableFailure('prepared_callback_missing') + } + const event = buildMention(input.batch) + if (Option.isNone(event)) { + yield* Effect.logError('Prepared Jira callback cannot be rebuilt from its batch').pipe( + Effect.annotateLogs(annotations), + ) + return yield* nonRetryableFailure('prepared_callback_unbuildable') + } + return yield* onMentioned(event.value, input.delivery).pipe( + Effect.mapError((error) => + ProviderEventExecutionFailed.make({ + provider: 'jira', + retryable: error.retryable, + safeCode: 'callback_failed', + }), + ), + Effect.as(ProviderEventHandled.make({})), + ) +}) + +const processJiraBatch = (options: JiraEventProcessorOptions, callbacks: JiraCallbackOperations) => + Effect.fn('jira.process_event_batch')(function* ( + admissions: DeliveryAdmissionBatch, + execution: ProviderDeliveryExecution, + ) { + const { first, comments } = yield* decodeJiraBatch(options, admissions) + const batch = normalizeBatch(first, comments, options.bot) + const run = (prepared: PreparedDeliveryInvocation) => + runPreparedInvocation({ + callbacks, + prepared, + callbackIndex: execution.callbackIndex, + batch, + delivery: execution.context, + }) + + if (Option.isSome(execution.prepared)) return yield* run(execution.prepared.value) + if (batch.relevant === 0) return ProviderEventIgnored.make({ reason: 'no_relevant_event' }) + const event = buildMention(batch) + if (Option.isNone(event)) return ProviderEventIgnored.make({ reason: 'no_activation_event' }) + if (Predicate.isUndefined(callbacks.onMentioned)) { + return ProviderEventIgnored.make({ reason: 'callback_not_configured' }) + } + return yield* Effect.flatMap(prepareInvocation(execution, event.value), run) + }) + +/** + * Jira's callback half: runs the application's `onMentioned` for each batch. What the handler needs stays + * in its requirements, for the host to supply. + */ +export const makeJiraEventProcessor = ( + options: JiraEventProcessorOptions & { readonly handlers: JiraCallbackHandlers }, +): ProviderEventProcessor => ({ + namespace: options.namespace, + providerName: 'jira', + process: processJiraBatch(options, makeJiraCallbacks(options.handlers)), +}) diff --git a/packages/jira/src/index.ts b/packages/jira/src/index.ts index 8450383..f45ebda 100644 --- a/packages/jira/src/index.ts +++ b/packages/jira/src/index.ts @@ -1,3 +1,8 @@ +export * from './JiraCallbackEvents' +export * from './JiraCallbacks' +export * from './JiraComment' +export * from './JiraDeliveryDestination' +export * from './JiraEventProcessor' export * from './JiraIdentity' export * from './JiraWebhookProvider' export * from './JiraWebhookSchemas' diff --git a/packages/jira/test/jira-comment.vi.test.ts b/packages/jira/test/jira-comment.vi.test.ts new file mode 100644 index 0000000..d3e6c2e --- /dev/null +++ b/packages/jira/test/jira-comment.vi.test.ts @@ -0,0 +1,91 @@ +import { describe, it } from '@effect/vitest' +import { Option, Schema } from 'effect' + +import { + commentPlainText, + type JiraAdfNode, + JiraCommentBody, + mentionedAccountIds, + textAfterMention, +} from '../src/JiraComment' +import { readJiraFixture } from './fixtures' + +const botAccountId = '5b10ac8d82e05b22cc7d4ef5' +const otherAccountId = '6c21bd9e93f16c33dd8e5fa6' + +const mention = (id: string, text = '@slopcodebot'): JiraAdfNode => ({ type: 'mention', attrs: { id, text } }) +const text = (value: string): JiraAdfNode => ({ type: 'text', text: value }) +const paragraph = (...content: ReadonlyArray): JiraAdfNode => ({ type: 'paragraph', content }) +const doc = (...content: ReadonlyArray): JiraAdfNode => ({ type: 'doc', content }) + +const fixtureBody = (fixture: string): JiraCommentBody => + Schema.decodeUnknownSync(JiraCommentBody)(JSON.parse(readJiraFixture(fixture).toString('utf8')).comment.body) + +describe('Jira comment parsing', () => { + it('finds a mention in a top-level paragraph', ({ expect }) => { + expect(mentionedAccountIds(doc(paragraph(mention(botAccountId), text(' hi'))))).toEqual([botAccountId]) + expect(mentionedAccountIds(fixtureBody('comment-created-mention.json'))).toEqual([botAccountId]) + }) + + it('finds a mention inside a quote', ({ expect }) => { + expect( + mentionedAccountIds(doc({ type: 'blockquote', content: [paragraph(mention(botAccountId), text(' hi'))] })), + ).toEqual([botAccountId]) + expect(mentionedAccountIds(fixtureBody('comment-created-mention-nested.json'))).toEqual([botAccountId]) + }) + + it('finds a mention inside a list item', ({ expect }) => { + const body = doc({ + type: 'bulletList', + content: [ + { type: 'listItem', content: [paragraph(text('first'))] }, + { type: 'listItem', content: [paragraph(mention(botAccountId), text(' /research list'))] }, + ], + }) + expect(mentionedAccountIds(body)).toEqual([botAccountId]) + expect(textAfterMention(body, botAccountId)).toBe('/research list') + }) + + it('returns the text after the first mention of the account, trimmed', ({ expect }) => { + expect(textAfterMention(doc(paragraph(mention(botAccountId), text(' /research please'))), botAccountId)).toBe( + '/research please', + ) + expect(textAfterMention(fixtureBody('comment-created-mention.json'), botAccountId)).toBe( + '/research how should we do this?', + ) + expect(textAfterMention(fixtureBody('comment-created-mention-nested.json'), botAccountId)).toBe( + '/research quoted\nsee above', + ) + }) + + it('returns undefined when the account is not mentioned', ({ expect }) => { + expect(textAfterMention(fixtureBody('comment-created-plain.json'), botAccountId)).toBeUndefined() + expect(mentionedAccountIds(fixtureBody('comment-created-plain.json'))).toEqual([]) + }) + + it('does not match a mention of a different account', ({ expect }) => { + const body = doc(paragraph(mention(otherAccountId, '@someone'), text(' /research nope'))) + expect(mentionedAccountIds(body)).toEqual([otherAccountId]) + expect(textAfterMention(body, botAccountId)).toBeUndefined() + }) + + it('renders plain text with mentions as @name', ({ expect }) => { + expect(commentPlainText(fixtureBody('comment-created-mention.json'))).toBe( + '@slopcodebot /research how should we do this?', + ) + expect(commentPlainText(fixtureBody('comment-created-mention-nested.json'))).toBe( + '@slopcodebot /research quoted\nsee above', + ) + }) + + it('tolerates a plain string body as text without mentions', ({ expect }) => { + expect(commentPlainText('just text')).toBe('just text') + expect(mentionedAccountIds('just text')).toEqual([]) + expect(textAfterMention('just text', botAccountId)).toBeUndefined() + }) + + it('rejects a body that is neither ADF nor a string', ({ expect }) => { + expect(Option.isNone(Schema.decodeUnknownOption(JiraCommentBody)(null))).toBe(true) + expect(Option.isNone(Schema.decodeUnknownOption(JiraCommentBody)({ type: 'doc', content: 'x' }))).toBe(true) + }) +}) diff --git a/packages/jira/test/jira-event-processing.vi.test.ts b/packages/jira/test/jira-event-processing.vi.test.ts new file mode 100644 index 0000000..bf0b608 --- /dev/null +++ b/packages/jira/test/jira-event-processing.vi.test.ts @@ -0,0 +1,232 @@ +import { describe, it } from '@effect/vitest' +import { + DeliveryAdmission, + deliveryMailboxKey, + PreparedDeliveryCallback, + PreparedDeliveryInvocation, + ProviderEventExecutionFailed, + ProviderEventHandled, + ProviderEventIgnored, + ProviderEventInvalid, +} from '@humanlayer/channels-delivery' +import { Effect, Option, Ref, Schema } from 'effect' +import { vi } from 'vite-plus/test' + +import { makeTestDeliveryExecution } from '../../delivery/test/delivery-execution' +import { JiraMentioned } from '../src/JiraCallbackEvents' +import type { JiraCallbackHandlers } from '../src/JiraCallbacks' +import { jiraSupportedOperations } from '../src/JiraDeliveryDestination' +import { JiraBotConfiguration, makeJiraEventProcessor } from '../src/JiraEventProcessor' +import { JiraAccountId, JiraIssueId, jiraIssueResourceId } from '../src/JiraIdentity' +import { readJiraFixture } from './fixtures' + +type Handler = NonNullable['onMentioned']> + +const namespace = 'jira-processing-test' +const site = 'example.atlassian.net' +const bot = JiraBotConfiguration.make({ accountId: JiraAccountId.make('5b10ac8d82e05b22cc7d4ef5') }) +const resourceId = jiraIssueResourceId({ issueId: JiraIssueId.make('10042') }) + +const fixturePayload = (fixture: string) => JSON.parse(readJiraFixture(fixture).toString('utf8')) + +const admission = (payload: Schema.Json, eventId: string, overrides: Partial = {}) => + DeliveryAdmission.make({ + namespace, + provider: 'jira', + installationId: site, + resourceId, + eventId, + payload, + ...overrides, + }) + +const fixtureAdmission = (fixture: string, eventId = fixture) => admission(fixturePayload(fixture), eventId) + +/** The mention fixture with a different comment id and text after the mention. */ +const mentionWith = (commentId: string, afterMention: string) => { + const payload = fixturePayload('comment-created-mention.json') + payload.comment.id = commentId + payload.comment.body.content[0].content = [ + payload.comment.body.content[0].content[0], + { type: 'text', text: ` ${afterMention}` }, + ] + return payload +} + +const process = ( + handlers: JiraCallbackHandlers, + admissions: readonly [DeliveryAdmission, ...Array], +) => + Effect.gen(function* () { + const test = yield* makeTestDeliveryExecution() + const result = yield* makeJiraEventProcessor({ namespace, bot, handlers }).process(admissions, test.execution) + return { result, preparations: yield* Ref.get(test.preparations) } + }) + +describe('Jira event batch processing', () => { + it.effect('runs onMentioned once for a comment that mentions the bot, after preparing', ({ expect }) => + Effect.gen(function* () { + const mention = fixtureAdmission('comment-created-mention.json', 'webhook-1') + const mailboxKey = deliveryMailboxKey(mention) + const onMentioned = vi.fn(() => Effect.void) + const { result, preparations } = yield* process({ onMentioned }, [mention]) + + expect(result).toEqual(ProviderEventHandled.make({})) + expect(onMentioned).toHaveBeenCalledOnce() + const event = onMentioned.mock.calls[0]?.[0] + expect(Schema.is(JiraMentioned)(event)).toBe(true) + expect(event?.comment.afterMention.startsWith('/research')).toBe(true) + expect(event?.comment.afterMention).toBe('/research how should we do this?') + expect(event?.comment.body).toBe('@slopcodebot /research how should we do this?') + expect(event?.comment.id).toBe('10100') + expect(event?.comment.author).toEqual({ + accountId: '557058a1b2c3d4e5f6a7b8c9', + displayName: 'Alex Example', + }) + expect(event?.issue).toEqual({ id: '10042', key: 'ABC-7', mailboxKey }) + expect(event?.eventId).toBe('webhook-1') + expect(preparations).toEqual([ + PreparedDeliveryInvocation.make({ + callbacks: [ + PreparedDeliveryCallback.make({ + name: 'onMentioned', + presentationVersion: 1, + destination: { _tag: 'JiraIssue', issue: { id: '10042', key: 'ABC-7', mailboxKey } }, + supportedOperations: jiraSupportedOperations, + reactionTargets: [], + }), + ], + }), + ]) + }), + ) + + it.effect('finds a mention nested in a quote', ({ expect }) => + Effect.gen(function* () { + const onMentioned = vi.fn(() => Effect.void) + const { result } = yield* process({ onMentioned }, [ + fixtureAdmission('comment-created-mention-nested.json'), + ]) + expect(result).toEqual(ProviderEventHandled.make({})) + expect(onMentioned.mock.calls[0]?.[0].comment.afterMention).toBe('/research quoted\nsee above') + }), + ) + + it.effect('ignores a comment authored by the bot account', ({ expect }) => + Effect.gen(function* () { + const onMentioned = vi.fn(() => Effect.void) + const { result, preparations } = yield* process({ onMentioned }, [ + fixtureAdmission('comment-created-by-bot.json'), + ]) + expect(Schema.is(ProviderEventIgnored)(result)).toBe(true) + expect(onMentioned).not.toHaveBeenCalled() + expect(preparations).toEqual([]) + }), + ) + + it.effect('ignores a plain comment without a mention', ({ expect }) => + Effect.gen(function* () { + const onMentioned = vi.fn(() => Effect.void) + const { result, preparations } = yield* process({ onMentioned }, [ + fixtureAdmission('comment-created-plain.json'), + ]) + expect(result).toEqual(ProviderEventIgnored.make({ reason: 'no_activation_event' })) + expect(onMentioned).not.toHaveBeenCalled() + expect(preparations).toEqual([]) + }), + ) + + it.effect('ignores a plain string comment body', ({ expect }) => + Effect.gen(function* () { + const onMentioned = vi.fn(() => Effect.void) + const payload = fixturePayload('comment-created-plain.json') + payload.comment.body = 'a plain string body' + const { result } = yield* process({ onMentioned }, [admission(payload, 'string-body')]) + expect(Schema.is(ProviderEventIgnored)(result)).toBe(true) + payload.comment.body = 42 + const unparseable = yield* process({ onMentioned }, [admission(payload, 'number-body')]) + expect(unparseable.result).toEqual(ProviderEventIgnored.make({ reason: 'no_activation_event' })) + expect(onMentioned).not.toHaveBeenCalled() + }), + ) + + it.effect('ignores a mention when no handler is configured', ({ expect }) => + Effect.gen(function* () { + const { result, preparations } = yield* process({}, [fixtureAdmission('comment-created-mention.json')]) + expect(result).toEqual(ProviderEventIgnored.make({ reason: 'callback_not_configured' })) + expect(preparations).toEqual([]) + }), + ) + + it.effect('calls onMentioned once, for the last mention, when a batch has two mentions', ({ expect }) => + Effect.gen(function* () { + const onMentioned = vi.fn(() => Effect.void) + const { result } = yield* process({ onMentioned }, [ + admission(mentionWith('20001', '/research first question'), 'first'), + fixtureAdmission('comment-created-plain.json', 'plain'), + admission(mentionWith('20002', '/research second question'), 'second'), + ]) + expect(result).toEqual(ProviderEventHandled.make({})) + expect(onMentioned).toHaveBeenCalledOnce() + const event = onMentioned.mock.calls[0]?.[0] + expect(event?.comment.id).toBe('20002') + expect(event?.comment.afterMention).toBe('/research second question') + expect(event?.eventId).toBe('second') + }), + ) + + it.effect('re-runs the saved callback on a retry without preparing again', ({ expect }) => { + let calls = 0 + const onMentioned = (): Effect.Effect => + Effect.suspend(() => { + calls += 1 + return calls === 1 ? Effect.fail({ retryable: true }) : Effect.void + }) + return Effect.gen(function* () { + const processor = makeJiraEventProcessor({ namespace, bot, handlers: { onMentioned } }) + const mention = fixtureAdmission('comment-created-mention.json') + const first = yield* makeTestDeliveryExecution() + expect(yield* processor.process([mention], first.execution).pipe(Effect.flip)).toEqual( + ProviderEventExecutionFailed.make({ provider: 'jira', retryable: true, safeCode: 'callback_failed' }), + ) + const second = yield* first.retry + expect(Option.map(second.execution.prepared, ({ callbacks }) => callbacks[0].name)).toEqual( + Option.some('onMentioned'), + ) + expect(yield* processor.process([mention], second.execution)).toEqual(ProviderEventHandled.make({})) + expect(yield* Ref.get(second.preparations)).toEqual([]) + expect(calls).toBe(2) + }) + }) + + it.effect('reports a non-retryable callback failure', ({ expect }) => + Effect.gen(function* () { + const onMentioned = () => Effect.fail({ retryability: 'non_retryable' as const }) + const error = yield* process({ onMentioned }, [fixtureAdmission('comment-created-mention.json')]).pipe( + Effect.flip, + ) + expect(error).toEqual( + ProviderEventExecutionFailed.make({ provider: 'jira', retryable: false, safeCode: 'callback_failed' }), + ) + }), + ) + + it.effect('rejects a batch for another namespace or issue', ({ expect }) => + Effect.gen(function* () { + const payload = fixturePayload('comment-created-mention.json') + const onMentioned = vi.fn(() => Effect.void) + expect( + yield* process({ onMentioned }, [admission(payload, 'x', { namespace: 'other' })]).pipe(Effect.flip), + ).toEqual(ProviderEventInvalid.make({ provider: 'jira', reason: 'identity_mismatch' })) + expect( + yield* process({ onMentioned }, [ + admission(payload, 'x', { resourceId: jiraIssueResourceId({ issueId: JiraIssueId.make('99') }) }), + ]).pipe(Effect.flip), + ).toEqual(ProviderEventInvalid.make({ provider: 'jira', reason: 'identity_mismatch' })) + expect(yield* process({ onMentioned }, [admission({ nope: true }, 'x')]).pipe(Effect.flip)).toEqual( + ProviderEventInvalid.make({ provider: 'jira', reason: 'invalid_payload' }), + ) + expect(onMentioned).not.toHaveBeenCalled() + }), + ) +}) From fab46f0140cd1f94582b97573776be7ecfcdd0e5 Mon Sep 17 00:00:00 2001 From: yashodhah Date: Mon, 19 Oct 2026 20:49:57 +0800 Subject: [PATCH 4/9] feat(jira): API client, output processor and JiraBot.make Update and delete are reported unsupported and dropped from jiraSupportedOperations. Co-Authored-By: Claude Sonnet 5.5 --- packages/jira/src/JiraApi.ts | 38 ++++ packages/jira/src/JiraApiLive.ts | 113 ++++++++++++ packages/jira/src/JiraBot.ts | 61 ++++++ packages/jira/src/JiraDeliveryDestination.ts | 9 +- packages/jira/src/JiraDeliveryOutput.ts | 104 +++++++++++ packages/jira/src/index.ts | 4 + packages/jira/test/jira-api-live.vi.test.ts | 174 ++++++++++++++++++ packages/jira/test/jira-bot.vi.test.ts | 81 ++++++++ .../jira/test/jira-delivery-output.vi.test.ts | 147 +++++++++++++++ 9 files changed, 724 insertions(+), 7 deletions(-) create mode 100644 packages/jira/src/JiraApi.ts create mode 100644 packages/jira/src/JiraApiLive.ts create mode 100644 packages/jira/src/JiraBot.ts create mode 100644 packages/jira/src/JiraDeliveryOutput.ts create mode 100644 packages/jira/test/jira-api-live.vi.test.ts create mode 100644 packages/jira/test/jira-bot.vi.test.ts create mode 100644 packages/jira/test/jira-delivery-output.vi.test.ts diff --git a/packages/jira/src/JiraApi.ts b/packages/jira/src/JiraApi.ts new file mode 100644 index 0000000..45a7bac --- /dev/null +++ b/packages/jira/src/JiraApi.ts @@ -0,0 +1,38 @@ +/** The Jira operations the channel needs. Transport, credentials, and decoding stay behind this service. */ +import { Context, type Effect, Schema } from 'effect' + +export const JiraApiOperation = Schema.Literals(['post_comment', 'fetch_issue', 'myself']) +export type JiraApiOperation = typeof JiraApiOperation.Type + +/** + * A failed Jira call. `retryable` is true for transport failures, 429, and 5xx; `retryAfterMs` comes from + * `Retry-After`. It never carries credentials or response bodies. + */ +export class JiraApiError extends Schema.TaggedError()('JiraApiError', { + operation: JiraApiOperation, + retryable: Schema.Boolean, + status: Schema.optionalKey(Schema.Int), + retryAfterMs: Schema.optionalKey( + Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER)), + ), +}) {} + +export type JiraIssueInfo = { + readonly key: string + readonly title: string + readonly description: string | null +} + +export type JiraApiOperations = { + /** Comment on an issue. Each blank-line-separated block of `markdown` becomes one plain-text paragraph. */ + readonly postComment: (input: { + readonly issueKey: string + readonly markdown: string + }) => Effect.Effect<{ readonly commentId: string }, JiraApiError> + readonly fetchIssue: (issueKey: string) => Effect.Effect + /** The account the credentials belong to. */ + readonly myself: () => Effect.Effect<{ readonly accountId: string }, JiraApiError> +} + +/** Application-wide Jira operations; `JiraApiLive` provides them. */ +export class JiraApi extends Context.Service()('@humanlayer/channels-jira/JiraApi') {} diff --git a/packages/jira/src/JiraApiLive.ts b/packages/jira/src/JiraApiLive.ts new file mode 100644 index 0000000..9bfc3b9 --- /dev/null +++ b/packages/jira/src/JiraApiLive.ts @@ -0,0 +1,113 @@ +/** `JiraApi` over Jira Cloud's REST v3, with basic auth (bot email and API token). */ +import { Config, Effect, Layer, Predicate, Redacted, Schema } from 'effect' +import { HttpClient, HttpClientRequest, type HttpClientResponse } from 'effect/http' + +import { JiraApi, JiraApiError, type JiraApiOperation } from './JiraApi' +import { commentPlainText, JiraAdfNode } from './JiraComment' + +const CommentCreated = Schema.Struct({ id: Schema.NonEmptyString }) +const IssueResponse = Schema.Struct({ + key: Schema.String, + fields: Schema.Struct({ + summary: Schema.String, + description: Schema.optionalKey(Schema.NullOr(Schema.Union([Schema.String, JiraAdfNode]))), + }), +}) +const MyselfResponse = Schema.Struct({ accountId: Schema.NonEmptyString }) + +/** One ADF paragraph per blank-line-separated block of text; no markdown is rendered. */ +export const markdownToAdf = (markdown: string) => ({ + version: 1, + type: 'doc', + content: markdown + .split(/\n{2,}/) + .map((block) => block.trim()) + .filter((block) => block.length > 0) + .map((text) => ({ type: 'paragraph', content: [{ type: 'text', text }] })), +}) + +const secondsPattern = /^\d+$/ +const retryAfterMillis = (value: string | undefined) => { + if (Predicate.isUndefined(value) || !secondsPattern.test(value)) return undefined + const milliseconds = Number(value) * 1_000 + return Number.isSafeInteger(milliseconds) ? milliseconds : undefined +} + +const statusError = (operation: JiraApiOperation, response: HttpClientResponse.HttpClientResponse) => { + const retryAfterMs = retryAfterMillis(response.headers['retry-after']) + const retryable = response.status === 429 || response.status >= 500 + if (Predicate.isUndefined(retryAfterMs)) return JiraApiError.make({ operation, retryable, status: response.status }) + return JiraApiError.make({ operation, retryable, status: response.status, retryAfterMs }) +} + +const finalError = (operation: JiraApiOperation) => JiraApiError.make({ operation, retryable: false }) + +/** Reads `JIRA_BASE_URL`, `JIRA_BOT_EMAIL`, and `JIRA_API_TOKEN` (redacted) when the layer is built. */ +export const JiraApiLive: Layer.Layer = Layer.effect( + JiraApi, + Effect.gen(function* () { + const baseUrl = (yield* Config.String('JIRA_BASE_URL')).replace(/\/+$/, '') + const email = yield* Config.String('JIRA_BOT_EMAIL') + const token = yield* Config.Redacted('JIRA_API_TOKEN') + const httpClient = yield* HttpClient.HttpClient + const authorization = () => `Basic ${btoa(`${email}:${Redacted.value(token)}`)}` + + /** Send a request and decode a 2xx JSON body; transport failures are retryable, decode failures are not. */ + const send = ( + operation: JiraApiOperation, + schema: Schema.Codec, + request: HttpClientRequest.HttpClientRequest, + ) => + httpClient + .execute( + request.pipe( + HttpClientRequest.setHeader('authorization', authorization()), + HttpClientRequest.setHeader('accept', 'application/json'), + ), + ) + .pipe( + Effect.mapError(() => JiraApiError.make({ operation, retryable: true })), + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? response.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(schema)), + Effect.mapError(() => finalError(operation)), + ) + : Effect.fail(statusError(operation, response)), + ), + ) + + return JiraApi.of({ + postComment: ({ issueKey, markdown }) => + HttpClientRequest.post(`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`).pipe( + HttpClientRequest.bodyJson({ body: markdownToAdf(markdown) }), + Effect.mapError(() => finalError('post_comment')), + Effect.flatMap((request) => send('post_comment', CommentCreated, request)), + Effect.map(({ id }) => ({ commentId: id })), + Effect.withSpan('jira.api.post_comment'), + ), + fetchIssue: (issueKey) => + send( + 'fetch_issue', + IssueResponse, + HttpClientRequest.get( + `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=summary,description`, + ), + ).pipe( + Effect.map(({ key, fields }) => ({ + key, + title: fields.summary, + description: Predicate.isNullish(fields.description) + ? null + : commentPlainText(fields.description), + })), + Effect.withSpan('jira.api.fetch_issue'), + ), + myself: () => + send('myself', MyselfResponse, HttpClientRequest.get(`${baseUrl}/rest/api/3/myself`)).pipe( + Effect.map(({ accountId }) => ({ accountId })), + Effect.withSpan('jira.api.myself'), + ), + }) + }), +) diff --git a/packages/jira/src/JiraBot.ts b/packages/jira/src/JiraBot.ts new file mode 100644 index 0000000..99e5b11 --- /dev/null +++ b/packages/jira/src/JiraBot.ts @@ -0,0 +1,61 @@ +/** + * This file defines `JiraBot.make`: the Jira provider as `Channels.make` takes it. + */ +import { type ChannelsProvider, type DeliveryMode } from '@humanlayer/channels-delivery' +import { Effect, Schema } from 'effect' +import type { Config, Redacted } from 'effect' + +import type { JiraApi } from './JiraApi' +import { type JiraCallbackHandlers } from './JiraCallbacks' +import { makeJiraOutputProcessor } from './JiraDeliveryOutput' +import { JiraBotConfiguration, makeJiraEventProcessor } from './JiraEventProcessor' +import { makeJiraWebhookProvider } from './JiraWebhookProvider' + +/** + * @property webhookSecret - read when the bot is built, so the bot itself can be declared at the top of a module + * @property deliveryMode - when an issue's mailbox runs and what each batch holds + * @property bot - the account id of this bot, whose mentions start a delivery + * @property site - the Jira site host, used as the installation id + * @property handlers - the callbacks; the application provides `JiraApi` (usually `JiraApiLive`) and anything + * else they need, and the storage supplies `MailboxSubscriptions` + */ +export type MakeOptions = { + readonly webhookSecret: Config.Config> + readonly deliveryMode: DeliveryMode + readonly bot: JiraBotConfiguration | Config.Config + readonly site: Config.Config + readonly handlers: JiraCallbackHandlers +} + +export const make = ( + options: MakeOptions, +): ChannelsProvider<{ + readonly build: never + readonly process: R | JiraApi + readonly error: Config.ConfigError +}> => { + const readBotConfiguration = Schema.is(JiraBotConfiguration)(options.bot) + ? Effect.succeed(options.bot) + : options.bot + + return { + providerName: 'jira', + deliveryMode: options.deliveryMode, + webhookProvider: Effect.fn('jira.bot.build_webhook_provider')(function* ({ namespace }) { + const webhookSecret = yield* options.webhookSecret + const site = yield* options.site + /** + * Split hosts build only this half in the Worker construction phase. Resolve the callback half's + * configuration here too, so it can be discovered and bound for the runtime that runs callbacks. + */ + yield* readBotConfiguration + return makeJiraWebhookProvider({ namespace, webhookSecret, site }) + }), + eventProcessor: Effect.fn('jira.bot.build_event_processor')(function* ({ namespace }) { + const bot = yield* readBotConfiguration + return makeJiraEventProcessor({ namespace, bot, handlers: options.handlers }) + }), + /** Sends a handed-off delivery's output: the comments it posts. */ + outputProcessor: ({ namespace }) => makeJiraOutputProcessor({ namespace }), + } +} diff --git a/packages/jira/src/JiraDeliveryDestination.ts b/packages/jira/src/JiraDeliveryDestination.ts index b5473c1..bcf499e 100644 --- a/packages/jira/src/JiraDeliveryDestination.ts +++ b/packages/jira/src/JiraDeliveryDestination.ts @@ -19,10 +19,5 @@ export type JiraDeliveryDestination = typeof JiraDeliveryDestination.Type /** JSON codec for storing the destination. */ export const JiraDeliveryDestinationJson = Schema.toCodecJson(JiraDeliveryDestination) -/** The output operations a Jira issue supports: comments only, no reactions or activity. */ -export const jiraSupportedOperations: ReadonlyArray = [ - 'PresentOutcome', - 'CreateMessage', - 'UpdateMessage', - 'DeleteMessage', -] +/** The output operations a Jira issue supports: new comments only: no edits, deletes, reactions, or activity. */ +export const jiraSupportedOperations: ReadonlyArray = ['PresentOutcome', 'CreateMessage'] diff --git a/packages/jira/src/JiraDeliveryOutput.ts b/packages/jira/src/JiraDeliveryOutput.ts new file mode 100644 index 0000000..f959d5e --- /dev/null +++ b/packages/jira/src/JiraDeliveryOutput.ts @@ -0,0 +1,104 @@ +/** + * This file sends a Jira delivery's saved output to Jira. + * + * - `PresentOutcome` comments its Markdown on the issue, with any `awaitingInput` options listed after it. + * Without Markdown it makes no comment. + * - `CreateMessage` comments. Its receipt holds the comment id. + * - Everything else (`SetActivity`, `SetMessageReaction`, `UpdateMessage`, `DeleteMessage`, `RenderPlan`, + * `AddExternalLink`) is not in `jiraSupportedOperations` and fails as final `unsupported_operation`. + * + * A comment is at-least-once: when Jira accepts it but the attempt dies before the store saves the result, + * the next attempt comments again, and the attempt carries `hadAmbiguousAttempt` (logged). Jira offers no + * idempotency key for comments. + */ +import { + DeliveryOutputApplied, + DeliveryOutputFailed, + type DeliveryOutcome, + type ProviderOutputAttempt, + type ProviderOutputProcessor, +} from '@humanlayer/channels-delivery' +import { Array as Arr, Effect, Match, Predicate, Schema } from 'effect' + +import { JiraApi } from './JiraApi' +import { JiraDeliveryDestinationJson, jiraPresentationVersion } from './JiraDeliveryDestination' + +/** What Jira made for an operation: the comment it posted. Saved by the store, read only here. */ +export const JiraOutputReceipt = Schema.TaggedStruct('JiraComment', { comment: Schema.Struct({ id: Schema.String }) }) +export type JiraOutputReceipt = typeof JiraOutputReceipt.Type +export const JiraOutputReceiptJson = Schema.toCodecJson(JiraOutputReceipt) + +const failed = (safeCode: string, retryable: boolean, retryAfterMs?: number) => + new DeliveryOutputFailed( + Predicate.isUndefined(retryAfterMs) + ? { provider: 'jira', retryable, safeCode } + : { provider: 'jira', retryable, safeCode, retryAfterMs }, + ) + +const unsupported = () => failed('unsupported_operation', false) + +/** The comment a result posts: its Markdown, then any options as a list. */ +const outcomeMarkdown = (outcome: DeliveryOutcome, markdown: string) => + Match.value(outcome).pipe( + Match.tag('AwaitingInput', ({ options }) => + Arr.match(options ?? [], { + onEmpty: () => markdown, + onNonEmpty: (listed) => `${markdown}\n\n${listed.map((option) => `- ${option}`).join('\n')}`, + }), + ), + Match.orElse(() => markdown), + ) + +/** Comment on the issue; the receipt holds the comment id. */ +const postComment = Effect.fn('jira.output.post_comment')(function* (issueKey: string, markdown: string) { + const api = yield* JiraApi + const posted = yield* api.postComment({ issueKey, markdown }).pipe( + Effect.tapError((error) => Effect.logWarning('Jira output comment failed', error)), + Effect.mapError((error) => failed('jira_comment_failed', error.retryable, error.retryAfterMs)), + ) + const receipt = yield* Schema.encodeEffect(JiraOutputReceiptJson)( + JiraOutputReceipt.make({ comment: { id: posted.commentId } }), + ).pipe( + Effect.tapError((error) => Effect.logWarning('Jira output receipt could not be encoded', error)), + Effect.mapError(() => failed('receipt_unencodable', false)), + ) + return DeliveryOutputApplied.make({ receipt }) +}) + +/** Build Jira's output half. It sends through the `JiraApi` the host supplies while it runs. */ +export const makeJiraOutputProcessor = Effect.fn('jira.make_output_processor')(function* (input: { + readonly namespace: string +}) { + const process = Effect.fn('jira.process_delivery_output')( + function* (attempt: ProviderOutputAttempt) { + if (attempt.prepared.presentationVersion !== jiraPresentationVersion) { + return yield* failed('unsupported_presentation_version', false) + } + const destination = yield* Schema.decodeEffect(JiraDeliveryDestinationJson)( + attempt.prepared.destination, + ).pipe( + Effect.tapError((error) => Effect.logWarning('Jira output destination could not be read', error)), + Effect.mapError(() => failed('destination_invalid', false)), + ) + if (attempt.hadAmbiguousAttempt) { + yield* Effect.logWarning('Jira output retried after an attempt that may have been applied') + } + const issueKey = destination.issue.key + yield* Effect.annotateCurrentSpan({ 'jira.issue_key': issueKey }) + + return yield* Match.value(attempt.operation).pipe( + Match.tag('PresentOutcome', (operation) => + Predicate.isUndefined(operation.markdown) + ? Effect.succeed(DeliveryOutputApplied.make({})) + : postComment(issueKey, outcomeMarkdown(operation.outcome, operation.markdown)), + ), + Match.tag('CreateMessage', ({ markdown }) => postComment(issueKey, markdown)), + Match.orElse(() => Effect.fail(unsupported())), + ) + }, + (effect, attempt) => + effect.pipe(Effect.annotateLogs({ delivery_id: attempt.deliveryId, operation_id: attempt.operationId })), + ) + + return { namespace: input.namespace, providerName: 'jira', process } satisfies ProviderOutputProcessor +}) diff --git a/packages/jira/src/index.ts b/packages/jira/src/index.ts index f45ebda..0842948 100644 --- a/packages/jira/src/index.ts +++ b/packages/jira/src/index.ts @@ -1,9 +1,13 @@ +export * from './JiraApi' +export * from './JiraApiLive' export * from './JiraCallbackEvents' export * from './JiraCallbacks' export * from './JiraComment' export * from './JiraDeliveryDestination' +export * from './JiraDeliveryOutput' export * from './JiraEventProcessor' export * from './JiraIdentity' export * from './JiraWebhookProvider' export * from './JiraWebhookSchemas' export * from './JiraWebhookSignature' +export * as JiraBot from './JiraBot' diff --git a/packages/jira/test/jira-api-live.vi.test.ts b/packages/jira/test/jira-api-live.vi.test.ts new file mode 100644 index 0000000..e6f2647 --- /dev/null +++ b/packages/jira/test/jira-api-live.vi.test.ts @@ -0,0 +1,174 @@ +import { describe, it } from '@effect/vitest' +import { Config, ConfigProvider, Effect, Layer, Queue } from 'effect' +import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/http' + +import { JiraApi } from '../src/JiraApi' +import { JiraApiLive } from '../src/JiraApiLive' + +const testCredential = 'fake-credential-value' +const config = { + JIRA_BASE_URL: 'https://example.atlassian.net', + JIRA_BOT_EMAIL: 'bot@example.com', + JIRA_API_TOKEN: testCredential, +} + +const makeLayer = (httpClient: HttpClient.HttpClient) => + JiraApiLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, httpClient), + ConfigProvider.layer(ConfigProvider.fromUnknown(config)), + ), + ), + ) + +const basicAuth = `Basic ${btoa(`bot@example.com:${testCredential}`)}` + +describe('JiraApiLive', () => { + it.effect('posts a comment as an ADF document with one paragraph per block', ({ expect }) => + Effect.gen(function* () { + const seen = yield* Queue.unbounded<{ method: string; url: string; auth: string | null; body: string }>() + const httpClient = HttpClient.make((request) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + yield* Queue.offer(seen, { + method: web.method, + url: web.url, + auth: web.headers.get('authorization'), + body: yield* Effect.promise(() => web.text()), + }) + return HttpClientResponse.fromWeb(request, Response.json({ id: '10001' }, { status: 201 })) + }), + ) + const api = yield* JiraApi.pipe(Effect.provide(makeLayer(httpClient))) + const posted = yield* api.postComment({ issueKey: 'ABC-1', markdown: 'a\n\nb' }) + const call = yield* Queue.take(seen) + + expect(posted).toEqual({ commentId: '10001' }) + expect(call.method).toBe('POST') + expect(call.url).toBe('https://example.atlassian.net/rest/api/3/issue/ABC-1/comment') + expect(call.auth).toBe(basicAuth) + expect(JSON.parse(call.body)).toEqual({ + body: { + version: 1, + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'a' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'b' }] }, + ], + }, + }) + }), + ) + + it.effect('maps an issue to key, title and plain-text description', ({ expect }) => + Effect.gen(function* () { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + key: 'ABC-1', + fields: { + summary: 'Add rate limiting', + description: { + version: 1, + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Please' }] }], + }, + }, + }), + ), + ), + ) + const api = yield* JiraApi.pipe(Effect.provide(makeLayer(httpClient))) + expect(yield* api.fetchIssue('ABC-1')).toEqual({ + key: 'ABC-1', + title: 'Add rate limiting', + description: 'Please', + }) + }), + ) + + it.effect('maps a missing description to null and reads myself', ({ expect }) => + Effect.gen(function* () { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url.endsWith('/myself') + ? Response.json({ accountId: '5b10', displayName: 'bot' }) + : Response.json({ key: 'ABC-2', fields: { summary: 'S', description: null } }), + ), + ), + ) + const api = yield* JiraApi.pipe(Effect.provide(makeLayer(httpClient))) + expect((yield* api.fetchIssue('ABC-2')).description).toBeNull() + expect(yield* api.myself()).toEqual({ accountId: '5b10' }) + }), + ) + + it.effect('maps 429 to a retryable error that honors Retry-After', ({ expect }) => + Effect.gen(function* () { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response('slow down', { status: 429, headers: { 'retry-after': '7' } }), + ), + ), + ) + const api = yield* JiraApi.pipe(Effect.provide(makeLayer(httpClient))) + const error = yield* api.postComment({ issueKey: 'ABC-1', markdown: 'x' }).pipe(Effect.flip) + expect(error).toMatchObject({ + operation: 'post_comment', + retryable: true, + retryAfterMs: 7_000, + status: 429, + }) + }), + ) + + it.effect('maps 5xx to retryable and other 4xx to non-retryable, never leaking the token', ({ expect }) => + Effect.gen(function* () { + const respond = (status: number) => + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response('no', { status }))), + ) + const failure = (status: number) => + Effect.gen(function* () { + const api = yield* JiraApi.pipe(Effect.provide(makeLayer(respond(status)))) + return yield* api.myself().pipe(Effect.flip) + }) + const unavailable = yield* failure(503) + const forbidden = yield* failure(403) + expect(unavailable.retryable).toBe(true) + expect(forbidden.retryable).toBe(false) + expect(JSON.stringify(unavailable) + String(unavailable) + JSON.stringify(forbidden)).not.toContain( + 'fake-credential', + ) + }), + ) + + it.effect('fails to build without configuration', ({ expect }) => + Effect.gen(function* () { + const httpClient = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}))), + ) + const error = yield* JiraApi.pipe( + Effect.provide( + JiraApiLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, httpClient), + ConfigProvider.layer(ConfigProvider.fromUnknown({})), + ), + ), + ), + ), + Effect.flip, + ) + expect(error).toBeInstanceOf(Config.ConfigError) + }), + ) +}) diff --git a/packages/jira/test/jira-bot.vi.test.ts b/packages/jira/test/jira-bot.vi.test.ts new file mode 100644 index 0000000..5ddf0ea --- /dev/null +++ b/packages/jira/test/jira-bot.vi.test.ts @@ -0,0 +1,81 @@ +import { describe, it } from '@effect/vitest' +import { QueueDeliveryMode } from '@humanlayer/channels-delivery' +import { Config, ConfigProvider, Effect, Redacted, Schema } from 'effect' + +import { JiraAccountId, JiraBot } from '../src' + +const noConfiguration = ConfigProvider.layer(ConfigProvider.fromUnknown({})) + +describe('JiraBot.make', () => { + it.effect('names the provider jira and has an output processor', ({ expect }) => + Effect.gen(function* () { + const provider = JiraBot.make({ + webhookSecret: Config.succeed(Redacted.make('secret')), + deliveryMode: QueueDeliveryMode.make({}), + bot: { accountId: Schema.decodeSync(JiraAccountId)('5b10') }, + site: Config.succeed('example.atlassian.net'), + handlers: {}, + }) + expect(provider.providerName).toBe('jira') + const output = yield* ( + provider.outputProcessor?.({ namespace: 'jira-bot-test' }) ?? Effect.die('no output processor') + ).pipe(Effect.scoped) + expect(output.providerName).toBe('jira') + }), + ) + + it.effect('builds the webhook half with only webhookSecret, bot and site configuration', ({ expect }) => + Effect.gen(function* () { + const provider = JiraBot.make({ + webhookSecret: Config.String('JIRA_WEBHOOK_SECRET').pipe(Config.map(Redacted.make)), + deliveryMode: QueueDeliveryMode.make({}), + bot: Config.schema(Schema.Struct({ accountId: JiraAccountId }), 'JIRA_BOT'), + site: Config.String('JIRA_SITE'), + handlers: {}, + }) + const provided = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + JIRA_WEBHOOK_SECRET: 's', + JIRA_BOT: { accountId: '5b10' }, + JIRA_SITE: 'example.atlassian.net', + }), + ) + const webhook = yield* provider + .webhookProvider({ namespace: 'jira-bot-test' }) + .pipe(Effect.scoped, Effect.provide(provided)) + expect(webhook.providerName).toBe('jira') + }), + ) + + it.effect('reads the bot configuration while building the webhook half', ({ expect }) => + Effect.gen(function* () { + const provider = JiraBot.make({ + webhookSecret: Config.succeed(Redacted.make('s')), + deliveryMode: QueueDeliveryMode.make({}), + bot: Config.schema(Schema.Struct({ accountId: JiraAccountId }), 'JIRA_BOT'), + site: Config.succeed('example.atlassian.net'), + handlers: {}, + }) + const error = yield* provider + .webhookProvider({ namespace: 'jira-bot-test' }) + .pipe(Effect.scoped, Effect.provide(noConfiguration), Effect.flip) + expect(error).toBeInstanceOf(Config.ConfigError) + }), + ) + + it.effect('builds the event processor from a literal bot without configuration', ({ expect }) => + Effect.gen(function* () { + const provider = JiraBot.make({ + webhookSecret: Config.succeed(Redacted.make('s')), + deliveryMode: QueueDeliveryMode.make({}), + bot: { accountId: Schema.decodeSync(JiraAccountId)('5b10') }, + site: Config.succeed('example.atlassian.net'), + handlers: {}, + }) + const events = yield* provider + .eventProcessor({ namespace: 'jira-bot-test' }) + .pipe(Effect.scoped, Effect.provide(noConfiguration)) + expect(events.providerName).toBe('jira') + }), + ) +}) diff --git a/packages/jira/test/jira-delivery-output.vi.test.ts b/packages/jira/test/jira-delivery-output.vi.test.ts new file mode 100644 index 0000000..eb03807 --- /dev/null +++ b/packages/jira/test/jira-delivery-output.vi.test.ts @@ -0,0 +1,147 @@ +/** Jira's output half over a recording `JiraApi`: comments become Jira calls, the rest is unsupported. */ +import { describe, it } from '@effect/vitest' +import { + BatchId, + CreateMessage, + ProviderDeleteMessage, + DeliveryActivity, + DeliveryOperationId, + DeliveryOutcome, + MessageId, + PreparedDeliveryCallback, + ProviderOutputAttempt, + ProviderPresentOutcome, + SetActivity, + makeDeliveryId, + type ProviderOutputOperation, +} from '@humanlayer/channels-delivery' +import { Effect, Layer, Ref, Result, Schema } from 'effect' + +import { + JiraApi, + JiraApiError, + JiraDeliveryDestination, + JiraIssueId, + JiraDeliveryDestinationJson, + jiraPresentationVersion, + jiraSupportedOperations, + makeJiraOutputProcessor, +} from '../src' + +const destination = JiraDeliveryDestination.cases.JiraIssue.make({ + issue: { id: Schema.decodeSync(JiraIssueId)('10042'), key: 'ABC-7', mailboxKey: 'jira:v1:mailbox' }, +}) + +const prepared = PreparedDeliveryCallback.make({ + name: 'onMentioned', + presentationVersion: jiraPresentationVersion, + destination: Schema.encodeSync(JiraDeliveryDestinationJson)(destination), + supportedOperations: jiraSupportedOperations, + reactionTargets: [], +}) + +const attempt = (operation: ProviderOutputOperation, hadAmbiguousAttempt = false) => + ProviderOutputAttempt.make({ + deliveryId: makeDeliveryId({ mailboxKey: 'jira:v1:mailbox', batchId: BatchId.make('batch-1') }), + operationId: DeliveryOperationId.make('outcome'), + attempt: 1, + hadAmbiguousAttempt, + idempotencyKey: '00000000-0000-4000-8000-000000000001', + prepared, + operation, + }) + +const run = (operation: ProviderOutputOperation, fail?: JiraApiError) => + Effect.gen(function* () { + const calls = yield* Ref.make>([]) + const api = Layer.mock(JiraApi, { + postComment: ({ issueKey, markdown }) => + Ref.update(calls, (all) => [...all, `${issueKey}: ${markdown}`]).pipe( + Effect.andThen(fail === undefined ? Effect.succeed({ commentId: '900' }) : Effect.fail(fail)), + ), + }) + const processor = yield* makeJiraOutputProcessor({ namespace: 'jira-output-test' }) + const result = yield* processor.process(attempt(operation)).pipe(Effect.provide(api), Effect.result) + return { result, calls: yield* Ref.get(calls) } + }) + +const completed = DeliveryOutcome.cases.Completed.make({}) + +describe('Jira delivery output', () => { + it.effect('PresentOutcome posts one comment on the destination issue and returns a receipt', ({ expect }) => + Effect.gen(function* () { + const { result, calls } = yield* run( + ProviderPresentOutcome.make({ clearActivity: true, outcome: completed, markdown: 'Done.' }), + ) + expect(calls).toEqual(['ABC-7: Done.']) + expect(Result.isSuccess(result)).toBe(true) + expect(Result.getOrThrow(result)).toMatchObject({ + _tag: 'DeliveryOutputApplied', + receipt: { comment: { id: '900' } }, + }) + }), + ) + + it.effect('PresentOutcome without markdown posts nothing', ({ expect }) => + Effect.gen(function* () { + const { result, calls } = yield* run( + ProviderPresentOutcome.make({ clearActivity: false, outcome: completed }), + ) + expect(calls).toEqual([]) + expect(Result.isSuccess(result)).toBe(true) + }), + ) + + it.effect('CreateMessage posts a comment', ({ expect }) => + Effect.gen(function* () { + const { result, calls } = yield* run(CreateMessage.make({ messageId: MessageId.make('m'), markdown: 'Hi' })) + expect(calls).toEqual(['ABC-7: Hi']) + expect(Result.isSuccess(result)).toBe(true) + }), + ) + + it.effect('rejects activity and delete as unsupported and not retryable', ({ expect }) => + Effect.gen(function* () { + for (const operation of [ + SetActivity.make({ activity: DeliveryActivity.cases.Idle.make({}) }), + ProviderDeleteMessage.make({ messageId: MessageId.make('m'), reference: {} }), + ]) { + const { result, calls } = yield* run(operation) + expect(calls).toEqual([]) + expect(Result.getOrThrow(Result.flip(result))).toMatchObject({ + provider: 'jira', + retryable: false, + safeCode: 'unsupported_operation', + }) + } + }), + ) + + it.effect('maps API failures to retryable or final, with Retry-After', ({ expect }) => + Effect.gen(function* () { + const operation = ProviderPresentOutcome.make({ clearActivity: false, outcome: completed, markdown: 'x' }) + const retry = yield* run( + operation, + JiraApiError.make({ operation: 'post_comment', retryable: true, status: 503, retryAfterMs: 5_000 }), + ) + expect(Result.getOrThrow(Result.flip(retry.result))).toMatchObject({ retryable: true, retryAfterMs: 5_000 }) + const final = yield* run( + operation, + JiraApiError.make({ operation: 'post_comment', retryable: false, status: 403 }), + ) + expect(Result.getOrThrow(Result.flip(final.result))).toMatchObject({ retryable: false }) + }), + ) + + it.effect('rejects a destination version it does not know', ({ expect }) => + Effect.gen(function* () { + const processor = yield* makeJiraOutputProcessor({ namespace: 'jira-output-test' }) + const old = ProviderOutputAttempt.make({ + ...attempt(CreateMessage.make({ messageId: MessageId.make('m'), markdown: 'x' })), + prepared: { ...prepared, presentationVersion: jiraPresentationVersion + 1 }, + }) + const result = yield* processor.process(old).pipe(Effect.provide(Layer.mock(JiraApi, {})), Effect.result) + expect(Result.getOrThrow(Result.flip(result))).toMatchObject({ retryable: false }) + }), + ) +}) From 3d0e5378c94e01a8b64e6dec02de29247a760534 Mon Sep 17 00:00:00 2001 From: yashodhah Date: Mon, 19 Oct 2026 20:57:47 +0800 Subject: [PATCH 5/9] fix(jira): port to the v0.1.0 delivery API v0.1.0 saves one callback per prepared invocation (`callback` name, no `callbacks`/`callbackIndex`) and its ProviderOutputProcessor is not generic. Prepare and rerun the single `onMentioned` callback as GitHubEventProcessor does at the tag, and have the output half read JiraApi when it is built and provide it per attempt, as GitHubDeliveryOutput does. JiraBot.make now declares JiraApi as a build requirement. Co-Authored-By: Claude Opus 5.5 --- packages/jira/src/JiraBot.ts | 5 +++-- packages/jira/src/JiraDeliveryOutput.ts | 14 +++++++++++--- packages/jira/src/JiraEventProcessor.ts | 17 ++++++----------- packages/jira/test/jira-bot.vi.test.ts | 14 ++++++++------ .../jira/test/jira-delivery-output.vi.test.ts | 16 +++++++++------- .../jira/test/jira-event-processing.vi.test.ts | 17 ++++++----------- 6 files changed, 43 insertions(+), 40 deletions(-) diff --git a/packages/jira/src/JiraBot.ts b/packages/jira/src/JiraBot.ts index 99e5b11..661c90b 100644 --- a/packages/jira/src/JiraBot.ts +++ b/packages/jira/src/JiraBot.ts @@ -17,7 +17,8 @@ import { makeJiraWebhookProvider } from './JiraWebhookProvider' * @property bot - the account id of this bot, whose mentions start a delivery * @property site - the Jira site host, used as the installation id * @property handlers - the callbacks; the application provides `JiraApi` (usually `JiraApiLive`) and anything - * else they need, and the storage supplies `MailboxSubscriptions` + * else they need, and the storage supplies `MailboxSubscriptions`. The output half reads the same `JiraApi` + * when it is built, so `JiraApi` is a build requirement too. */ export type MakeOptions = { readonly webhookSecret: Config.Config> @@ -30,7 +31,7 @@ export type MakeOptions = { export const make = ( options: MakeOptions, ): ChannelsProvider<{ - readonly build: never + readonly build: JiraApi readonly process: R | JiraApi readonly error: Config.ConfigError }> => { diff --git a/packages/jira/src/JiraDeliveryOutput.ts b/packages/jira/src/JiraDeliveryOutput.ts index f959d5e..7dccf25 100644 --- a/packages/jira/src/JiraDeliveryOutput.ts +++ b/packages/jira/src/JiraDeliveryOutput.ts @@ -65,10 +65,15 @@ const postComment = Effect.fn('jira.output.post_comment')(function* (issueKey: s return DeliveryOutputApplied.make({ receipt }) }) -/** Build Jira's output half. It sends through the `JiraApi` the host supplies while it runs. */ +/** + * Build Jira's output half. It reads the `JiraApi` the host supplies when the half is built and sends + * through it, so the processor itself needs nothing while it runs. + */ export const makeJiraOutputProcessor = Effect.fn('jira.make_output_processor')(function* (input: { readonly namespace: string }) { + const jiraApi = yield* JiraApi + const process = Effect.fn('jira.process_delivery_output')( function* (attempt: ProviderOutputAttempt) { if (attempt.prepared.presentationVersion !== jiraPresentationVersion) { @@ -97,8 +102,11 @@ export const makeJiraOutputProcessor = Effect.fn('jira.make_output_processor')(f ) }, (effect, attempt) => - effect.pipe(Effect.annotateLogs({ delivery_id: attempt.deliveryId, operation_id: attempt.operationId })), + effect.pipe( + Effect.annotateLogs({ delivery_id: attempt.deliveryId, operation_id: attempt.operationId }), + Effect.provideService(JiraApi, jiraApi), + ), ) - return { namespace: input.namespace, providerName: 'jira', process } satisfies ProviderOutputProcessor + return { namespace: input.namespace, providerName: 'jira', process } satisfies ProviderOutputProcessor }) diff --git a/packages/jira/src/JiraEventProcessor.ts b/packages/jira/src/JiraEventProcessor.ts index 82cd69a..5f9842f 100644 --- a/packages/jira/src/JiraEventProcessor.ts +++ b/packages/jira/src/JiraEventProcessor.ts @@ -4,7 +4,6 @@ import { type DeliveryAdmission, type DeliveryAdmissionBatch, type DeliveryContext, - PreparedDeliveryCallback, PreparedDeliveryInvocation, type ProviderDeliveryExecution, ProviderEventExecutionFailed, @@ -151,17 +150,17 @@ const prepareInvocation = Effect.fn('jira.prepare_delivery')(function* ( Effect.tapError((error) => Effect.logError('Jira delivery destination could not be encoded', error)), Effect.mapError(() => nonRetryableFailure('delivery_destination_unencodable')), ) - const step = PreparedDeliveryCallback.make({ - name: 'onMentioned', + const prepared = PreparedDeliveryInvocation.make({ + callback: 'onMentioned', presentationVersion: jiraPresentationVersion, destination, supportedOperations: jiraSupportedOperations, reactionTargets: [], }) - return yield* execution.prepare(PreparedDeliveryInvocation.make({ callbacks: [step] })).pipe( + return yield* execution.prepare(prepared).pipe( Effect.tapError((error) => Effect.logError('Jira delivery preparation failed', error).pipe( - Effect.annotateLogs({ deliveryId: execution.deliveryId, callbacks: [step.name] }), + Effect.annotateLogs({ deliveryId: execution.deliveryId, callback: prepared.callback }), ), ), Effect.catchTags({ @@ -175,14 +174,11 @@ const prepareInvocation = Effect.fn('jira.prepare_delivery')(function* ( const runPreparedInvocation = Effect.fn('jira.run_prepared_invocation')(function* (input: { readonly callbacks: JiraCallbackOperations readonly prepared: PreparedDeliveryInvocation - readonly callbackIndex: number readonly batch: JiraBatchEvents readonly delivery: DeliveryContext }) { - const step = input.prepared.callbacks[input.callbackIndex] - if (Predicate.isUndefined(step)) return yield* nonRetryableFailure('prepared_callback_missing') - const annotations = { deliveryId: input.delivery.deliveryId, callback: step.name } - yield* Schema.decodeUnknownEffect(JiraCallbackName)(step.name).pipe( + const annotations = { deliveryId: input.delivery.deliveryId, callback: input.prepared.callback } + yield* Schema.decodeUnknownEffect(JiraCallbackName)(input.prepared.callback).pipe( Effect.tapError((error) => Effect.logError('Prepared Jira callback is unknown', error).pipe(Effect.annotateLogs(annotations)), ), @@ -223,7 +219,6 @@ const processJiraBatch = (options: JiraEventProcessorOptions, callbacks: Jira runPreparedInvocation({ callbacks, prepared, - callbackIndex: execution.callbackIndex, batch, delivery: execution.context, }) diff --git a/packages/jira/test/jira-bot.vi.test.ts b/packages/jira/test/jira-bot.vi.test.ts index 5ddf0ea..87355cf 100644 --- a/packages/jira/test/jira-bot.vi.test.ts +++ b/packages/jira/test/jira-bot.vi.test.ts @@ -1,10 +1,12 @@ import { describe, it } from '@effect/vitest' import { QueueDeliveryMode } from '@humanlayer/channels-delivery' -import { Config, ConfigProvider, Effect, Redacted, Schema } from 'effect' +import { Config, ConfigProvider, Effect, Layer, Redacted, Schema } from 'effect' -import { JiraAccountId, JiraBot } from '../src' +import { JiraAccountId, JiraApi, JiraBot } from '../src' const noConfiguration = ConfigProvider.layer(ConfigProvider.fromUnknown({})) +/** `JiraApi` is a build requirement of the whole provider; only the output half uses it. */ +const unusedJiraApi = Layer.mock(JiraApi, {}) describe('JiraBot.make', () => { it.effect('names the provider jira and has an output processor', ({ expect }) => @@ -19,7 +21,7 @@ describe('JiraBot.make', () => { expect(provider.providerName).toBe('jira') const output = yield* ( provider.outputProcessor?.({ namespace: 'jira-bot-test' }) ?? Effect.die('no output processor') - ).pipe(Effect.scoped) + ).pipe(Effect.scoped, Effect.provide(unusedJiraApi)) expect(output.providerName).toBe('jira') }), ) @@ -42,7 +44,7 @@ describe('JiraBot.make', () => { ) const webhook = yield* provider .webhookProvider({ namespace: 'jira-bot-test' }) - .pipe(Effect.scoped, Effect.provide(provided)) + .pipe(Effect.scoped, Effect.provide(Layer.merge(provided, unusedJiraApi))) expect(webhook.providerName).toBe('jira') }), ) @@ -58,7 +60,7 @@ describe('JiraBot.make', () => { }) const error = yield* provider .webhookProvider({ namespace: 'jira-bot-test' }) - .pipe(Effect.scoped, Effect.provide(noConfiguration), Effect.flip) + .pipe(Effect.scoped, Effect.provide(Layer.merge(noConfiguration, unusedJiraApi)), Effect.flip) expect(error).toBeInstanceOf(Config.ConfigError) }), ) @@ -74,7 +76,7 @@ describe('JiraBot.make', () => { }) const events = yield* provider .eventProcessor({ namespace: 'jira-bot-test' }) - .pipe(Effect.scoped, Effect.provide(noConfiguration)) + .pipe(Effect.scoped, Effect.provide(Layer.merge(noConfiguration, unusedJiraApi))) expect(events.providerName).toBe('jira') }), ) diff --git a/packages/jira/test/jira-delivery-output.vi.test.ts b/packages/jira/test/jira-delivery-output.vi.test.ts index eb03807..6904243 100644 --- a/packages/jira/test/jira-delivery-output.vi.test.ts +++ b/packages/jira/test/jira-delivery-output.vi.test.ts @@ -8,7 +8,7 @@ import { DeliveryOperationId, DeliveryOutcome, MessageId, - PreparedDeliveryCallback, + PreparedDeliveryInvocation, ProviderOutputAttempt, ProviderPresentOutcome, SetActivity, @@ -32,8 +32,8 @@ const destination = JiraDeliveryDestination.cases.JiraIssue.make({ issue: { id: Schema.decodeSync(JiraIssueId)('10042'), key: 'ABC-7', mailboxKey: 'jira:v1:mailbox' }, }) -const prepared = PreparedDeliveryCallback.make({ - name: 'onMentioned', +const prepared = PreparedDeliveryInvocation.make({ + callback: 'onMentioned', presentationVersion: jiraPresentationVersion, destination: Schema.encodeSync(JiraDeliveryDestinationJson)(destination), supportedOperations: jiraSupportedOperations, @@ -60,8 +60,8 @@ const run = (operation: ProviderOutputOperation, fail?: JiraApiError) => Effect.andThen(fail === undefined ? Effect.succeed({ commentId: '900' }) : Effect.fail(fail)), ), }) - const processor = yield* makeJiraOutputProcessor({ namespace: 'jira-output-test' }) - const result = yield* processor.process(attempt(operation)).pipe(Effect.provide(api), Effect.result) + const processor = yield* makeJiraOutputProcessor({ namespace: 'jira-output-test' }).pipe(Effect.provide(api)) + const result = yield* processor.process(attempt(operation)).pipe(Effect.result) return { result, calls: yield* Ref.get(calls) } }) @@ -135,12 +135,14 @@ describe('Jira delivery output', () => { it.effect('rejects a destination version it does not know', ({ expect }) => Effect.gen(function* () { - const processor = yield* makeJiraOutputProcessor({ namespace: 'jira-output-test' }) + const processor = yield* makeJiraOutputProcessor({ namespace: 'jira-output-test' }).pipe( + Effect.provide(Layer.mock(JiraApi, {})), + ) const old = ProviderOutputAttempt.make({ ...attempt(CreateMessage.make({ messageId: MessageId.make('m'), markdown: 'x' })), prepared: { ...prepared, presentationVersion: jiraPresentationVersion + 1 }, }) - const result = yield* processor.process(old).pipe(Effect.provide(Layer.mock(JiraApi, {})), Effect.result) + const result = yield* processor.process(old).pipe(Effect.result) expect(Result.getOrThrow(Result.flip(result))).toMatchObject({ retryable: false }) }), ) diff --git a/packages/jira/test/jira-event-processing.vi.test.ts b/packages/jira/test/jira-event-processing.vi.test.ts index bf0b608..8ba32fb 100644 --- a/packages/jira/test/jira-event-processing.vi.test.ts +++ b/packages/jira/test/jira-event-processing.vi.test.ts @@ -2,7 +2,6 @@ import { describe, it } from '@effect/vitest' import { DeliveryAdmission, deliveryMailboxKey, - PreparedDeliveryCallback, PreparedDeliveryInvocation, ProviderEventExecutionFailed, ProviderEventHandled, @@ -87,15 +86,11 @@ describe('Jira event batch processing', () => { expect(event?.eventId).toBe('webhook-1') expect(preparations).toEqual([ PreparedDeliveryInvocation.make({ - callbacks: [ - PreparedDeliveryCallback.make({ - name: 'onMentioned', - presentationVersion: 1, - destination: { _tag: 'JiraIssue', issue: { id: '10042', key: 'ABC-7', mailboxKey } }, - supportedOperations: jiraSupportedOperations, - reactionTargets: [], - }), - ], + callback: 'onMentioned', + presentationVersion: 1, + destination: { _tag: 'JiraIssue', issue: { id: '10042', key: 'ABC-7', mailboxKey } }, + supportedOperations: jiraSupportedOperations, + reactionTargets: [], }), ]) }), @@ -190,7 +185,7 @@ describe('Jira event batch processing', () => { ProviderEventExecutionFailed.make({ provider: 'jira', retryable: true, safeCode: 'callback_failed' }), ) const second = yield* first.retry - expect(Option.map(second.execution.prepared, ({ callbacks }) => callbacks[0].name)).toEqual( + expect(Option.map(second.execution.prepared, ({ callback }) => callback)).toEqual( Option.some('onMentioned'), ) expect(yield* processor.process([mention], second.execution)).toEqual(ProviderEventHandled.make({})) From ea6c5ef620260fea7716942bb6a967f77d70ce26 Mon Sep 17 00:00:00 2001 From: yashodhah Date: Mon, 19 Oct 2026 21:02:35 +0800 Subject: [PATCH 6/9] fix(jira): build the Jira API layer inside each half, like GitHubBot JiraBot.make takes an optional `jiraApi` layer (default JiraApiLive over Fetch), builds it in all three halves, supplies it to the handlers and the output processor, and declares `build: ApiRequirements` and `process: Exclude` as GitHubBot does at v0.1.0. Also test that a saved callback that is unknown, unconfigured or unbuildable fails. Co-Authored-By: Claude Opus 5.5 --- packages/jira/src/JiraBot.ts | 52 +++++++++--- packages/jira/test/jira-bot.vi.test.ts | 85 +++++++++++++++++-- .../test/jira-event-processing.vi.test.ts | 39 +++++++++ 3 files changed, 154 insertions(+), 22 deletions(-) diff --git a/packages/jira/src/JiraBot.ts b/packages/jira/src/JiraBot.ts index 661c90b..9f311fe 100644 --- a/packages/jira/src/JiraBot.ts +++ b/packages/jira/src/JiraBot.ts @@ -1,43 +1,58 @@ /** * This file defines `JiraBot.make`: the Jira provider as `Channels.make` takes it. */ -import { type ChannelsProvider, type DeliveryMode } from '@humanlayer/channels-delivery' -import { Effect, Schema } from 'effect' +import { + type ChannelsProvider, + type DeliveryAdmissionBatch, + type DeliveryMode, + type ProviderDeliveryExecution, +} from '@humanlayer/channels-delivery' +import { Effect, Layer, Predicate, Schema } from 'effect' import type { Config, Redacted } from 'effect' +import * as FetchHttpClient from 'effect/http/FetchHttpClient' import type { JiraApi } from './JiraApi' +import { JiraApiLive } from './JiraApiLive' import { type JiraCallbackHandlers } from './JiraCallbacks' import { makeJiraOutputProcessor } from './JiraDeliveryOutput' import { JiraBotConfiguration, makeJiraEventProcessor } from './JiraEventProcessor' import { makeJiraWebhookProvider } from './JiraWebhookProvider' +/** `JiraApiLive` over the standard Fetch transport: the default `jiraApi` of `JiraBot.make`. */ +const JiraApiFetchLive = JiraApiLive.pipe(Layer.provide(FetchHttpClient.layer)) + /** * @property webhookSecret - read when the bot is built, so the bot itself can be declared at the top of a module * @property deliveryMode - when an issue's mailbox runs and what each batch holds * @property bot - the account id of this bot, whose mentions start a delivery * @property site - the Jira site host, used as the installation id - * @property handlers - the callbacks; the application provides `JiraApi` (usually `JiraApiLive`) and anything - * else they need, and the storage supplies `MailboxSubscriptions`. The output half reads the same `JiraApi` - * when it is built, so `JiraApi` is a build requirement too. + * @property handlers - the callbacks; the bot supplies `JiraApi` to them, the storage supplies + * `MailboxSubscriptions`, and anything else they need comes from the application + * @property jiraApi - how the bot talks to Jira; defaults to `JiraApiLive` over Fetch, which reads + * `JIRA_BASE_URL`, `JIRA_BOT_EMAIL` and `JIRA_API_TOKEN` from the configuration */ -export type MakeOptions = { +export type MakeOptions = { readonly webhookSecret: Config.Config> readonly deliveryMode: DeliveryMode readonly bot: JiraBotConfiguration | Config.Config readonly site: Config.Config readonly handlers: JiraCallbackHandlers + readonly jiraApi?: Layer.Layer } -export const make = ( - options: MakeOptions, +export const make = ( + options: MakeOptions, ): ChannelsProvider<{ - readonly build: JiraApi - readonly process: R | JiraApi - readonly error: Config.ConfigError + readonly build: ApiRequirements + readonly process: Exclude + readonly error: Config.ConfigError | ApiError }> => { const readBotConfiguration = Schema.is(JiraBotConfiguration)(options.bot) ? Effect.succeed(options.bot) : options.bot + const buildJiraApi = Predicate.isUndefined(options.jiraApi) + ? Layer.build(JiraApiFetchLive) + : Layer.build(options.jiraApi) return { providerName: 'jira', @@ -50,13 +65,24 @@ export const make = ( * configuration here too, so it can be discovered and bound for the runtime that runs callbacks. */ yield* readBotConfiguration + yield* buildJiraApi return makeJiraWebhookProvider({ namespace, webhookSecret, site }) }), eventProcessor: Effect.fn('jira.bot.build_event_processor')(function* ({ namespace }) { const bot = yield* readBotConfiguration - return makeJiraEventProcessor({ namespace, bot, handlers: options.handlers }) + const jiraApi = yield* buildJiraApi + const eventProcessor = makeJiraEventProcessor({ namespace, bot, handlers: options.handlers }) + return { + namespace: eventProcessor.namespace, + providerName: eventProcessor.providerName, + process: (admissions: DeliveryAdmissionBatch, execution: ProviderDeliveryExecution) => + eventProcessor.process(admissions, execution).pipe(Effect.provide(jiraApi)), + } }), /** Sends a handed-off delivery's output: the comments it posts. */ - outputProcessor: ({ namespace }) => makeJiraOutputProcessor({ namespace }), + outputProcessor: Effect.fn('jira.bot.build_output_processor')(function* ({ namespace }) { + const jiraApi = yield* buildJiraApi + return yield* makeJiraOutputProcessor({ namespace }).pipe(Effect.provide(jiraApi)) + }), } } diff --git a/packages/jira/test/jira-bot.vi.test.ts b/packages/jira/test/jira-bot.vi.test.ts index 87355cf..5c7d416 100644 --- a/packages/jira/test/jira-bot.vi.test.ts +++ b/packages/jira/test/jira-bot.vi.test.ts @@ -1,12 +1,18 @@ import { describe, it } from '@effect/vitest' -import { QueueDeliveryMode } from '@humanlayer/channels-delivery' -import { Config, ConfigProvider, Effect, Layer, Redacted, Schema } from 'effect' +import { + DeliveryAdmission, + MailboxSubscriptions, + ProviderEventHandled, + QueueDeliveryMode, +} from '@humanlayer/channels-delivery' +import { Config, ConfigProvider, Effect, Layer, Redacted, Ref, Schema } from 'effect' -import { JiraAccountId, JiraApi, JiraBot } from '../src' +import { makeTestDeliveryExecution } from '../../delivery/test/delivery-execution' +import { JiraAccountId, JiraApi, JiraBot, JiraIssueId, jiraIssueResourceId } from '../src' +import { readJiraFixture } from './fixtures' const noConfiguration = ConfigProvider.layer(ConfigProvider.fromUnknown({})) -/** `JiraApi` is a build requirement of the whole provider; only the output half uses it. */ -const unusedJiraApi = Layer.mock(JiraApi, {}) +const mockJiraApi = Layer.mock(JiraApi, {}) describe('JiraBot.make', () => { it.effect('names the provider jira and has an output processor', ({ expect }) => @@ -16,12 +22,13 @@ describe('JiraBot.make', () => { deliveryMode: QueueDeliveryMode.make({}), bot: { accountId: Schema.decodeSync(JiraAccountId)('5b10') }, site: Config.succeed('example.atlassian.net'), + jiraApi: mockJiraApi, handlers: {}, }) expect(provider.providerName).toBe('jira') const output = yield* ( provider.outputProcessor?.({ namespace: 'jira-bot-test' }) ?? Effect.die('no output processor') - ).pipe(Effect.scoped, Effect.provide(unusedJiraApi)) + ).pipe(Effect.scoped) expect(output.providerName).toBe('jira') }), ) @@ -33,6 +40,7 @@ describe('JiraBot.make', () => { deliveryMode: QueueDeliveryMode.make({}), bot: Config.schema(Schema.Struct({ accountId: JiraAccountId }), 'JIRA_BOT'), site: Config.String('JIRA_SITE'), + jiraApi: mockJiraApi, handlers: {}, }) const provided = ConfigProvider.layer( @@ -44,7 +52,7 @@ describe('JiraBot.make', () => { ) const webhook = yield* provider .webhookProvider({ namespace: 'jira-bot-test' }) - .pipe(Effect.scoped, Effect.provide(Layer.merge(provided, unusedJiraApi))) + .pipe(Effect.scoped, Effect.provide(provided)) expect(webhook.providerName).toBe('jira') }), ) @@ -56,11 +64,28 @@ describe('JiraBot.make', () => { deliveryMode: QueueDeliveryMode.make({}), bot: Config.schema(Schema.Struct({ accountId: JiraAccountId }), 'JIRA_BOT'), site: Config.succeed('example.atlassian.net'), + jiraApi: mockJiraApi, handlers: {}, }) const error = yield* provider .webhookProvider({ namespace: 'jira-bot-test' }) - .pipe(Effect.scoped, Effect.provide(Layer.merge(noConfiguration, unusedJiraApi)), Effect.flip) + .pipe(Effect.scoped, Effect.provide(noConfiguration), Effect.flip) + expect(error).toBeInstanceOf(Config.ConfigError) + }), + ) + + it.effect('reads the default API configuration while building the webhook half', ({ expect }) => + Effect.gen(function* () { + const provider = JiraBot.make({ + webhookSecret: Config.succeed(Redacted.make('s')), + deliveryMode: QueueDeliveryMode.make({}), + bot: { accountId: Schema.decodeSync(JiraAccountId)('5b10') }, + site: Config.succeed('example.atlassian.net'), + handlers: {}, + }) + const error = yield* provider + .webhookProvider({ namespace: 'jira-bot-test' }) + .pipe(Effect.scoped, Effect.provide(noConfiguration), Effect.flip) expect(error).toBeInstanceOf(Config.ConfigError) }), ) @@ -72,12 +97,54 @@ describe('JiraBot.make', () => { deliveryMode: QueueDeliveryMode.make({}), bot: { accountId: Schema.decodeSync(JiraAccountId)('5b10') }, site: Config.succeed('example.atlassian.net'), + jiraApi: mockJiraApi, handlers: {}, }) const events = yield* provider .eventProcessor({ namespace: 'jira-bot-test' }) - .pipe(Effect.scoped, Effect.provide(Layer.merge(noConfiguration, unusedJiraApi))) + .pipe(Effect.scoped, Effect.provide(noConfiguration)) expect(events.providerName).toBe('jira') }), ) + + it.effect("supplies the bot's JiraApi to onMentioned", ({ expect }) => + Effect.gen(function* () { + const posted = yield* Ref.make>([]) + const provider = JiraBot.make({ + webhookSecret: Config.succeed(Redacted.make('s')), + deliveryMode: QueueDeliveryMode.make({}), + bot: { accountId: Schema.decodeSync(JiraAccountId)('5b10ac8d82e05b22cc7d4ef5') }, + site: Config.succeed('example.atlassian.net'), + jiraApi: Layer.mock(JiraApi, { + postComment: ({ issueKey, markdown }) => + Ref.update(posted, (all) => [...all, `${issueKey}: ${markdown}`]).pipe( + Effect.as({ commentId: '900' }), + ), + }), + handlers: { + onMentioned: (event) => + Effect.gen(function* () { + const api = yield* JiraApi + yield* api.postComment({ issueKey: event.issue.key, markdown: 'On it.' }) + }), + }, + }) + const events = yield* provider.eventProcessor({ namespace: 'jira-bot-test' }).pipe(Effect.scoped) + const test = yield* makeTestDeliveryExecution() + const mention = DeliveryAdmission.make({ + namespace: 'jira-bot-test', + provider: 'jira', + installationId: 'example.atlassian.net', + resourceId: jiraIssueResourceId({ issueId: JiraIssueId.make('10042') }), + eventId: 'webhook-1', + payload: JSON.parse(readJiraFixture('comment-created-mention.json').toString('utf8')), + }) + /** The type says a batch may need subscriptions; Jira's handlers here do not use them. */ + const result = yield* events + .process([mention], test.execution) + .pipe(Effect.provide(Layer.mock(MailboxSubscriptions, {}))) + expect(result).toEqual(ProviderEventHandled.make({})) + expect(yield* Ref.get(posted)).toEqual(['ABC-7: On it.']) + }), + ) }) diff --git a/packages/jira/test/jira-event-processing.vi.test.ts b/packages/jira/test/jira-event-processing.vi.test.ts index 8ba32fb..8f3379b 100644 --- a/packages/jira/test/jira-event-processing.vi.test.ts +++ b/packages/jira/test/jira-event-processing.vi.test.ts @@ -3,6 +3,7 @@ import { DeliveryAdmission, deliveryMailboxKey, PreparedDeliveryInvocation, + ProviderDeliveryExecution, ProviderEventExecutionFailed, ProviderEventHandled, ProviderEventIgnored, @@ -206,6 +207,44 @@ describe('Jira event batch processing', () => { }), ) + it.effect('fails a saved callback that is unknown, unconfigured, or cannot be rebuilt', ({ expect }) => { + const onMentioned = vi.fn(() => Effect.void) + return Effect.gen(function* () { + const test = yield* makeTestDeliveryExecution() + const withPrepared = (callback: string) => + new ProviderDeliveryExecution({ + ...test.execution, + prepared: Option.some( + PreparedDeliveryInvocation.make({ + callback, + presentationVersion: 1, + destination: null, + supportedOperations: [], + }), + ), + }) + const failed = (safeCode: string) => + ProviderEventExecutionFailed.make({ provider: 'jira', retryable: false, safeCode }) + const configured = makeJiraEventProcessor({ namespace, bot, handlers: { onMentioned } }) + const unconfigured = makeJiraEventProcessor({ namespace, bot, handlers: {} }) + const mention = fixtureAdmission('comment-created-mention.json') + + expect(yield* configured.process([mention], withPrepared('onRemoved')).pipe(Effect.flip)).toEqual( + failed('prepared_callback_missing'), + ) + expect(yield* unconfigured.process([mention], withPrepared('onMentioned')).pipe(Effect.flip)).toEqual( + failed('prepared_callback_missing'), + ) + expect( + yield* configured + .process([fixtureAdmission('comment-created-plain.json')], withPrepared('onMentioned')) + .pipe(Effect.flip), + ).toEqual(failed('prepared_callback_unbuildable')) + expect(onMentioned).not.toHaveBeenCalled() + expect(yield* Ref.get(test.preparations)).toEqual([]) + }) + }) + it.effect('rejects a batch for another namespace or issue', ({ expect }) => Effect.gen(function* () { const payload = fixturePayload('comment-created-mention.json') From 727e59ed1f10286e235289bbe6d4557338a1f779 Mon Sep 17 00:00:00 2001 From: yashodhah Date: Mon, 19 Oct 2026 21:24:50 +0800 Subject: [PATCH 7/9] fix(jira): address final review findings - Read a string comment body as wiki markup: [~accountid:] counts as a mention and renders as @; warn (format and comment id only) on a string or undecodable body. - Event id is comment_created: from the signed body; the unsigned X-Atlassian-Webhook-Identifier header is ignored. - Bound the webhook body at 1 MiB (413 from the routes). - UTF-8-safe basic auth; JIRA_BASE_URL must be https. - Tests: tampered body, signature-before-JSON order, missing header reason, >64-byte HMAC key, bot-authored ignore reason. - Add files: ["src"] and a README (release:validate requires one). Co-Authored-By: Claude Sonnet 5.5 --- packages/jira/ASSUMPTIONS.md | 6 +- packages/jira/README.md | 15 +++ packages/jira/package.json | 3 + packages/jira/src/JiraApiLive.ts | 18 ++- packages/jira/src/JiraComment.ts | 34 +++++- packages/jira/src/JiraEventProcessor.ts | 12 +- packages/jira/src/JiraWebhookProvider.ts | 11 +- packages/jira/src/JiraWebhookSchemas.ts | 7 +- packages/jira/test/jira-api-live.vi.test.ts | 46 +++++++- packages/jira/test/jira-comment.vi.test.ts | 22 +++- .../test/jira-event-processing.vi.test.ts | 47 +++++++- .../jira/test/jira-webhook-routing.vi.test.ts | 109 ++++++++++++++---- .../test/jira-webhook-signature.vi.test.ts | 15 +++ 13 files changed, 301 insertions(+), 44 deletions(-) create mode 100644 packages/jira/README.md diff --git a/packages/jira/ASSUMPTIONS.md b/packages/jira/ASSUMPTIONS.md index 543f948..de20e86 100644 --- a/packages/jira/ASSUMPTIONS.md +++ b/packages/jira/ASSUMPTIONS.md @@ -9,7 +9,7 @@ No live Jira site was used. Every item is unverified against a real site. `docum ## (b) comment_created payload - Fields: `webhookEvent` ("comment_created" / "comment_updated"), `timestamp` (epoch ms), `comment`, `issue`. Status: documented generically (page has no full example; comment object matches the REST comment shape). Exact `comment.*`/`issue.fields.summary` layout: assumed from REST API v3. -- `comment.body` is ADF (object, `version: 1`, `type: "doc"`) for Jira Cloud REST v3-style payloads. Status: assumed. Parser should also tolerate a plain string body (treat as text, no mentions). +- `comment.body` is ADF (object, `version: 1`, `type: "doc"`) for Jira Cloud REST v3-style payloads. Status: assumed. A string body is read as wiki markup: a mention is written `[~accountid:]` (assumed), counts as a mention of that account, and renders as `@`; other text is kept as is. A string body or one that is neither ADF nor a string is logged at warn with the comment id and body format only. - `comment.author` has `accountId`, `displayName`, `accountType` (`app` for bots, assumed). ## (c) ADF mention node @@ -21,11 +21,11 @@ No live Jira site was used. Every item is unverified against a real site. `docum ## (e) Stable event id - Documented: every webhook carries `X-Atlassian-Webhook-Identifier`, unique per tenant and constant across retries; `X-Atlassian-Webhook-Retry` is present on retries; `X-Atlassian-Webhook-Flow` is Primary/Secondary. Source: same webhooks URL. -- Plan: use the identifier header when present, else fall back to `comment.id` (plus `webhookEvent`). Note `comment_updated` for the same comment must not collide with its created event. +- Code: the event id is always `comment_created:`, read from the signed body. The identifier header is ignored: it is not covered by the HMAC, so a replayed body with a fresh header would otherwise be admitted as a new event and start a second run. Retries of the same comment dedupe on `comment.id`. Only `comment_created` is admitted; the prefix keeps a later event type for the same comment from colliding. ## (f) REST calls - `GET /rest/api/3/myself` returns `accountId`, `displayName`. Status: documented (Jira Cloud REST v3), not re-fetched here. -- `POST /rest/api/3/issue/{key}/comment` with body `{ "body": }`, one `paragraph` node per block; returns the created comment with `id`. Status: documented (REST v3), not re-fetched here. Basic auth (email + API token) assumed for the bot. +- `POST /rest/api/3/issue/{key}/comment` with body `{ "body": }`, one `paragraph` node per block; returns the created comment with `id`. Status: documented (REST v3), not re-fetched here. Basic auth (email + API token, encoded as UTF-8 before base64) assumed for the bot. `JIRA_BASE_URL` must be `https://`; anything else fails the layer build with a `ConfigError`. ## Fixtures `test/fixtures/*.json` are hand-written. Bot: accountId `5b10ac8d82e05b22cc7d4ef5`, name `slopcodebot`. Human author: `557058a1b2c3d4e5f6a7b8c9` (fictional). Issue id `10042`, key `ABC-7`, summary `Add rate limiting`. Site `example.atlassian.net` is a placeholder. diff --git a/packages/jira/README.md b/packages/jira/README.md new file mode 100644 index 0000000..e42fa8d --- /dev/null +++ b/packages/jira/README.md @@ -0,0 +1,15 @@ +# Jira + +`@humanlayer/channels-jira` turns Jira Cloud `comment_created` webhooks that mention a bot account into durable issue callbacks. It verifies the `X-Hub-Signature` HMAC, stores accepted events in provider-neutral mailboxes keyed by issue, runs `onMentioned` with the text after the mention, and posts replies as issue comments through `JiraApi`. + +Status: experimental. No live Jira site has been used; the payload, signature and mention formats it relies on are listed in [ASSUMPTIONS.md](./ASSUMPTIONS.md). + +## Configuration + +`JiraBot.make` reads: + +- `JIRA_BASE_URL`: the site, such as `https://example.atlassian.net` (https only) +- `JIRA_BOT_EMAIL` and `JIRA_API_TOKEN`: basic-auth credentials of the bot account +- the webhook secret, bot account id and site host, through the `Config` values the application passes + +Register a Jira webhook for `comment_created` with a secret, pointing at `/integrations/jira/webhook`. diff --git a/packages/jira/package.json b/packages/jira/package.json index 4aab97e..a679394 100644 --- a/packages/jira/package.json +++ b/packages/jira/package.json @@ -2,6 +2,9 @@ "name": "@humanlayer/channels-jira", "version": "0.0.0", "private": true, + "files": [ + "src" + ], "type": "module", "sideEffects": false, "module": "./src/index.ts", diff --git a/packages/jira/src/JiraApiLive.ts b/packages/jira/src/JiraApiLive.ts index 9bfc3b9..bcf2579 100644 --- a/packages/jira/src/JiraApiLive.ts +++ b/packages/jira/src/JiraApiLive.ts @@ -42,15 +42,27 @@ const statusError = (operation: JiraApiOperation, response: HttpClientResponse.H const finalError = (operation: JiraApiOperation) => JiraApiError.make({ operation, retryable: false }) -/** Reads `JIRA_BASE_URL`, `JIRA_BOT_EMAIL`, and `JIRA_API_TOKEN` (redacted) when the layer is built. */ +/** Base64 of the UTF-8 bytes; `btoa` on the raw string throws on characters outside Latin-1. */ +const utf8Base64 = (value: string) => { + let binary = '' + for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte) + return btoa(binary) +} + +/** The base URL must be `https:` with a host: every request carries the bot's credentials. */ +const JiraBaseUrl = Schema.String.check( + Schema.isPattern(/^https:\/\/[^\s/?#]+/i, { message: 'JIRA_BASE_URL must be an https:// URL' }), +) + +/** Reads `JIRA_BASE_URL` (https only), `JIRA_BOT_EMAIL`, and `JIRA_API_TOKEN` (redacted) when the layer is built. */ export const JiraApiLive: Layer.Layer = Layer.effect( JiraApi, Effect.gen(function* () { - const baseUrl = (yield* Config.String('JIRA_BASE_URL')).replace(/\/+$/, '') + const baseUrl = (yield* Config.schema(JiraBaseUrl, 'JIRA_BASE_URL')).replace(/\/+$/, '') const email = yield* Config.String('JIRA_BOT_EMAIL') const token = yield* Config.Redacted('JIRA_API_TOKEN') const httpClient = yield* HttpClient.HttpClient - const authorization = () => `Basic ${btoa(`${email}:${Redacted.value(token)}`)}` + const authorization = () => `Basic ${utf8Base64(`${email}:${Redacted.value(token)}`)}` /** Send a request and decode a 2xx JSON body; transport failures are retryable, decode failures are not. */ const send = ( diff --git a/packages/jira/src/JiraComment.ts b/packages/jira/src/JiraComment.ts index 910c84a..b991533 100644 --- a/packages/jira/src/JiraComment.ts +++ b/packages/jira/src/JiraComment.ts @@ -1,7 +1,8 @@ /** - * Reading a Jira comment body: an Atlassian Document Format (ADF) tree in Jira Cloud payloads, or a plain - * string otherwise. A string body is treated as text with no mentions (see ASSUMPTIONS.md). The webhook - * schema keeps the body unparsed; decode it with {@link JiraCommentBody} before calling these functions. + * Reading a Jira comment body: an Atlassian Document Format (ADF) tree in Jira Cloud payloads, or a + * string otherwise. A string body is read as wiki markup, where a mention is written `[~accountid:]` + * and renders as `@`; any other text is kept as it is (see ASSUMPTIONS.md). The webhook schema keeps + * the body unparsed; decode it with {@link JiraCommentBody} before calling these functions. */ import { Predicate, Schema } from 'effect' @@ -68,14 +69,31 @@ const renderNode = (node: JiraAdfNode, rendering: Rendering, accountId: string | }) } +/** A wiki-markup user mention, `[~accountid:]`; group 1 is the account id. */ +const wikiMention = /\[~accountid:([^\]\s]+)\]/g + +/** Render a wiki-markup string body, replacing each mention token with `@`. */ +const renderWikiMarkup = (body: string, rendering: Rendering, accountId: string | undefined) => { + let last = 0 + for (const match of body.matchAll(wikiMention)) { + const id = match[1] ?? '' + rendering.text += `${body.slice(last, match.index)}@${id}` + if (rendering.mentionOffset === undefined && accountId !== undefined && id === accountId) { + rendering.mentionOffset = rendering.text.length + } + last = match.index + match[0].length + } + rendering.text += body.slice(last) +} + const render = (body: JiraCommentBody, accountId?: string): Rendering => { const rendering: Rendering = { text: '', mentionOffset: undefined } - if (Predicate.isString(body)) rendering.text = body + if (Predicate.isString(body)) renderWikiMarkup(body, rendering, accountId) else renderNode(body, rendering, accountId) return rendering } -/** The comment as plain text, with each mention rendered as `@`. */ +/** The comment as plain text, with each ADF mention rendered as `@` and each wiki-markup one as `@`. */ export const commentPlainText = (body: JiraCommentBody): string => render(body).text /** The account ids mentioned anywhere in the body, nested blocks included, in document order without repeats. */ @@ -87,6 +105,12 @@ export const mentionedAccountIds = (body: JiraCommentBody): ReadonlyArray ({ + eventId: admission.eventId, + commentId: webhook.comment.id, + bodyFormat, + }) if (Option.isNone(body)) { yield* Effect.logWarning('Jira comment body is neither ADF nor text; treating it as no mention').pipe( - Effect.annotateLogs({ eventId: admission.eventId, commentId: webhook.comment.id }), + Effect.annotateLogs(annotations('unrecognized')), + ) + } else if (Predicate.isString(body.value)) { + yield* Effect.logWarning('Jira comment body is a string, not ADF; reading it as wiki markup').pipe( + Effect.annotateLogs(annotations('wiki_markup')), ) } return { admission, webhook, body } diff --git a/packages/jira/src/JiraWebhookProvider.ts b/packages/jira/src/JiraWebhookProvider.ts index 4fcd52c..086059b 100644 --- a/packages/jira/src/JiraWebhookProvider.ts +++ b/packages/jira/src/JiraWebhookProvider.ts @@ -19,8 +19,17 @@ export type JiraWebhookProviderOptions = { readonly site: string } +/** The largest webhook body the provider reads, 1 MiB; a larger body is answered with 413 by the routes. */ +export const jiraMaxBodyBytes = 1_048_576 + +/** + * The admission's event id is `comment_created:`, taken from the signed body. The + * `X-Atlassian-Webhook-Identifier` header is not covered by the signature, so a replayed body with a fresh + * header would otherwise be admitted as a new event and start a second run. + */ export const makeJiraWebhookProvider = (options: JiraWebhookProviderOptions): WebhookProvider => ({ providerName: 'jira', + maxBodyBytes: jiraMaxBodyBytes, handle: (input) => Effect.gen(function* () { const headers = yield* Schema.decodeUnknownEffect(JiraWebhookHeaders)(input.headers).pipe( @@ -47,7 +56,7 @@ export const makeJiraWebhookProvider = (options: JiraWebhookProviderOptions): We provider: 'jira', installationId: options.site, resourceId: jiraIssueResourceId({ issueId: webhook.issue.id }), - eventId: headers['x-atlassian-webhook-identifier'] ?? webhook.comment.id, + eventId: `comment_created:${webhook.comment.id}`, payload: webhook, }), }) diff --git a/packages/jira/src/JiraWebhookSchemas.ts b/packages/jira/src/JiraWebhookSchemas.ts index 9d46cb5..a8baced 100644 --- a/packages/jira/src/JiraWebhookSchemas.ts +++ b/packages/jira/src/JiraWebhookSchemas.ts @@ -2,9 +2,12 @@ import { Schema } from 'effect' import { JiraAccountId, JiraIssueId } from './JiraIdentity' +/** + * Only the signature header is read. `X-Atlassian-Webhook-Identifier` is not covered by the HMAC, so the + * event id comes from the signed body instead (see `makeJiraWebhookProvider`). + */ export const JiraWebhookHeaders = Schema.Struct({ 'x-hub-signature': Schema.NonEmptyString, - 'x-atlassian-webhook-identifier': Schema.optionalKey(Schema.NonEmptyString), }) export const JiraWebhookEnvelope = Schema.Struct({ webhookEvent: Schema.NonEmptyString }) @@ -16,7 +19,7 @@ export const JiraUser = Schema.Struct({ }) export type JiraUser = typeof JiraUser.Type -/** `body` is an ADF document in Jira Cloud payloads and a plain string otherwise; callers parse it. */ +/** `body` is an ADF document in Jira Cloud payloads and a wiki-markup string otherwise; callers parse it. */ export const JiraComment = Schema.Struct({ id: Schema.NonEmptyString, author: JiraUser, diff --git a/packages/jira/test/jira-api-live.vi.test.ts b/packages/jira/test/jira-api-live.vi.test.ts index e6f2647..fc40f0f 100644 --- a/packages/jira/test/jira-api-live.vi.test.ts +++ b/packages/jira/test/jira-api-live.vi.test.ts @@ -12,16 +12,20 @@ const config = { JIRA_API_TOKEN: testCredential, } -const makeLayer = (httpClient: HttpClient.HttpClient) => +const makeLayer = (httpClient: HttpClient.HttpClient, overrides: Readonly> = {}) => JiraApiLive.pipe( Layer.provide( Layer.mergeAll( Layer.succeed(HttpClient.HttpClient, httpClient), - ConfigProvider.layer(ConfigProvider.fromUnknown(config)), + ConfigProvider.layer(ConfigProvider.fromUnknown({ ...config, ...overrides })), ), ), ) +const okClient = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ accountId: '5b10' }))), +) + const basicAuth = `Basic ${btoa(`bot@example.com:${testCredential}`)}` describe('JiraApiLive', () => { @@ -171,4 +175,42 @@ describe('JiraApiLive', () => { expect(error).toBeInstanceOf(Config.ConfigError) }), ) + + it.effect('encodes non-Latin1 credentials as UTF-8 for basic auth', ({ expect }) => + Effect.gen(function* () { + const seen = yield* Queue.unbounded() + const httpClient = HttpClient.make((request) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + yield* Queue.offer(seen, web.headers.get('authorization')) + return HttpClientResponse.fromWeb(request, Response.json({ accountId: '5b10' })) + }), + ) + const email = 'bøt@exämple.com' + const credential = 'naïve-crédential-✓' + const api = yield* JiraApi.pipe( + Effect.provide(makeLayer(httpClient, { JIRA_BOT_EMAIL: email, JIRA_API_TOKEN: credential })), + ) + expect(yield* api.myself()).toEqual({ accountId: '5b10' }) + const authorization = yield* Queue.take(seen) + expect(authorization).toBe(`Basic ${Buffer.from(`${email}:${credential}`, 'utf8').toString('base64')}`) + }), + ) + + it.effect('requires an https JIRA_BASE_URL', ({ expect }) => + Effect.gen(function* () { + for (const baseUrl of ['http://example.atlassian.net', 'ftp://example.atlassian.net', 'not a url']) { + const error = yield* JiraApi.pipe( + Effect.provide(makeLayer(okClient, { JIRA_BASE_URL: baseUrl })), + Effect.flip, + ) + expect(error).toBeInstanceOf(Config.ConfigError) + expect(String(error)).toContain('JIRA_BASE_URL') + } + const api = yield* JiraApi.pipe( + Effect.provide(makeLayer(okClient, { JIRA_BASE_URL: 'https://example.atlassian.net/' })), + ) + expect(yield* api.myself()).toEqual({ accountId: '5b10' }) + }), + ) }) diff --git a/packages/jira/test/jira-comment.vi.test.ts b/packages/jira/test/jira-comment.vi.test.ts index d3e6c2e..b7c00dc 100644 --- a/packages/jira/test/jira-comment.vi.test.ts +++ b/packages/jira/test/jira-comment.vi.test.ts @@ -78,12 +78,32 @@ describe('Jira comment parsing', () => { ) }) - it('tolerates a plain string body as text without mentions', ({ expect }) => { + it('tolerates a string body without wiki-markup mentions as text without mentions', ({ expect }) => { expect(commentPlainText('just text')).toBe('just text') expect(mentionedAccountIds('just text')).toEqual([]) expect(textAfterMention('just text', botAccountId)).toBeUndefined() }) + it('finds a wiki-markup [~accountid:] mention of the bot in a string body', ({ expect }) => { + const body = `[~accountid:${botAccountId}] /research how should we do this?` + expect(mentionedAccountIds(body)).toEqual([botAccountId]) + expect(textAfterMention(body, botAccountId)).toBe('/research how should we do this?') + expect(commentPlainText(body)).toBe(`@${botAccountId} /research how should we do this?`) + }) + + it('uses the first wiki-markup mention of the bot and lists every mentioned account once', ({ expect }) => { + const body = `hey [~accountid:${otherAccountId}] and [~accountid:${botAccountId}] /research a [~accountid:${botAccountId}] b` + expect(mentionedAccountIds(body)).toEqual([otherAccountId, botAccountId]) + expect(textAfterMention(body, botAccountId)).toBe(`/research a @${botAccountId} b`) + }) + + it('does not match a wiki-markup mention of another account in a string body', ({ expect }) => { + const body = `[~accountid:${otherAccountId}] /research nope` + expect(mentionedAccountIds(body)).toEqual([otherAccountId]) + expect(textAfterMention(body, botAccountId)).toBeUndefined() + expect(commentPlainText(body)).toBe(`@${otherAccountId} /research nope`) + }) + it('rejects a body that is neither ADF nor a string', ({ expect }) => { expect(Option.isNone(Schema.decodeUnknownOption(JiraCommentBody)(null))).toBe(true) expect(Option.isNone(Schema.decodeUnknownOption(JiraCommentBody)({ type: 'doc', content: 'x' }))).toBe(true) diff --git a/packages/jira/test/jira-event-processing.vi.test.ts b/packages/jira/test/jira-event-processing.vi.test.ts index 8f3379b..2e759d5 100644 --- a/packages/jira/test/jira-event-processing.vi.test.ts +++ b/packages/jira/test/jira-event-processing.vi.test.ts @@ -9,7 +9,7 @@ import { ProviderEventIgnored, ProviderEventInvalid, } from '@humanlayer/channels-delivery' -import { Effect, Option, Ref, Schema } from 'effect' +import { Effect, Logger, Option, Ref, Schema } from 'effect' import { vi } from 'vite-plus/test' import { makeTestDeliveryExecution } from '../../delivery/test/delivery-execution' @@ -114,7 +114,7 @@ describe('Jira event batch processing', () => { const { result, preparations } = yield* process({ onMentioned }, [ fixtureAdmission('comment-created-by-bot.json'), ]) - expect(Schema.is(ProviderEventIgnored)(result)).toBe(true) + expect(result).toEqual(ProviderEventIgnored.make({ reason: 'no_relevant_event' })) expect(onMentioned).not.toHaveBeenCalled() expect(preparations).toEqual([]) }), @@ -146,6 +146,49 @@ describe('Jira event batch processing', () => { }), ) + it.effect('runs onMentioned for a wiki-markup [~accountid:] mention in a string body', ({ expect }) => + Effect.gen(function* () { + const onMentioned = vi.fn(() => Effect.void) + const payload = fixturePayload('comment-created-mention.json') + payload.comment.body = `[~accountid:${bot.accountId}] /research from wiki markup` + const { result } = yield* process({ onMentioned }, [admission(payload, 'wiki-body')]) + expect(result).toEqual(ProviderEventHandled.make({})) + expect(onMentioned).toHaveBeenCalledOnce() + expect(onMentioned.mock.calls[0]?.[0].comment.afterMention).toBe('/research from wiki markup') + }), + ) + + it.effect( + 'warns with the body format and comment id, never the content, for a string or undecodable body', + ({ expect }) => + Effect.gen(function* () { + const logs: Array = [] + const logger = Logger.layer([ + Logger.make((entry) => logs.push(JSON.stringify(Logger.formatStructured.log(entry)))), + ]) + const onMentioned = vi.fn(() => Effect.void) + const payload = fixturePayload('comment-created-plain.json') + payload.comment.body = 'private-string-body-sentinel' + yield* process({ onMentioned }, [admission(payload, 'string-body')]).pipe(Effect.provide(logger)) + payload.comment.body = { type: 'doc', content: 'private-undecodable-body-sentinel' } + yield* process({ onMentioned }, [admission(payload, 'object-body')]).pipe(Effect.provide(logger)) + + expect(logs).toHaveLength(2) + const [stringLog, objectLog] = logs.map((line) => JSON.parse(line)) + expect(stringLog.level).toBe('WARN') + expect(stringLog.annotations).toMatchObject({ + commentId: payload.comment.id, + bodyFormat: 'wiki_markup', + }) + expect(objectLog.level).toBe('WARN') + expect(objectLog.annotations).toMatchObject({ + commentId: payload.comment.id, + bodyFormat: 'unrecognized', + }) + expect(logs.join('\n')).not.toContain('private-') + }), + ) + it.effect('ignores a mention when no handler is configured', ({ expect }) => Effect.gen(function* () { const { result, preparations } = yield* process({}, [fixtureAdmission('comment-created-mention.json')]) diff --git a/packages/jira/test/jira-webhook-routing.vi.test.ts b/packages/jira/test/jira-webhook-routing.vi.test.ts index 61353b8..4281663 100644 --- a/packages/jira/test/jira-webhook-routing.vi.test.ts +++ b/packages/jira/test/jira-webhook-routing.vi.test.ts @@ -1,13 +1,16 @@ import { NodeCrypto } from '@effect/platform-node' import { describe, it } from '@effect/vitest' import { + DeliveryReceipt, + MailboxDelivery, ProviderWebhookIgnored, type ProviderWebhookOutcome, WebhookAuthenticationError, WebhookPayloadInvalidError, + webhookRoutes, } from '@humanlayer/channels-delivery' -import { Effect, Match, Redacted } from 'effect' -import { Headers } from 'effect/http' +import { Context, Effect, Match, Redacted } from 'effect' +import { Headers, HttpRouter } from 'effect/http' import { JiraIssueId, jiraIssueResourceId } from '../src/JiraIdentity' import { makeJiraWebhookProvider } from '../src/JiraWebhookProvider' @@ -34,30 +37,32 @@ describe('Jira webhook routing', () => { expect(provider.providerName).toBe('jira') }) - it.effect('admits comment_created with the issue resource and the delivery header as event id', ({ expect }) => - Effect.gen(function* () { - const input = () => - signedJiraInput('comment-created-mention.json', jiraTestKey, { - 'x-atlassian-webhook-identifier': 'delivery-abc', - }) - const first = admitted(yield* handle(input())) - const second = admitted(yield* handle(input())) - expect(first.provider).toBe('jira') - expect(first.namespace).toBe('jira-test') - expect(first.installationId).toBe('example.atlassian.net') - expect(first.resourceId).toBe(jiraIssueResourceId({ issueId: JiraIssueId.make('10042') })) - expect(first.resourceId).toBe('jira:v1:issue:10042') - expect(first.eventId).toBe('delivery-abc') - expect(second.eventId).toBe(first.eventId) - }), + it.effect( + 'admits comment_created with the issue resource and comment_created: as event id', + ({ expect }) => + Effect.gen(function* () { + const first = admitted(yield* handle(signedJiraInput('comment-created-mention.json', jiraTestKey))) + const second = admitted(yield* handle(signedJiraInput('comment-created-mention.json', jiraTestKey))) + expect(first.provider).toBe('jira') + expect(first.namespace).toBe('jira-test') + expect(first.installationId).toBe('example.atlassian.net') + expect(first.resourceId).toBe(jiraIssueResourceId({ issueId: JiraIssueId.make('10042') })) + expect(first.resourceId).toBe('jira:v1:issue:10042') + expect(first.eventId).toBe('comment_created:10100') + expect(second.eventId).toBe(first.eventId) + }), ) - it.effect('falls back to the comment id when the delivery header is absent', ({ expect }) => + it.effect('ignores the unsigned X-Atlassian-Webhook-Identifier header for the event id', ({ expect }) => Effect.gen(function* () { - const first = admitted(yield* handle(signedJiraInput('comment-created-mention.json', jiraTestKey))) - const second = admitted(yield* handle(signedJiraInput('comment-created-mention.json', jiraTestKey))) - expect(first.eventId).toBe('10100') - expect(second.eventId).toBe(first.eventId) + const withIdentifier = (identifier: string) => + signedJiraInput('comment-created-mention.json', jiraTestKey, { + 'x-atlassian-webhook-identifier': identifier, + }) + const first = admitted(yield* handle(withIdentifier('delivery-abc'))) + const replay = admitted(yield* handle(withIdentifier('delivery-xyz'))) + expect(first.eventId).toBe('comment_created:10100') + expect(replay.eventId).toBe(first.eventId) }), ) @@ -93,7 +98,63 @@ describe('Jira webhook routing', () => { headers: Headers.empty, body: new Uint8Array(readJiraFixture('comment-updated.json')), }).pipe(Effect.flip) - expect(missing).toBeInstanceOf(WebhookAuthenticationError) + expect(missing).toEqual(WebhookAuthenticationError.make({ reason: 'invalid_signature_headers' })) + }), + ) + + it.effect('rejects a body altered after signing', ({ expect }) => + Effect.gen(function* () { + const signed = signedJiraInput('comment-created-mention.json', jiraTestKey) + const tampered = new TextEncoder().encode( + new TextDecoder().decode(signed.body).replace('how should we do this?', 'delete everything'), + ) + expect(tampered).not.toEqual(signed.body) + const error = yield* handle({ headers: signed.headers, body: tampered }).pipe(Effect.flip) + expect(error).toEqual(WebhookAuthenticationError.make({ reason: 'invalid_signature' })) + }), + ) + + it.effect('checks the signature before parsing JSON', ({ expect }) => + Effect.gen(function* () { + const error = yield* handle(signedJiraBody(new TextEncoder().encode('{nope'), 'other')).pipe(Effect.flip) + expect(error).toEqual(WebhookAuthenticationError.make({ reason: 'invalid_signature' })) + }), + ) + + it('bounds the webhook body at 1 MiB', ({ expect }) => { + expect(provider.maxBodyBytes).toBe(1_048_576) + }) + + it.effect('answers 413 for a body over the limit through the webhook routes', ({ expect }) => + Effect.gen(function* () { + const web = HttpRouter.toWebHandler( + webhookRoutes([provider]).pipe(HttpRouter.provideRequest(NodeCrypto.layer)), + { disableLogger: true }, + ) + yield* Effect.addFinalizer(() => Effect.promise(web.dispose)) + const context = Context.make( + MailboxDelivery, + MailboxDelivery.of({ + deliver: (event) => + Effect.succeed(DeliveryReceipt.make({ mailboxKey: event.resourceId, accepted: true })), + }), + ) + const post = (input: ReturnType) => + Effect.promise(() => + web.handler( + new Request('http://localhost/integrations/jira/webhook', { + method: 'POST', + headers: input.headers, + body: new TextDecoder().decode(input.body), + }), + context, + ), + ) + const payload = JSON.parse(readJiraFixture('comment-created-mention.json').toString('utf8')) + const fits = signedJiraInput('comment-created-mention.json', jiraTestKey) + expect((yield* post(fits)).status).toBe(200) + const oversized = new TextEncoder().encode(JSON.stringify({ ...payload, padding: 'x'.repeat(1_048_576) })) + expect((yield* post(signedJiraBody(oversized, jiraTestKey))).status).toBe(413) }), ) }) diff --git a/packages/jira/test/jira-webhook-signature.vi.test.ts b/packages/jira/test/jira-webhook-signature.vi.test.ts index c7ccd0e..6d12170 100644 --- a/packages/jira/test/jira-webhook-signature.vi.test.ts +++ b/packages/jira/test/jira-webhook-signature.vi.test.ts @@ -39,4 +39,19 @@ describe('Jira webhook signature', () => { expect(yield* verify(header('right-key').replace('sha256=', 'sha1='))).toEqual(expected) }), ) + + it.effect('accepts a body signed with a key longer than the 64-byte HMAC block', ({ expect }) => + Effect.gen(function* () { + const longKey = 'k'.repeat(65) + '-long-test-key' + expect(new TextEncoder().encode(longKey).length).toBeGreaterThan(64) + yield* verifyJiraWebhookSignature({ + body, + signature: header(longKey), + webhookSecret: Redacted.make(longKey), + }).pipe(Effect.provide(NodeCrypto.layer)) + expect(yield* verify(header(longKey), `${longKey}x`)).toEqual( + WebhookAuthenticationError.make({ reason: 'invalid_signature' }), + ) + }), + ) }) From fd63838cd85587ad5c8d7a6c6da3be8e75946aa8 Mon Sep 17 00:00:00 2001 From: yashodhah Date: Mon, 19 Oct 2026 22:25:57 +0800 Subject: [PATCH 8/9] feat(jira): authenticate with a Bearer token through the Atlassian gateway The bot is an Atlassian service account. JiraApiLive now reads JIRA_CLOUD_ID (validated as a UUID, since it is part of the request path) and JIRA_API_TOKEN, and calls https://api.atlassian.com/ex/jira//rest/api/3/... with Authorization: Bearer . JIRA_BASE_URL and JIRA_BOT_EMAIL are no longer read. makeJiraApiLive takes an overridable gateway base for tests. Co-Authored-By: Claude Sonnet 5.5 --- packages/jira/ASSUMPTIONS.md | 8 +- packages/jira/README.md | 8 +- packages/jira/src/JiraApiLive.ts | 163 +++++++++++--------- packages/jira/src/JiraBot.ts | 3 +- packages/jira/test/jira-api-live.vi.test.ts | 148 +++++++++++++++--- 5 files changed, 224 insertions(+), 106 deletions(-) diff --git a/packages/jira/ASSUMPTIONS.md b/packages/jira/ASSUMPTIONS.md index de20e86..ff7f79c 100644 --- a/packages/jira/ASSUMPTIONS.md +++ b/packages/jira/ASSUMPTIONS.md @@ -1,6 +1,6 @@ # Jira channel assumptions -No live Jira site was used. Every item is unverified against a real site. `documented` = stated in Atlassian docs (URL given); `assumed` = inferred, confirm before relying on it. +Only authentication and `myself` were checked against a real site (section (f)). Every other item is unverified against a real site. `documented` = stated in Atlassian docs (URL given); `assumed` = inferred, confirm before relying on it. ## (a) Signature - Header `X-Hub-Signature`, value `method=signature`, e.g. `sha256=`; HMAC with the hash named by `method`, keyed by the webhook secret. Status: documented. https://developer.atlassian.com/cloud/jira/platform/webhooks/ @@ -24,8 +24,10 @@ No live Jira site was used. Every item is unverified against a real site. `docum - Code: the event id is always `comment_created:`, read from the signed body. The identifier header is ignored: it is not covered by the HMAC, so a replayed body with a fresh header would otherwise be admitted as a new event and start a second run. Retries of the same comment dedupe on `comment.id`. Only `comment_created` is admitted; the prefix keeps a later event type for the same comment from colliding. ## (f) REST calls -- `GET /rest/api/3/myself` returns `accountId`, `displayName`. Status: documented (Jira Cloud REST v3), not re-fetched here. -- `POST /rest/api/3/issue/{key}/comment` with body `{ "body": }`, one `paragraph` node per block; returns the created comment with `id`. Status: documented (REST v3), not re-fetched here. Basic auth (email + API token, encoded as UTF-8 before base64) assumed for the bot. `JIRA_BASE_URL` must be `https://`; anything else fails the layer build with a `ConfigError`. +- Auth: the bot is an Atlassian service account with an API token. Every call goes through the API gateway, `https://api.atlassian.com/ex/jira//rest/api/3/...`, with `Authorization: Bearer `. Basic auth (email + token against the site URL) is not used. `JIRA_CLOUD_ID` must be a UUID (it is part of the request path); anything else fails the layer build with a `ConfigError`. The cloud id comes from `https://.atlassian.net/_edge/tenant_info`. +- VERIFIED 2026-10-19 by a real curl against a real site: `GET https://api.atlassian.com/ex/jira//rest/api/3/myself` with `Authorization: Bearer ` returned 200 with `accountId` in the form `:` (e.g. `712020:9a62b034-dabf-4364-b257-f4ad41e00a74`), `accountType: "app"`, `displayName`. Only this 200 on `myself` was verified. +- `POST /rest/api/3/issue/{key}/comment` with body `{ "body": }`, one `paragraph` node per block; returns the created comment with `id`. Status: documented (REST v3), not verified. Assumed to work through the same gateway prefix with the same Bearer token. +- `GET /rest/api/3/issue/{key}?fields=summary,description`. Status: documented (REST v3), not verified. Same gateway and Bearer assumption. ## Fixtures `test/fixtures/*.json` are hand-written. Bot: accountId `5b10ac8d82e05b22cc7d4ef5`, name `slopcodebot`. Human author: `557058a1b2c3d4e5f6a7b8c9` (fictional). Issue id `10042`, key `ABC-7`, summary `Add rate limiting`. Site `example.atlassian.net` is a placeholder. diff --git a/packages/jira/README.md b/packages/jira/README.md index e42fa8d..de84610 100644 --- a/packages/jira/README.md +++ b/packages/jira/README.md @@ -2,14 +2,16 @@ `@humanlayer/channels-jira` turns Jira Cloud `comment_created` webhooks that mention a bot account into durable issue callbacks. It verifies the `X-Hub-Signature` HMAC, stores accepted events in provider-neutral mailboxes keyed by issue, runs `onMentioned` with the text after the mention, and posts replies as issue comments through `JiraApi`. -Status: experimental. No live Jira site has been used; the payload, signature and mention formats it relies on are listed in [ASSUMPTIONS.md](./ASSUMPTIONS.md). +Status: experimental. Only authentication and `GET /myself` through the gateway have been checked against a real site (2026-10-19); the payload, signature and mention formats, and the comment and issue calls, are unverified and listed in [ASSUMPTIONS.md](./ASSUMPTIONS.md). ## Configuration `JiraBot.make` reads: -- `JIRA_BASE_URL`: the site, such as `https://example.atlassian.net` (https only) -- `JIRA_BOT_EMAIL` and `JIRA_API_TOKEN`: basic-auth credentials of the bot account +- `JIRA_CLOUD_ID`: the site's cloud id, a UUID, from `curl -s https://.atlassian.net/_edge/tenant_info` +- `JIRA_API_TOKEN`: the API token of the bot, an Atlassian service account; sent as `Authorization: Bearer ` to `https://api.atlassian.com/ex/jira//rest/api/3/...` - the webhook secret, bot account id and site host, through the `Config` values the application passes +Both halves of the bot build the API layer, so the webhook half needs `JIRA_CLOUD_ID` and `JIRA_API_TOKEN` too. + Register a Jira webhook for `comment_created` with a secret, pointing at `/integrations/jira/webhook`. diff --git a/packages/jira/src/JiraApiLive.ts b/packages/jira/src/JiraApiLive.ts index bcf2579..158632d 100644 --- a/packages/jira/src/JiraApiLive.ts +++ b/packages/jira/src/JiraApiLive.ts @@ -1,4 +1,4 @@ -/** `JiraApi` over Jira Cloud's REST v3, with basic auth (bot email and API token). */ +/** `JiraApi` over Jira Cloud's REST v3 through the Atlassian API gateway, with a Bearer token (service account API token). */ import { Config, Effect, Layer, Predicate, Redacted, Schema } from 'effect' import { HttpClient, HttpClientRequest, type HttpClientResponse } from 'effect/http' @@ -42,84 +42,97 @@ const statusError = (operation: JiraApiOperation, response: HttpClientResponse.H const finalError = (operation: JiraApiOperation) => JiraApiError.make({ operation, retryable: false }) -/** Base64 of the UTF-8 bytes; `btoa` on the raw string throws on characters outside Latin-1. */ -const utf8Base64 = (value: string) => { - let binary = '' - for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte) - return btoa(binary) +/** + * The cloud id is interpolated into every request path, so only a UUID is accepted: anything else could + * redirect the bot's token to another path on the gateway. + */ +const JiraCloudId = Schema.String.check( + Schema.isPattern(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, { + message: 'JIRA_CLOUD_ID must be a UUID', + }), +) + +export type MakeJiraApiLiveOptions = { + /** The gateway origin; defaults to `https://api.atlassian.com`. Tests point it at a fake. */ + readonly gatewayBaseUrl?: string } -/** The base URL must be `https:` with a host: every request carries the bot's credentials. */ -const JiraBaseUrl = Schema.String.check( - Schema.isPattern(/^https:\/\/[^\s/?#]+/i, { message: 'JIRA_BASE_URL must be an https:// URL' }), -) +/** + * Reads `JIRA_CLOUD_ID` (a UUID) and `JIRA_API_TOKEN` (redacted) when the layer is built, and sends + * `Authorization: Bearer ` to `/ex/jira//rest/api/3/...`. + */ +export const makeJiraApiLive = ( + options: MakeJiraApiLiveOptions = {}, +): Layer.Layer => + Layer.effect( + JiraApi, + Effect.gen(function* () { + const gatewayBaseUrl = (options.gatewayBaseUrl ?? 'https://api.atlassian.com').replace(/\/+$/, '') + const cloudId = yield* Config.schema(JiraCloudId, 'JIRA_CLOUD_ID') + const token = yield* Config.Redacted('JIRA_API_TOKEN') + const baseUrl = `${gatewayBaseUrl}/ex/jira/${cloudId}` + const httpClient = yield* HttpClient.HttpClient + const authorization = () => `Bearer ${Redacted.value(token)}` -/** Reads `JIRA_BASE_URL` (https only), `JIRA_BOT_EMAIL`, and `JIRA_API_TOKEN` (redacted) when the layer is built. */ -export const JiraApiLive: Layer.Layer = Layer.effect( - JiraApi, - Effect.gen(function* () { - const baseUrl = (yield* Config.schema(JiraBaseUrl, 'JIRA_BASE_URL')).replace(/\/+$/, '') - const email = yield* Config.String('JIRA_BOT_EMAIL') - const token = yield* Config.Redacted('JIRA_API_TOKEN') - const httpClient = yield* HttpClient.HttpClient - const authorization = () => `Basic ${utf8Base64(`${email}:${Redacted.value(token)}`)}` + /** Send a request and decode a 2xx JSON body; transport failures are retryable, decode failures are not. */ + const send = ( + operation: JiraApiOperation, + schema: Schema.Codec, + request: HttpClientRequest.HttpClientRequest, + ) => + httpClient + .execute( + request.pipe( + HttpClientRequest.setHeader('authorization', authorization()), + HttpClientRequest.setHeader('accept', 'application/json'), + ), + ) + .pipe( + Effect.mapError(() => JiraApiError.make({ operation, retryable: true })), + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? response.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(schema)), + Effect.mapError(() => finalError(operation)), + ) + : Effect.fail(statusError(operation, response)), + ), + ) - /** Send a request and decode a 2xx JSON body; transport failures are retryable, decode failures are not. */ - const send = ( - operation: JiraApiOperation, - schema: Schema.Codec, - request: HttpClientRequest.HttpClientRequest, - ) => - httpClient - .execute( - request.pipe( - HttpClientRequest.setHeader('authorization', authorization()), - HttpClientRequest.setHeader('accept', 'application/json'), + return JiraApi.of({ + postComment: ({ issueKey, markdown }) => + HttpClientRequest.post(`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`).pipe( + HttpClientRequest.bodyJson({ body: markdownToAdf(markdown) }), + Effect.mapError(() => finalError('post_comment')), + Effect.flatMap((request) => send('post_comment', CommentCreated, request)), + Effect.map(({ id }) => ({ commentId: id })), + Effect.withSpan('jira.api.post_comment'), ), - ) - .pipe( - Effect.mapError(() => JiraApiError.make({ operation, retryable: true })), - Effect.flatMap((response) => - response.status >= 200 && response.status < 300 - ? response.json.pipe( - Effect.flatMap(Schema.decodeUnknownEffect(schema)), - Effect.mapError(() => finalError(operation)), - ) - : Effect.fail(statusError(operation, response)), + fetchIssue: (issueKey) => + send( + 'fetch_issue', + IssueResponse, + HttpClientRequest.get( + `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=summary,description`, + ), + ).pipe( + Effect.map(({ key, fields }) => ({ + key, + title: fields.summary, + description: Predicate.isNullish(fields.description) + ? null + : commentPlainText(fields.description), + })), + Effect.withSpan('jira.api.fetch_issue'), ), - ) - - return JiraApi.of({ - postComment: ({ issueKey, markdown }) => - HttpClientRequest.post(`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`).pipe( - HttpClientRequest.bodyJson({ body: markdownToAdf(markdown) }), - Effect.mapError(() => finalError('post_comment')), - Effect.flatMap((request) => send('post_comment', CommentCreated, request)), - Effect.map(({ id }) => ({ commentId: id })), - Effect.withSpan('jira.api.post_comment'), - ), - fetchIssue: (issueKey) => - send( - 'fetch_issue', - IssueResponse, - HttpClientRequest.get( - `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=summary,description`, + myself: () => + send('myself', MyselfResponse, HttpClientRequest.get(`${baseUrl}/rest/api/3/myself`)).pipe( + Effect.map(({ accountId }) => ({ accountId })), + Effect.withSpan('jira.api.myself'), ), - ).pipe( - Effect.map(({ key, fields }) => ({ - key, - title: fields.summary, - description: Predicate.isNullish(fields.description) - ? null - : commentPlainText(fields.description), - })), - Effect.withSpan('jira.api.fetch_issue'), - ), - myself: () => - send('myself', MyselfResponse, HttpClientRequest.get(`${baseUrl}/rest/api/3/myself`)).pipe( - Effect.map(({ accountId }) => ({ accountId })), - Effect.withSpan('jira.api.myself'), - ), - }) - }), -) + }) + }), + ) + +/** `makeJiraApiLive` against the public gateway. */ +export const JiraApiLive: Layer.Layer = makeJiraApiLive() diff --git a/packages/jira/src/JiraBot.ts b/packages/jira/src/JiraBot.ts index 9f311fe..04c8ed8 100644 --- a/packages/jira/src/JiraBot.ts +++ b/packages/jira/src/JiraBot.ts @@ -29,7 +29,8 @@ const JiraApiFetchLive = JiraApiLive.pipe(Layer.provide(FetchHttpClient.layer)) * @property handlers - the callbacks; the bot supplies `JiraApi` to them, the storage supplies * `MailboxSubscriptions`, and anything else they need comes from the application * @property jiraApi - how the bot talks to Jira; defaults to `JiraApiLive` over Fetch, which reads - * `JIRA_BASE_URL`, `JIRA_BOT_EMAIL` and `JIRA_API_TOKEN` from the configuration + * `JIRA_CLOUD_ID` and `JIRA_API_TOKEN` from the configuration and calls the Atlassian API gateway with a + * Bearer token. Both halves build it, so the webhook half needs these two variables as well */ export type MakeOptions = { readonly webhookSecret: Config.Config> diff --git a/packages/jira/test/jira-api-live.vi.test.ts b/packages/jira/test/jira-api-live.vi.test.ts index fc40f0f..d0859d3 100644 --- a/packages/jira/test/jira-api-live.vi.test.ts +++ b/packages/jira/test/jira-api-live.vi.test.ts @@ -1,14 +1,15 @@ import { describe, it } from '@effect/vitest' import { Config, ConfigProvider, Effect, Layer, Queue } from 'effect' -import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/http' +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from 'effect/http' import { JiraApi } from '../src/JiraApi' -import { JiraApiLive } from '../src/JiraApiLive' +import { JiraApiLive, makeJiraApiLive } from '../src/JiraApiLive' const testCredential = 'fake-credential-value' +const cloudId = '11111111-2222-4333-8444-555555555555' +const gateway = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3` const config = { - JIRA_BASE_URL: 'https://example.atlassian.net', - JIRA_BOT_EMAIL: 'bot@example.com', + JIRA_CLOUD_ID: cloudId, JIRA_API_TOKEN: testCredential, } @@ -26,7 +27,7 @@ const okClient = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ accountId: '5b10' }))), ) -const basicAuth = `Basic ${btoa(`bot@example.com:${testCredential}`)}` +const bearerAuth = `Bearer ${testCredential}` describe('JiraApiLive', () => { it.effect('posts a comment as an ADF document with one paragraph per block', ({ expect }) => @@ -50,8 +51,8 @@ describe('JiraApiLive', () => { expect(posted).toEqual({ commentId: '10001' }) expect(call.method).toBe('POST') - expect(call.url).toBe('https://example.atlassian.net/rest/api/3/issue/ABC-1/comment') - expect(call.auth).toBe(basicAuth) + expect(call.url).toBe(`${gateway}/issue/ABC-1/comment`) + expect(call.auth).toBe(bearerAuth) expect(JSON.parse(call.body)).toEqual({ body: { version: 1, @@ -176,41 +177,140 @@ describe('JiraApiLive', () => { }), ) - it.effect('encodes non-Latin1 credentials as UTF-8 for basic auth', ({ expect }) => + it.effect('sends every operation to the gateway with the Bearer token', ({ expect }) => Effect.gen(function* () { - const seen = yield* Queue.unbounded() + const seen = yield* Queue.unbounded<{ method: string; url: string; auth: string | null }>() const httpClient = HttpClient.make((request) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) - yield* Queue.offer(seen, web.headers.get('authorization')) + yield* Queue.offer(seen, { + method: web.method, + url: web.url, + auth: web.headers.get('authorization'), + }) + const body = request.url.endsWith('/myself') + ? { accountId: '712020:9a62b034-dabf-4364-b257-f4ad41e00a74' } + : request.method === 'POST' + ? { id: '10001' } + : { key: 'ABC/1', fields: { summary: 'S' } } + return HttpClientResponse.fromWeb(request, Response.json(body)) + }), + ) + const api = yield* JiraApi.pipe(Effect.provide(makeLayer(httpClient))) + yield* api.myself() + yield* api.fetchIssue('ABC/1') + yield* api.postComment({ issueKey: 'ABC/1', markdown: 'x' }) + const calls = [yield* Queue.take(seen), yield* Queue.take(seen), yield* Queue.take(seen)] + expect(calls).toEqual([ + { method: 'GET', url: `${gateway}/myself`, auth: bearerAuth }, + { method: 'GET', url: `${gateway}/issue/ABC%2F1?fields=summary,description`, auth: bearerAuth }, + { method: 'POST', url: `${gateway}/issue/ABC%2F1/comment`, auth: bearerAuth }, + ]) + }), + ) + + it.effect('accepts a service-account id from myself', ({ expect }) => + Effect.gen(function* () { + const accountId = '712020:9a62b034-dabf-4364-b257-f4ad41e00a74' + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ accountId, accountType: 'app', displayName: 'slopcodebot' }), + ), + ), + ) + const api = yield* JiraApi.pipe(Effect.provide(makeLayer(httpClient))) + expect(yield* api.myself()).toEqual({ accountId }) + }), + ) + + it.effect('uses an overridden gateway base', ({ expect }) => + Effect.gen(function* () { + const seen = yield* Queue.unbounded() + const httpClient = HttpClient.make((request) => + Effect.gen(function* () { + yield* Queue.offer(seen, request.url) return HttpClientResponse.fromWeb(request, Response.json({ accountId: '5b10' })) }), ) - const email = 'bøt@exämple.com' - const credential = 'naïve-crédential-✓' - const api = yield* JiraApi.pipe( - Effect.provide(makeLayer(httpClient, { JIRA_BOT_EMAIL: email, JIRA_API_TOKEN: credential })), + const layer = makeJiraApiLive({ gatewayBaseUrl: 'http://127.0.0.1:9999/' }).pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, httpClient), + ConfigProvider.layer(ConfigProvider.fromUnknown(config)), + ), + ), ) - expect(yield* api.myself()).toEqual({ accountId: '5b10' }) - const authorization = yield* Queue.take(seen) - expect(authorization).toBe(`Basic ${Buffer.from(`${email}:${credential}`, 'utf8').toString('base64')}`) + const api = yield* JiraApi.pipe(Effect.provide(layer)) + yield* api.myself() + expect(yield* Queue.take(seen)).toBe(`http://127.0.0.1:9999/ex/jira/${cloudId}/rest/api/3/myself`) }), ) - it.effect('requires an https JIRA_BASE_URL', ({ expect }) => + it.effect('rejects a JIRA_CLOUD_ID that is not a UUID', ({ expect }) => Effect.gen(function* () { - for (const baseUrl of ['http://example.atlassian.net', 'ftp://example.atlassian.net', 'not a url']) { + for (const bad of [ + '', + 'not-a-uuid', + `${cloudId}/../admin`, + '../x', + `${cloudId}?a=b`, + 'example.atlassian.net', + ]) { const error = yield* JiraApi.pipe( - Effect.provide(makeLayer(okClient, { JIRA_BASE_URL: baseUrl })), + Effect.provide(makeLayer(okClient, { JIRA_CLOUD_ID: bad })), Effect.flip, ) expect(error).toBeInstanceOf(Config.ConfigError) - expect(String(error)).toContain('JIRA_BASE_URL') + expect(String(error)).toContain('JIRA_CLOUD_ID') } - const api = yield* JiraApi.pipe( - Effect.provide(makeLayer(okClient, { JIRA_BASE_URL: 'https://example.atlassian.net/' })), - ) + }), + ) + + it.effect('names each missing variable in the ConfigError', ({ expect }) => + Effect.gen(function* () { + for (const name of ['JIRA_CLOUD_ID', 'JIRA_API_TOKEN'] as const) { + const { [name]: _omitted, ...rest } = config + const error = yield* JiraApi.pipe( + Effect.provide( + JiraApiLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, okClient), + ConfigProvider.layer(ConfigProvider.fromUnknown(rest)), + ), + ), + ), + ), + Effect.flip, + ) + expect(error).toBeInstanceOf(Config.ConfigError) + expect(String(error)).toContain(name) + } + }), + ) + + it.effect('does not need JIRA_BASE_URL or JIRA_BOT_EMAIL', ({ expect }) => + Effect.gen(function* () { + const api = yield* JiraApi.pipe(Effect.provide(makeLayer(okClient))) expect(yield* api.myself()).toEqual({ accountId: '5b10' }) }), ) + + it.effect('never puts the token in request-error values', ({ expect }) => + Effect.gen(function* () { + const failing = HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, description: 'boom' }), + }), + ), + ) + const api = yield* JiraApi.pipe(Effect.provide(makeLayer(failing))) + const error = yield* api.myself().pipe(Effect.flip) + expect(error.retryable).toBe(true) + expect(JSON.stringify(error) + String(error)).not.toContain('fake-credential') + }), + ) }) From 85830e96539cd8401eeaee693c8276301bd37305 Mon Sep 17 00:00:00 2001 From: yashodhah Date: Mon, 19 Oct 2026 22:48:33 +0800 Subject: [PATCH 9/9] feat(jira): mention the requester in replies Save the triggering comment's author as an optional replyTo on the JiraIssue destination at prepare time, and have the output processor pass it to JiraApi.postComment as a structural mention. The posted ADF body then starts with a mention node followed by the first block's text. Markdown is still converted to plain text nodes only, so mention-like text in user content never becomes a mention. replyTo is optional, so destinations saved without it still decode and jiraPresentationVersion stays 1. Co-Authored-By: Claude Sonnet 5.5 --- packages/jira/ASSUMPTIONS.md | 2 + packages/jira/README.md | 2 +- packages/jira/src/JiraApi.ts | 13 ++- packages/jira/src/JiraApiLive.ts | 38 ++++++--- packages/jira/src/JiraDeliveryDestination.ts | 16 +++- packages/jira/src/JiraDeliveryOutput.ts | 26 +++--- packages/jira/src/JiraEventProcessor.ts | 2 +- packages/jira/test/jira-api-live.vi.test.ts | 79 ++++++++++++++++++- .../test/jira-delivery-destination.vi.test.ts | 50 ++++++++++++ .../jira/test/jira-delivery-output.vi.test.ts | 48 +++++++++++ .../test/jira-event-processing.vi.test.ts | 26 +++++- 11 files changed, 275 insertions(+), 27 deletions(-) create mode 100644 packages/jira/test/jira-delivery-destination.vi.test.ts diff --git a/packages/jira/ASSUMPTIONS.md b/packages/jira/ASSUMPTIONS.md index ff7f79c..f6c4b4c 100644 --- a/packages/jira/ASSUMPTIONS.md +++ b/packages/jira/ASSUMPTIONS.md @@ -27,6 +27,8 @@ Only authentication and `myself` were checked against a real site (section (f)). - Auth: the bot is an Atlassian service account with an API token. Every call goes through the API gateway, `https://api.atlassian.com/ex/jira//rest/api/3/...`, with `Authorization: Bearer `. Basic auth (email + token against the site URL) is not used. `JIRA_CLOUD_ID` must be a UUID (it is part of the request path); anything else fails the layer build with a `ConfigError`. The cloud id comes from `https://.atlassian.net/_edge/tenant_info`. - VERIFIED 2026-10-19 by a real curl against a real site: `GET https://api.atlassian.com/ex/jira//rest/api/3/myself` with `Authorization: Bearer ` returned 200 with `accountId` in the form `:` (e.g. `712020:9a62b034-dabf-4364-b257-f4ad41e00a74`), `accountType: "app"`, `displayName`. Only this 200 on `myself` was verified. - `POST /rest/api/3/issue/{key}/comment` with body `{ "body": }`, one `paragraph` node per block; returns the created comment with `id`. Status: documented (REST v3), not verified. Assumed to work through the same gateway prefix with the same Bearer token. +- Replies mention the requester: when the saved destination has `replyTo` (the triggering comment's `author`, from the signed webhook), the first paragraph of the posted ADF body is `{ "type": "mention", "attrs": { "id": "", "text": "@" } }` followed by a `text` node with a leading space and the first block's text. Status: UNVERIFIED. The node shape follows the ADF mention spec (section (c)) but posting it (without `accessLevel`) through the gateway has not been tried against a real site; confirm that Jira accepts it, renders it as a mention, and notifies the requester. Markdown text is never turned into a mention, so `[~accountid:...]` or `@name` in user-controlled text stays plain text. +- Jira Cloud REST v3 has no comment-reaction endpoint and no comment threading field (checked against Atlassian's published OpenAPI spec), so an acknowledgement is a separate comment and a "reply" is a new comment that starts with a mention. - `GET /rest/api/3/issue/{key}?fields=summary,description`. Status: documented (REST v3), not verified. Same gateway and Bearer assumption. ## Fixtures diff --git a/packages/jira/README.md b/packages/jira/README.md index de84610..977220d 100644 --- a/packages/jira/README.md +++ b/packages/jira/README.md @@ -1,6 +1,6 @@ # Jira -`@humanlayer/channels-jira` turns Jira Cloud `comment_created` webhooks that mention a bot account into durable issue callbacks. It verifies the `X-Hub-Signature` HMAC, stores accepted events in provider-neutral mailboxes keyed by issue, runs `onMentioned` with the text after the mention, and posts replies as issue comments through `JiraApi`. +`@humanlayer/channels-jira` turns Jira Cloud `comment_created` webhooks that mention a bot account into durable issue callbacks. It verifies the `X-Hub-Signature` HMAC, stores accepted events in provider-neutral mailboxes keyed by issue, runs `onMentioned` with the text after the mention, and posts replies as issue comments through `JiraApi`. A reply starts with an ADF mention of the person whose comment started the delivery (`replyTo` on the saved destination, taken from the signed webhook's comment author; never parsed from text). Jira has no comment reactions or threads, so that mention is how a reply points at its request. Status: experimental. Only authentication and `GET /myself` through the gateway have been checked against a real site (2026-10-19); the payload, signature and mention formats, and the comment and issue calls, are unverified and listed in [ASSUMPTIONS.md](./ASSUMPTIONS.md). diff --git a/packages/jira/src/JiraApi.ts b/packages/jira/src/JiraApi.ts index 45a7bac..264eecf 100644 --- a/packages/jira/src/JiraApi.ts +++ b/packages/jira/src/JiraApi.ts @@ -17,6 +17,12 @@ export class JiraApiError extends Schema.TaggedError()('JiraApiErr ), }) {} +/** An account a comment mentions. */ +export type JiraMention = { + readonly accountId: string + readonly displayName: string +} + export type JiraIssueInfo = { readonly key: string readonly title: string @@ -24,10 +30,15 @@ export type JiraIssueInfo = { } export type JiraApiOperations = { - /** Comment on an issue. Each blank-line-separated block of `markdown` becomes one plain-text paragraph. */ + /** + * Comment on an issue. Each blank-line-separated block of `markdown` becomes one plain-text paragraph; + * nothing in `markdown` ever becomes a mention. `mention`, when given, starts the first paragraph with an + * ADF mention of that account; it must come from a structural source (a webhook's author), never from text. + */ readonly postComment: (input: { readonly issueKey: string readonly markdown: string + readonly mention?: JiraMention }) => Effect.Effect<{ readonly commentId: string }, JiraApiError> readonly fetchIssue: (issueKey: string) => Effect.Effect /** The account the credentials belong to. */ diff --git a/packages/jira/src/JiraApiLive.ts b/packages/jira/src/JiraApiLive.ts index 158632d..05edb35 100644 --- a/packages/jira/src/JiraApiLive.ts +++ b/packages/jira/src/JiraApiLive.ts @@ -2,7 +2,7 @@ import { Config, Effect, Layer, Predicate, Redacted, Schema } from 'effect' import { HttpClient, HttpClientRequest, type HttpClientResponse } from 'effect/http' -import { JiraApi, JiraApiError, type JiraApiOperation } from './JiraApi' +import { JiraApi, JiraApiError, type JiraApiOperation, type JiraMention } from './JiraApi' import { commentPlainText, JiraAdfNode } from './JiraComment' const CommentCreated = Schema.Struct({ id: Schema.NonEmptyString }) @@ -15,16 +15,34 @@ const IssueResponse = Schema.Struct({ }) const MyselfResponse = Schema.Struct({ accountId: Schema.NonEmptyString }) -/** One ADF paragraph per blank-line-separated block of text; no markdown is rendered. */ -export const markdownToAdf = (markdown: string) => ({ - version: 1, - type: 'doc', - content: markdown +type AdfInline = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'mention'; readonly attrs: { readonly id: string; readonly text: string } } +type AdfParagraph = { readonly type: 'paragraph'; readonly content: ReadonlyArray } + +/** + * One ADF paragraph per blank-line-separated block of text; no markdown is rendered, and the text only + * ever becomes `text` nodes (so `[~accountid:...]` or `@name` in it stays text). When `mention` is given, + * the first paragraph starts with a mention node, then the first block's text after a single space. + */ +export const markdownToAdf = (markdown: string, mention?: JiraMention) => { + const blocks = markdown .split(/\n{2,}/) .map((block) => block.trim()) .filter((block) => block.length > 0) - .map((text) => ({ type: 'paragraph', content: [{ type: 'text', text }] })), -}) + const paragraph = (content: ReadonlyArray): AdfParagraph => ({ type: 'paragraph', content }) + const text = (value: string): AdfInline => ({ type: 'text', text: value }) + if (Predicate.isUndefined(mention)) { + return { version: 1, type: 'doc', content: blocks.map((block) => paragraph([text(block)])) } + } + const mentionNode: AdfInline = { + type: 'mention', + attrs: { id: mention.accountId, text: `@${mention.displayName}` }, + } + const [first, ...rest] = blocks + const lead = paragraph(Predicate.isUndefined(first) ? [mentionNode] : [mentionNode, text(` ${first}`)]) + return { version: 1, type: 'doc', content: [lead, ...rest.map((block) => paragraph([text(block)]))] } +} const secondsPattern = /^\d+$/ const retryAfterMillis = (value: string | undefined) => { @@ -100,9 +118,9 @@ export const makeJiraApiLive = ( ) return JiraApi.of({ - postComment: ({ issueKey, markdown }) => + postComment: ({ issueKey, markdown, mention }) => HttpClientRequest.post(`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`).pipe( - HttpClientRequest.bodyJson({ body: markdownToAdf(markdown) }), + HttpClientRequest.bodyJson({ body: markdownToAdf(markdown, mention) }), Effect.mapError(() => finalError('post_comment')), Effect.flatMap((request) => send('post_comment', CommentCreated, request)), Effect.map(({ id }) => ({ commentId: id })), diff --git a/packages/jira/src/JiraDeliveryDestination.ts b/packages/jira/src/JiraDeliveryDestination.ts index bcf499e..4037d95 100644 --- a/packages/jira/src/JiraDeliveryDestination.ts +++ b/packages/jira/src/JiraDeliveryDestination.ts @@ -7,12 +7,22 @@ import { Schema } from 'effect' import { JiraIssueRef } from './JiraCallbackEvents' -/** The version of {@link JiraDeliveryDestination}. Bump when it changes shape. */ +/** + * The version of {@link JiraDeliveryDestination}. Bump when it changes shape incompatibly. Adding the + * optional `replyTo` did not bump it: destinations saved without it still decode. + */ export const jiraPresentationVersion = 1 -/** The issue a delivery replies on. */ +/** The person a reply mentions: the author of the comment that started the delivery, from the signed webhook. */ +export const JiraReplyTo = Schema.Struct({ accountId: Schema.String, displayName: Schema.String }) +export type JiraReplyTo = typeof JiraReplyTo.Type + +/** + * The issue a delivery replies on. `replyTo` is the requester replies start by mentioning; it is optional + * so destinations saved before it existed still decode (their replies mention no one). + */ export const JiraDeliveryDestination = Schema.TaggedUnion({ - JiraIssue: { issue: JiraIssueRef }, + JiraIssue: { issue: JiraIssueRef, replyTo: Schema.optionalKey(JiraReplyTo) }, }) export type JiraDeliveryDestination = typeof JiraDeliveryDestination.Type diff --git a/packages/jira/src/JiraDeliveryOutput.ts b/packages/jira/src/JiraDeliveryOutput.ts index 7dccf25..61d326e 100644 --- a/packages/jira/src/JiraDeliveryOutput.ts +++ b/packages/jira/src/JiraDeliveryOutput.ts @@ -4,6 +4,7 @@ * - `PresentOutcome` comments its Markdown on the issue, with any `awaitingInput` options listed after it. * Without Markdown it makes no comment. * - `CreateMessage` comments. Its receipt holds the comment id. + * - Both start the comment with a mention of the destination's saved `replyTo` (the requester), when present. * - Everything else (`SetActivity`, `SetMessageReaction`, `UpdateMessage`, `DeleteMessage`, `RenderPlan`, * `AddExternalLink`) is not in `jiraSupportedOperations` and fails as final `unsupported_operation`. * @@ -20,7 +21,7 @@ import { } from '@humanlayer/channels-delivery' import { Array as Arr, Effect, Match, Predicate, Schema } from 'effect' -import { JiraApi } from './JiraApi' +import { JiraApi, type JiraMention } from './JiraApi' import { JiraDeliveryDestinationJson, jiraPresentationVersion } from './JiraDeliveryDestination' /** What Jira made for an operation: the comment it posted. Saved by the store, read only here. */ @@ -49,13 +50,19 @@ const outcomeMarkdown = (outcome: DeliveryOutcome, markdown: string) => Match.orElse(() => markdown), ) -/** Comment on the issue; the receipt holds the comment id. */ -const postComment = Effect.fn('jira.output.post_comment')(function* (issueKey: string, markdown: string) { +/** Comment on the issue, mentioning `mention` first when given; the receipt holds the comment id. */ +const postComment = Effect.fn('jira.output.post_comment')(function* ( + issueKey: string, + markdown: string, + mention: JiraMention | undefined, +) { const api = yield* JiraApi - const posted = yield* api.postComment({ issueKey, markdown }).pipe( - Effect.tapError((error) => Effect.logWarning('Jira output comment failed', error)), - Effect.mapError((error) => failed('jira_comment_failed', error.retryable, error.retryAfterMs)), - ) + const posted = yield* api + .postComment(Predicate.isUndefined(mention) ? { issueKey, markdown } : { issueKey, markdown, mention }) + .pipe( + Effect.tapError((error) => Effect.logWarning('Jira output comment failed', error)), + Effect.mapError((error) => failed('jira_comment_failed', error.retryable, error.retryAfterMs)), + ) const receipt = yield* Schema.encodeEffect(JiraOutputReceiptJson)( JiraOutputReceipt.make({ comment: { id: posted.commentId } }), ).pipe( @@ -89,15 +96,16 @@ export const makeJiraOutputProcessor = Effect.fn('jira.make_output_processor')(f yield* Effect.logWarning('Jira output retried after an attempt that may have been applied') } const issueKey = destination.issue.key + const mention = destination.replyTo yield* Effect.annotateCurrentSpan({ 'jira.issue_key': issueKey }) return yield* Match.value(attempt.operation).pipe( Match.tag('PresentOutcome', (operation) => Predicate.isUndefined(operation.markdown) ? Effect.succeed(DeliveryOutputApplied.make({})) - : postComment(issueKey, outcomeMarkdown(operation.outcome, operation.markdown)), + : postComment(issueKey, outcomeMarkdown(operation.outcome, operation.markdown), mention), ), - Match.tag('CreateMessage', ({ markdown }) => postComment(issueKey, markdown)), + Match.tag('CreateMessage', ({ markdown }) => postComment(issueKey, markdown, mention)), Match.orElse(() => Effect.fail(unsupported())), ) }, diff --git a/packages/jira/src/JiraEventProcessor.ts b/packages/jira/src/JiraEventProcessor.ts index f7d8ec1..525586a 100644 --- a/packages/jira/src/JiraEventProcessor.ts +++ b/packages/jira/src/JiraEventProcessor.ts @@ -155,7 +155,7 @@ const prepareInvocation = Effect.fn('jira.prepare_delivery')(function* ( event: JiraMentioned, ) { const destination = yield* Schema.encodeEffect(JiraDeliveryDestinationJson)( - JiraDeliveryDestination.cases.JiraIssue.make({ issue: event.issue }), + JiraDeliveryDestination.cases.JiraIssue.make({ issue: event.issue, replyTo: event.comment.author }), ).pipe( Effect.tapError((error) => Effect.logError('Jira delivery destination could not be encoded', error)), Effect.mapError(() => nonRetryableFailure('delivery_destination_unencodable')), diff --git a/packages/jira/test/jira-api-live.vi.test.ts b/packages/jira/test/jira-api-live.vi.test.ts index d0859d3..a1e9db8 100644 --- a/packages/jira/test/jira-api-live.vi.test.ts +++ b/packages/jira/test/jira-api-live.vi.test.ts @@ -3,7 +3,7 @@ import { Config, ConfigProvider, Effect, Layer, Queue } from 'effect' import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from 'effect/http' import { JiraApi } from '../src/JiraApi' -import { JiraApiLive, makeJiraApiLive } from '../src/JiraApiLive' +import { JiraApiLive, makeJiraApiLive, markdownToAdf } from '../src/JiraApiLive' const testCredential = 'fake-credential-value' const cloudId = '11111111-2222-4333-8444-555555555555' @@ -66,6 +66,83 @@ describe('JiraApiLive', () => { }), ) + it.effect('starts the first paragraph with a structural mention when one is given', ({ expect }) => + Effect.gen(function* () { + const bodies = yield* Queue.unbounded() + const httpClient = HttpClient.make((request) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + yield* Queue.offer(bodies, yield* Effect.promise(() => web.text())) + return HttpClientResponse.fromWeb(request, Response.json({ id: '10002' }, { status: 201 })) + }), + ) + const api = yield* JiraApi.pipe(Effect.provide(makeLayer(httpClient))) + yield* api.postComment({ + issueKey: 'ABC-1', + markdown: 'Echo: title\n\nsecond block', + mention: { accountId: '557058a1b2c3d4e5f6a7b8c9', displayName: 'Alex Example' }, + }) + yield* api.postComment({ issueKey: 'ABC-1', markdown: 'Echo: title' }) + expect(JSON.parse(yield* Queue.take(bodies))).toEqual({ + body: { + version: 1, + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'mention', + attrs: { id: '557058a1b2c3d4e5f6a7b8c9', text: '@Alex Example' }, + }, + { type: 'text', text: ' Echo: title' }, + ], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'second block' }] }, + ], + }, + }) + expect(JSON.parse(yield* Queue.take(bodies))).toEqual({ + body: { + version: 1, + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Echo: title' }] }], + }, + }) + }), + ) + + it('keeps a mention as the only first-paragraph content when the markdown is empty', ({ expect }) => { + expect(markdownToAdf('', { accountId: 'acc-1', displayName: 'Alex' })).toEqual({ + version: 1, + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'mention', attrs: { id: 'acc-1', text: '@Alex' } }] }], + }) + }) + + it('never turns mention-like text in the markdown into a mention node', ({ expect }) => { + const markdown = 'Title [~accountid:abc] and @someone\n\n<@U123> [~accountid:557058a1b2c3d4e5f6a7b8c9]' + const withoutMention = markdownToAdf(markdown) + expect(withoutMention).toEqual({ + version: 1, + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Title [~accountid:abc] and @someone' }] }, + { + type: 'paragraph', + content: [{ type: 'text', text: '<@U123> [~accountid:557058a1b2c3d4e5f6a7b8c9]' }], + }, + ], + }) + const withMention = markdownToAdf(markdown, { accountId: 'requester', displayName: 'Alex' }) + const mentions = JSON.stringify(withMention).match(/"type":"mention"/g) ?? [] + expect(mentions).toHaveLength(1) + expect(withMention.content[0]?.content).toEqual([ + { type: 'mention', attrs: { id: 'requester', text: '@Alex' } }, + { type: 'text', text: ' Title [~accountid:abc] and @someone' }, + ]) + }) + it.effect('maps an issue to key, title and plain-text description', ({ expect }) => Effect.gen(function* () { const httpClient = HttpClient.make((request) => diff --git a/packages/jira/test/jira-delivery-destination.vi.test.ts b/packages/jira/test/jira-delivery-destination.vi.test.ts new file mode 100644 index 0000000..ed86add --- /dev/null +++ b/packages/jira/test/jira-delivery-destination.vi.test.ts @@ -0,0 +1,50 @@ +/** The saved Jira destination: it round-trips with and without the requester, and older saved shapes still decode. */ +import { describe, it } from '@effect/vitest' +import { Effect, Schema } from 'effect' + +import { JiraDeliveryDestination, JiraDeliveryDestinationJson, JiraIssueId, jiraPresentationVersion } from '../src' + +const issue = { id: Schema.decodeSync(JiraIssueId)('10042'), key: 'ABC-7', mailboxKey: 'jira:v1:mailbox' } + +describe('Jira delivery destination', () => { + it.effect('round-trips a destination with replyTo', ({ expect }) => + Effect.gen(function* () { + const destination = JiraDeliveryDestination.cases.JiraIssue.make({ + issue, + replyTo: { accountId: '557058a1b2c3d4e5f6a7b8c9', displayName: 'Alex Example' }, + }) + const encoded = yield* Schema.encodeEffect(JiraDeliveryDestinationJson)(destination) + expect(encoded).toEqual({ + _tag: 'JiraIssue', + issue: { id: '10042', key: 'ABC-7', mailboxKey: 'jira:v1:mailbox' }, + replyTo: { accountId: '557058a1b2c3d4e5f6a7b8c9', displayName: 'Alex Example' }, + }) + expect(yield* Schema.decodeEffect(JiraDeliveryDestinationJson)(encoded)).toEqual(destination) + }), + ) + + it.effect('round-trips a destination without replyTo', ({ expect }) => + Effect.gen(function* () { + const destination = JiraDeliveryDestination.cases.JiraIssue.make({ issue }) + const encoded = yield* Schema.encodeEffect(JiraDeliveryDestinationJson)(destination) + expect(encoded).toEqual({ + _tag: 'JiraIssue', + issue: { id: '10042', key: 'ABC-7', mailboxKey: 'jira:v1:mailbox' }, + }) + expect(yield* Schema.decodeEffect(JiraDeliveryDestinationJson)(encoded)).toEqual(destination) + }), + ) + + it.effect('decodes a destination saved before replyTo existed, under the same presentation version', ({ expect }) => + Effect.gen(function* () { + const savedBeforeReplyTo = { + _tag: 'JiraIssue', + issue: { id: '10042', key: 'ABC-7', mailboxKey: 'jira:v1:mailbox' }, + } + const decoded = yield* Schema.decodeEffect(JiraDeliveryDestinationJson)(savedBeforeReplyTo) + expect(decoded.issue.key).toBe('ABC-7') + expect(decoded.replyTo).toBeUndefined() + expect(jiraPresentationVersion).toBe(1) + }), + ) +}) diff --git a/packages/jira/test/jira-delivery-output.vi.test.ts b/packages/jira/test/jira-delivery-output.vi.test.ts index 6904243..3906192 100644 --- a/packages/jira/test/jira-delivery-output.vi.test.ts +++ b/packages/jira/test/jira-delivery-output.vi.test.ts @@ -82,6 +82,54 @@ describe('Jira delivery output', () => { }), ) + it.effect('PresentOutcome and CreateMessage mention the saved requester', ({ expect }) => + Effect.gen(function* () { + const replyTo = { accountId: '557058a1b2c3d4e5f6a7b8c9', displayName: 'Alex Example' } + const withReplyTo = PreparedDeliveryInvocation.make({ + ...prepared, + destination: yield* Schema.encodeEffect(JiraDeliveryDestinationJson)( + JiraDeliveryDestination.cases.JiraIssue.make({ issue: destination.issue, replyTo }), + ), + }) + const inputs = yield* Ref.make>([]) + const api = Layer.mock(JiraApi, { + postComment: (input) => + Ref.update(inputs, (all) => [...all, input]).pipe(Effect.as({ commentId: '901' })), + }) + const processor = yield* makeJiraOutputProcessor({ namespace: 'jira-output-test' }).pipe( + Effect.provide(api), + ) + for (const operation of [ + ProviderPresentOutcome.make({ clearActivity: true, outcome: completed, markdown: 'Done.' }), + CreateMessage.make({ messageId: MessageId.make('m'), markdown: 'Hi' }), + ]) { + const result = yield* processor + .process(ProviderOutputAttempt.make({ ...attempt(operation), prepared: withReplyTo })) + .pipe(Effect.result) + expect(Result.isSuccess(result)).toBe(true) + } + expect(yield* Ref.get(inputs)).toEqual([ + { issueKey: 'ABC-7', markdown: 'Done.', mention: replyTo }, + { issueKey: 'ABC-7', markdown: 'Hi', mention: replyTo }, + ]) + }), + ) + + it.effect('posts without a mention when the saved destination has no requester', ({ expect }) => + Effect.gen(function* () { + const inputs = yield* Ref.make>([]) + const api = Layer.mock(JiraApi, { + postComment: (input) => + Ref.update(inputs, (all) => [...all, input]).pipe(Effect.as({ commentId: '902' })), + }) + const processor = yield* makeJiraOutputProcessor({ namespace: 'jira-output-test' }).pipe( + Effect.provide(api), + ) + yield* processor.process(attempt(CreateMessage.make({ messageId: MessageId.make('m'), markdown: 'Hi' }))) + expect(yield* Ref.get(inputs)).toEqual([{ issueKey: 'ABC-7', markdown: 'Hi' }]) + }), + ) + it.effect('PresentOutcome without markdown posts nothing', ({ expect }) => Effect.gen(function* () { const { result, calls } = yield* run( diff --git a/packages/jira/test/jira-event-processing.vi.test.ts b/packages/jira/test/jira-event-processing.vi.test.ts index 2e759d5..e79de38 100644 --- a/packages/jira/test/jira-event-processing.vi.test.ts +++ b/packages/jira/test/jira-event-processing.vi.test.ts @@ -89,7 +89,11 @@ describe('Jira event batch processing', () => { PreparedDeliveryInvocation.make({ callback: 'onMentioned', presentationVersion: 1, - destination: { _tag: 'JiraIssue', issue: { id: '10042', key: 'ABC-7', mailboxKey } }, + destination: { + _tag: 'JiraIssue', + issue: { id: '10042', key: 'ABC-7', mailboxKey }, + replyTo: { accountId: '557058a1b2c3d4e5f6a7b8c9', displayName: 'Alex Example' }, + }, supportedOperations: jiraSupportedOperations, reactionTargets: [], }), @@ -97,6 +101,26 @@ describe('Jira event batch processing', () => { }), ) + it.effect("ignores the bot's own reply that starts with a mention of the requester", ({ expect }) => + Effect.gen(function* () { + const payload = fixturePayload('comment-created-by-bot.json') + payload.comment.body.content = [ + { + type: 'paragraph', + content: [ + { type: 'mention', attrs: { id: '557058a1b2c3d4e5f6a7b8c9', text: '@Alex Example' } }, + { type: 'text', text: ' Echo: Add rate limiting' }, + ], + }, + ] + const onMentioned = vi.fn(() => Effect.void) + const { result, preparations } = yield* process({ onMentioned }, [admission(payload, 'bot-reply')]) + expect(result).toEqual(ProviderEventIgnored.make({ reason: 'no_relevant_event' })) + expect(onMentioned).not.toHaveBeenCalled() + expect(preparations).toEqual([]) + }), + ) + it.effect('finds a mention nested in a quote', ({ expect }) => Effect.gen(function* () { const onMentioned = vi.fn(() => Effect.void)