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/ASSUMPTIONS.md b/packages/jira/ASSUMPTIONS.md new file mode 100644 index 0000000..f6c4b4c --- /dev/null +++ b/packages/jira/ASSUMPTIONS.md @@ -0,0 +1,35 @@ +# Jira channel assumptions + +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/ +- 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. 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 +- `{ "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. +- 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 +- 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 +`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..977220d --- /dev/null +++ b/packages/jira/README.md @@ -0,0 +1,17 @@ +# 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`. 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). + +## Configuration + +`JiraBot.make` reads: + +- `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/package.json b/packages/jira/package.json new file mode 100644 index 0000000..a679394 --- /dev/null +++ b/packages/jira/package.json @@ -0,0 +1,30 @@ +{ + "name": "@humanlayer/channels-jira", + "version": "0.0.0", + "private": true, + "files": [ + "src" + ], + "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/JiraApi.ts b/packages/jira/src/JiraApi.ts new file mode 100644 index 0000000..264eecf --- /dev/null +++ b/packages/jira/src/JiraApi.ts @@ -0,0 +1,49 @@ +/** 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)), + ), +}) {} + +/** An account a comment mentions. */ +export type JiraMention = { + readonly accountId: string + readonly displayName: string +} + +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; + * 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. */ + 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..05edb35 --- /dev/null +++ b/packages/jira/src/JiraApiLive.ts @@ -0,0 +1,156 @@ +/** `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' + +import { JiraApi, JiraApiError, type JiraApiOperation, type JiraMention } 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 }) + +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) + 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) => { + 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 }) + +/** + * 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 +} + +/** + * 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)}` + + /** 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, mention }) => + HttpClientRequest.post(`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`).pipe( + HttpClientRequest.bodyJson({ body: markdownToAdf(markdown, mention) }), + 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'), + ), + }) + }), + ) + +/** `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 new file mode 100644 index 0000000..04c8ed8 --- /dev/null +++ b/packages/jira/src/JiraBot.ts @@ -0,0 +1,89 @@ +/** + * This file defines `JiraBot.make`: the Jira provider as `Channels.make` takes it. + */ +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 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_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> + readonly deliveryMode: DeliveryMode + readonly bot: JiraBotConfiguration | Config.Config + readonly site: Config.Config + readonly handlers: JiraCallbackHandlers + readonly jiraApi?: Layer.Layer +} + +export const make = ( + options: MakeOptions, +): ChannelsProvider<{ + 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', + 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 + yield* buildJiraApi + return makeJiraWebhookProvider({ namespace, webhookSecret, site }) + }), + eventProcessor: Effect.fn('jira.bot.build_event_processor')(function* ({ namespace }) { + const bot = yield* readBotConfiguration + 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: 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/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..b991533 --- /dev/null +++ b/packages/jira/src/JiraComment.ts @@ -0,0 +1,124 @@ +/** + * 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' + +/** 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) + }) +} + +/** 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)) renderWikiMarkup(body, rendering, accountId) + else renderNode(body, rendering, accountId) + return rendering +} + +/** 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. */ +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) + else { + for (const match of body.matchAll(wikiMention)) { + const id = match[1] + if (id !== undefined && !ids.includes(id)) ids.push(id) + } + } + 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..4037d95 --- /dev/null +++ b/packages/jira/src/JiraDeliveryDestination.ts @@ -0,0 +1,33 @@ +/** + * 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 incompatibly. Adding the + * optional `replyTo` did not bump it: destinations saved without it still decode. + */ +export const jiraPresentationVersion = 1 + +/** 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, replyTo: Schema.optionalKey(JiraReplyTo) }, +}) +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: 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..61d326e --- /dev/null +++ b/packages/jira/src/JiraDeliveryOutput.ts @@ -0,0 +1,120 @@ +/** + * 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. + * - 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`. + * + * 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, 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. */ +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, 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(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( + 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 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) { + 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 + 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), mention), + ), + Match.tag('CreateMessage', ({ markdown }) => postComment(issueKey, markdown, mention)), + Match.orElse(() => Effect.fail(unsupported())), + ) + }, + (effect, attempt) => + 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 +}) diff --git a/packages/jira/src/JiraEventProcessor.ts b/packages/jira/src/JiraEventProcessor.ts new file mode 100644 index 0000000..525586a --- /dev/null +++ b/packages/jira/src/JiraEventProcessor.ts @@ -0,0 +1,256 @@ +/** Jira post-admission batch identity validation, mention detection, and callback routing. */ +import { + deliveryMailboxKey, + type DeliveryAdmission, + type DeliveryAdmissionBatch, + type DeliveryContext, + 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) + /** Warn, so a payload mismatch is visible in production; log the body format only, never the content. */ + const annotations = (bodyFormat: 'unrecognized' | 'wiki_markup') => ({ + 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(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 } +}) + +/** 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, replyTo: event.comment.author }), + ).pipe( + Effect.tapError((error) => Effect.logError('Jira delivery destination could not be encoded', error)), + Effect.mapError(() => nonRetryableFailure('delivery_destination_unencodable')), + ) + const prepared = PreparedDeliveryInvocation.make({ + callback: 'onMentioned', + presentationVersion: jiraPresentationVersion, + destination, + supportedOperations: jiraSupportedOperations, + reactionTargets: [], + }) + return yield* execution.prepare(prepared).pipe( + Effect.tapError((error) => + Effect.logError('Jira delivery preparation failed', error).pipe( + Effect.annotateLogs({ deliveryId: execution.deliveryId, callback: prepared.callback }), + ), + ), + 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 batch: JiraBatchEvents + readonly delivery: DeliveryContext +}) { + 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)), + ), + 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, + 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/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..086059b --- /dev/null +++ b/packages/jira/src/JiraWebhookProvider.ts @@ -0,0 +1,64 @@ +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 +} + +/** 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( + 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: `comment_created:${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..a8baced --- /dev/null +++ b/packages/jira/src/JiraWebhookSchemas.ts @@ -0,0 +1,43 @@ +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, +}) + +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 wiki-markup 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..0842948 --- /dev/null +++ b/packages/jira/src/index.ts @@ -0,0 +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/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/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" + } + } +} 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..a1e9db8 --- /dev/null +++ b/packages/jira/test/jira-api-live.vi.test.ts @@ -0,0 +1,393 @@ +import { describe, it } from '@effect/vitest' +import { Config, ConfigProvider, Effect, Layer, Queue } from 'effect' +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from 'effect/http' + +import { JiraApi } from '../src/JiraApi' +import { JiraApiLive, makeJiraApiLive, markdownToAdf } 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_CLOUD_ID: cloudId, + JIRA_API_TOKEN: testCredential, +} + +const makeLayer = (httpClient: HttpClient.HttpClient, overrides: Readonly> = {}) => + JiraApiLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, httpClient), + ConfigProvider.layer(ConfigProvider.fromUnknown({ ...config, ...overrides })), + ), + ), + ) + +const okClient = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ accountId: '5b10' }))), +) + +const bearerAuth = `Bearer ${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(`${gateway}/issue/ABC-1/comment`) + expect(call.auth).toBe(bearerAuth) + 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('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) => + 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) + }), + ) + + it.effect('sends every operation to the gateway with the Bearer token', ({ expect }) => + Effect.gen(function* () { + 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, { + 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 layer = makeJiraApiLive({ gatewayBaseUrl: 'http://127.0.0.1:9999/' }).pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, httpClient), + ConfigProvider.layer(ConfigProvider.fromUnknown(config)), + ), + ), + ) + 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('rejects a JIRA_CLOUD_ID that is not a UUID', ({ expect }) => + Effect.gen(function* () { + 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_CLOUD_ID: bad })), + Effect.flip, + ) + expect(error).toBeInstanceOf(Config.ConfigError) + expect(String(error)).toContain('JIRA_CLOUD_ID') + } + }), + ) + + 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') + }), + ) +}) 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..5c7d416 --- /dev/null +++ b/packages/jira/test/jira-bot.vi.test.ts @@ -0,0 +1,150 @@ +import { describe, it } from '@effect/vitest' +import { + DeliveryAdmission, + MailboxSubscriptions, + ProviderEventHandled, + QueueDeliveryMode, +} from '@humanlayer/channels-delivery' +import { Config, ConfigProvider, Effect, Layer, Redacted, Ref, Schema } from 'effect' + +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({})) +const mockJiraApi = Layer.mock(JiraApi, {}) + +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'), + 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) + 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'), + jiraApi: mockJiraApi, + 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'), + jiraApi: mockJiraApi, + 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('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) + }), + ) + + 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'), + jiraApi: mockJiraApi, + handlers: {}, + }) + const events = yield* provider + .eventProcessor({ namespace: 'jira-bot-test' }) + .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-comment.vi.test.ts b/packages/jira/test/jira-comment.vi.test.ts new file mode 100644 index 0000000..b7c00dc --- /dev/null +++ b/packages/jira/test/jira-comment.vi.test.ts @@ -0,0 +1,111 @@ +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 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-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 new file mode 100644 index 0000000..3906192 --- /dev/null +++ b/packages/jira/test/jira-delivery-output.vi.test.ts @@ -0,0 +1,197 @@ +/** 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, + PreparedDeliveryInvocation, + 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 = PreparedDeliveryInvocation.make({ + callback: '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' }).pipe(Effect.provide(api)) + const result = yield* processor.process(attempt(operation)).pipe(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 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( + 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' }).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.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 new file mode 100644 index 0000000..e79de38 --- /dev/null +++ b/packages/jira/test/jira-event-processing.vi.test.ts @@ -0,0 +1,333 @@ +import { describe, it } from '@effect/vitest' +import { + DeliveryAdmission, + deliveryMailboxKey, + PreparedDeliveryInvocation, + ProviderDeliveryExecution, + ProviderEventExecutionFailed, + ProviderEventHandled, + ProviderEventIgnored, + ProviderEventInvalid, +} from '@humanlayer/channels-delivery' +import { Effect, Logger, 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({ + callback: 'onMentioned', + presentationVersion: 1, + destination: { + _tag: 'JiraIssue', + issue: { id: '10042', key: 'ABC-7', mailboxKey }, + replyTo: { accountId: '557058a1b2c3d4e5f6a7b8c9', displayName: 'Alex Example' }, + }, + supportedOperations: jiraSupportedOperations, + reactionTargets: [], + }), + ]) + }), + ) + + 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) + 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(result).toEqual(ProviderEventIgnored.make({ reason: 'no_relevant_event' })) + 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('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')]) + 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, ({ callback }) => callback)).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('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') + 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() + }), + ) +}) 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..4281663 --- /dev/null +++ b/packages/jira/test/jira-webhook-routing.vi.test.ts @@ -0,0 +1,160 @@ +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 { Context, Effect, Match, Redacted } from 'effect' +import { Headers, HttpRouter } 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 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('ignores the unsigned X-Atlassian-Webhook-Identifier header for the event id', ({ expect }) => + Effect.gen(function* () { + 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) + }), + ) + + 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).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 new file mode 100644 index 0000000..6d12170 --- /dev/null +++ b/packages/jira/test/jira-webhook-signature.vi.test.ts @@ -0,0 +1,57 @@ +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) + }), + ) + + 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' }), + ) + }), + ) +}) 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.-]+)?$/