Skip to content
18 changes: 18 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions packages/jira/ASSUMPTIONS.md
Original file line number Diff line number Diff line change
@@ -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=<hex>`; 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:<id>]` (assumed), counts as a mention of that account, and renders as `@<id>`; 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": "<accountId>", "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:<comment.id>`, 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/<cloudId>/rest/api/3/...`, with `Authorization: Bearer <token>`. 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://<site>.atlassian.net/_edge/tenant_info`.
- VERIFIED 2026-10-19 by a real curl against a real site: `GET https://api.atlassian.com/ex/jira/<cloudId>/rest/api/3/myself` with `Authorization: Bearer <service-account token>` returned 200 with `accountId` in the form `<digits>:<uuid>` (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": <ADF doc> }`, 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": "<accountId>", "text": "@<displayName>" } }` 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.
17 changes: 17 additions & 0 deletions packages/jira/README.md
Original file line number Diff line number Diff line change
@@ -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://<site>.atlassian.net/_edge/tenant_info`
- `JIRA_API_TOKEN`: the API token of the bot, an Atlassian service account; sent as `Authorization: Bearer <token>` to `https://api.atlassian.com/ex/jira/<cloudId>/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 `<base>/integrations/jira/webhook`.
30 changes: 30 additions & 0 deletions packages/jira/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}
49 changes: 49 additions & 0 deletions packages/jira/src/JiraApi.ts
Original file line number Diff line number Diff line change
@@ -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>()('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<JiraIssueInfo, JiraApiError>
/** 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<JiraApi, JiraApiOperations>()('@humanlayer/channels-jira/JiraApi') {}
156 changes: 156 additions & 0 deletions packages/jira/src/JiraApiLive.ts
Original file line number Diff line number Diff line change
@@ -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<AdfInline> }

/**
* 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<AdfInline>): 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 <token>` to `<gateway>/ex/jira/<cloudId>/rest/api/3/...`.
*/
export const makeJiraApiLive = (
options: MakeJiraApiLiveOptions = {},
): Layer.Layer<JiraApi, Config.ConfigError, HttpClient.HttpClient> =>
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 = <A, I>(
operation: JiraApiOperation,
schema: Schema.Codec<A, I>,
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<JiraApi, Config.ConfigError, HttpClient.HttpClient> = makeJiraApiLive()
Loading