diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fec3c18a..794c7a8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ permissions: contents: read env: - EMULATOR_PACKAGES: adapter-next adapter-nuxt apple aws clerk core github google linear microsoft mongoatlas okta resend slack stripe twilio vercel + EMULATOR_PACKAGES: adapter-next adapter-nuxt apple aws clerk core github google jira linear microsoft mongoatlas okta resend slack stripe twilio vercel jobs: check-release: diff --git a/README.md b/README.md index 6e459daa..b1faadba 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ All services listen on IPv4 loopback (`127.0.0.1`) by default. No config file ne - **Clerk** on `http://localhost:4011` - **Linear** on `http://localhost:4012` - **Twilio** on `http://localhost:4013` +- **Jira** on `http://localhost:4014` Stripe webhooks configured with a secret include a `Stripe-Signature` header signed over the timestamp and raw request body. @@ -251,7 +252,7 @@ afterAll(() => Promise.all([github.close(), vercel.close()])) | Option | Default | Description | |--------|---------|-------------| -| `service` | *(required)* | Service name: `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, `'okta'`, `'aws'`, `'resend'`, `'stripe'`, `'mongoatlas'`, `'clerk'`, `'linear'`, or `'twilio'` | +| `service` | *(required)* | Service name: `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, `'okta'`, `'aws'`, `'resend'`, `'stripe'`, `'mongoatlas'`, `'clerk'`, `'linear'`, `'twilio'`, or `'jira'` | | `port` | `4000` | Port for the HTTP server | | `hostname` | `127.0.0.1` | Listening address for built-in and custom emulators | | `seed` | none | Inline seed data (same shape as YAML config) | @@ -640,6 +641,43 @@ twilio: conversations: services: - friendly_name: Local Conversations + +jira: + site: + name: acme + users: + - email: admin@example.com + display_name: Admin User + admin: true + api_token: jira_test_token + - email: dev@example.com + display_name: Developer + api_token: jira_dev_token + projects: + - key: ENG + name: Engineering + lead: admin@example.com + statuses: [To Do, In Progress, In Review, Done] + components: [API, Web] + versions: ["1.0.0"] + sprints: + - project: ENG + name: ENG Sprint 1 + state: active + issues: + - project: ENG + summary: Fix local checkout test + type: Bug + status: To Do + priority: High + assignee: dev@example.com + labels: [checkout] + sprint: ENG Sprint 1 + webhooks: + - name: Local issue events + url: http://localhost:3000/api/webhooks/jira + events: [jira:issue_created, jira:issue_updated, comment_created] + jql: project = ENG ``` GitHub organization `members` are optional. Each entry references a seeded user by `login`; `role` defaults to `member`, while `admin` creates an organization administrator. Unknown users are ignored. Seeded memberships use the synthetic `members` team and grant private organization repository access. @@ -735,6 +773,19 @@ linear: actor: user ``` +### Jira (Atlassian) OAuth 2.0 Apps + +```yaml +jira: + oauth_apps: + - client_id: "jira_example_client_id" + client_secret: "example_client_secret" + name: "My Jira App" + redirect_uris: + - "http://localhost:3000/api/auth/callback/atlassian" + scopes: [read:jira-work, write:jira-work, read:jira-user, manage:jira-webhook, offline_access] +``` + ### Apple OAuth Clients ```yaml @@ -1286,6 +1337,79 @@ To test inbound SMS webhooks, configure a seeded phone number `sms_url`, then ca Current Twilio limits: no carrier delivery, A2P 10DLC, toll-free verification, real phone number purchasing, exact rate limits, Studio, Flex, TaskRouter, Video, Sync, Segment, SendGrid, Conversations SDK websocket behavior, or complete TwiML interpreter. +## Jira API + +Stateful Jira Cloud REST API emulation (platform REST API v3 and v2, plus the Jira Software Agile API) with seeded users, API tokens, projects, workflows, issue types, priorities, resolutions, custom fields, components, versions, boards, sprints, issues, comments, worklogs, issue links, webhooks, and OAuth 2.0 (3LO) apps. Writes record changelog history and dispatch Jira-shaped webhooks. + +Default local credentials (Basic auth `email:api_token`, the same scheme Jira Cloud API tokens use): + +```text +JIRA_BASE_URL=http://localhost:4014 +JIRA_EMAIL=admin@jira.local +JIRA_API_TOKEN=jira_test_token +``` + +The default seed also creates `dev@jira.local` / `jira_dev_token`, project `EMU` with board `1`, active sprint `1`, and issue `EMU-1`. Project and webhook changes require an admin; components and versions also accept the project lead. + +### Issues + +- `POST /rest/api/3/issue`, `POST /rest/api/3/issue/bulk` - create issues (v3 requires Atlassian Document Format descriptions; v2 accepts plain strings) +- `GET /rest/api/3/issue/{issueIdOrKey}` - supports `fields` (`*all`, `-comment`) and `expand` (`changelog`, `renderedFields`, `names`, `schema`, `transitions`) +- `PUT /rest/api/3/issue/{issueIdOrKey}` - edit with `fields` and `update` operations (`set`, `add`, `remove`, `issuelinks`, `comment`) +- `DELETE /rest/api/3/issue/{issueIdOrKey}` - `deleteSubtasks=true` is required for issues with subtasks +- `PUT /rest/api/3/issue/{issueIdOrKey}/assignee` +- `GET|POST /rest/api/3/issue/{issueIdOrKey}/transitions` - moving to a Done category status sets the resolution +- `GET /rest/api/3/issue/{issueIdOrKey}/changelog`, `GET /rest/api/3/issue/{issueIdOrKey}/editmeta` +- `GET /rest/api/3/issue/createmeta/{projectIdOrKey}/issuetypes[/{issueTypeId}]` +- Comments, watchers, worklogs (`timeSpent` like `1h 30m`), and issue links under `/rest/api/3/issue/{key}/...` and `/rest/api/3/issueLink` + +### Search (JQL) + +- `GET|POST /rest/api/3/search/jql` - enhanced search with `nextPageToken`; rejects unbounded queries like Jira Cloud +- `POST /rest/api/3/search/approximate-count` +- `GET|POST /rest/api/3/search` - legacy `startAt`/`total` search +- `GET /rest/api/3/issue/picker` + +The JQL engine supports `AND`, `OR`, `NOT`, parentheses, `=`, `!=`, `~`, `!~`, `>`, `>=`, `<`, `<=`, `IN`, `NOT IN`, `IS [NOT] EMPTY`, `ORDER BY`, relative dates (`-7d`), and functions such as `currentUser()`, `now()`, `startOfDay()`, `openSprints()`, `closedSprints()`, `linkedIssues()`, `subTaskIssueTypes()`, and `unreleasedVersions()`. Unknown fields, values, and functions return Jira's error messages. + +### Projects, Users, And Configuration + +- `GET /rest/api/3/myself`, `/user`, `/user/search`, `/users/search`, `/user/assignable/search`, `/user/bulk` +- `GET|POST /rest/api/3/project`, `GET /rest/api/3/project/search`, `GET|PUT|DELETE /rest/api/3/project/{projectIdOrKey}` +- `GET /rest/api/3/project/{key}/statuses`, `/components`, `/versions`; `POST /rest/api/3/component`, `POST /rest/api/3/version` +- `GET /rest/api/3/issuetype`, `/status`, `/statuscategory`, `/priority`, `/resolution`, `/field`, `/label`, `/issueLinkType`, `/mypermissions`, `/serverInfo` + +### Agile + +- `GET|POST /rest/agile/1.0/board`, `GET /rest/agile/1.0/board/{id}/issue`, `/backlog`, `/sprint`, `/epic`, `/configuration` +- `POST /rest/agile/1.0/sprint`, `GET|POST|PUT|DELETE /rest/agile/1.0/sprint/{id}` - start and close sprints; closing returns unfinished issues to the backlog +- `GET|POST /rest/agile/1.0/sprint/{id}/issue`, `POST /rest/agile/1.0/backlog/issue` +- The same routes are served under `/rest/software/1.0` + +### Webhooks + +- `GET|POST /rest/webhooks/1.0/webhook`, `GET|PUT|DELETE /rest/webhooks/1.0/webhook/{id}` - admin webhooks with JQL filters, `excludeBody`, and an optional `secret` that signs deliveries with `X-Hub-Signature: sha256=` +- `GET|POST|DELETE /rest/api/3/webhook`, `PUT /rest/api/3/webhook/refresh` - dynamic webhooks for OAuth 2.0 apps +- Events: `jira:issue_created`, `jira:issue_updated` (with `changelog` and `issue_event_type_name`), sent once per edit even when the edit also adds a comment, `jira:issue_deleted`, `comment_created`, `comment_updated`, `comment_deleted` + +### OAuth 2.0 (3LO) And The API Gateway + +- `GET /authorize` - Atlassian consent screen with a local user picker (`audience=api.atlassian.com`) +- `POST /oauth/token` - `authorization_code` and rotating `refresh_token` grants (refresh tokens require `offline_access`) +- `GET /oauth/token/accessible-resources`, `GET /me` +- `/ex/jira/{cloudId}/rest/...` - the `api.atlassian.com` gateway path for OAuth clients; the default cloud ID is `11111111-2222-4333-8444-555555555555` + +Point clients at the emulator by replacing `https://your-domain.atlassian.net` with the emulator URL, and `https://api.atlassian.com` / `https://auth.atlassian.com` with the same URL for OAuth apps. + +### Inspector + +- `GET /` - tabbed inspector for issues, projects, boards and sprints, users, webhooks and deliveries, and auth +- `GET /browse/{issueKey}` - issue detail page + +OAuth scope checks are relaxed by default. Set `jira.strict_scopes: true` to require granular scopes such as `read:jira-work` and `write:jira-work` for OAuth tokens. API tokens always carry the user's full permissions. + +Current Jira limits: no attachments, filters, dashboards, workflow and screen scheme administration, permission schemes, groups, service management, Confluence, rate limiting, or exact search relevance. Workflows allow any status to move to any other status, like team-managed projects. + ## Apple Sign In Sign in with Apple emulation with authorization code flow, PKCE support, RS256 ID tokens, and OIDC discovery. @@ -1651,6 +1775,7 @@ packages/ slack/ # Slack Web API, OAuth v2, incoming webhooks linear/ # Linear GraphQL API, OAuth, webhooks twilio/ # Twilio Messaging, Verify, Voice, webhooks + jira/ # Jira Cloud REST v2/v3, JQL, Agile, webhooks, OAuth 2.0 apple/ # Apple Sign In / OIDC microsoft/ # Microsoft Entra ID OAuth 2.0 / OIDC + Graph /me aws/ # AWS S3, SQS, IAM, STS @@ -1680,6 +1805,8 @@ Tokens are configured in the seed config and map to users. Pass them as `Authori **Twilio**: HTTP Basic auth accepts the seeded Account SID/Auth Token pair or API Key/API Secret pair. Product-host APIs are exposed under local prefixes such as `/messaging/v1` and `/verify/v2`; the 2010 API lives at `/2010-04-01`. +**Jira**: HTTP Basic auth with a seeded user's `email:api_token`, or `Authorization: Bearer ` with an API token or an OAuth 2.0 access token. Errors use Jira's `{ "errorMessages": [], "errors": {} }` shape. OAuth clients call the API through `/ex/jira/{cloudId}`. + **Apple**: OIDC authorization code flow with RS256 ID tokens. On first auth per user/client pair, a `user` JSON blob is included. **Microsoft**: OIDC authorization code flow with PKCE support. Also supports client credentials grants. Microsoft Graph `/v1.0/me` available. diff --git a/apps/web/app/api/docs-chat/route.ts b/apps/web/app/api/docs-chat/route.ts index 839dfd7a..8f37dd01 100644 --- a/apps/web/app/api/docs-chat/route.ts +++ b/apps/web/app/api/docs-chat/route.ts @@ -9,7 +9,7 @@ export const maxDuration = 60; const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; -const SYSTEM_PROMPT = `You are a helpful documentation assistant for emulate, a local drop-in replacement for Vercel, GitHub, Google, Slack, Apple, Microsoft, Okta, AWS, Resend, Stripe, MongoDB Atlas, Clerk, Linear, and Twilio APIs used in CI and no-network sandboxes. +const SYSTEM_PROMPT = `You are a helpful documentation assistant for emulate, a local drop-in replacement for Vercel, GitHub, Google, Slack, Apple, Microsoft, Okta, AWS, Resend, Stripe, MongoDB Atlas, Clerk, Linear, Twilio, and Jira APIs used in CI and no-network sandboxes. emulate provides fully stateful, production-fidelity API emulation, not mocks. The CLI is installed as the "emulate" npm package and run via "npx emulate". It also supports a programmatic API via createEmulator, a Next.js adapter (@emulators/adapter-next), and a Nuxt adapter (@emulators/adapter-nuxt) for embedding emulators in your app. diff --git a/apps/web/content/docs/architecture.mdx b/apps/web/content/docs/architecture.mdx index da6546a2..6e594c6d 100644 --- a/apps/web/content/docs/architecture.mdx +++ b/apps/web/content/docs/architecture.mdx @@ -15,6 +15,7 @@ packages/ slack/ # Slack Web API, OAuth v2, incoming webhooks linear/ # Linear GraphQL API, OAuth, webhooks twilio/ # Twilio Messaging, Verify, Voice, webhooks + jira/ # Jira Cloud REST v2/v3, JQL, Agile, webhooks, OAuth 2.0 apple/ # Apple Sign In / OIDC microsoft/ # Microsoft Entra ID OAuth 2.0 / OIDC + Graph /me aws/ # AWS S3, SQS, IAM, STS diff --git a/apps/web/content/docs/configuration.mdx b/apps/web/content/docs/configuration.mdx index 47fe8b34..df2af8bb 100644 --- a/apps/web/content/docs/configuration.mdx +++ b/apps/web/content/docs/configuration.mdx @@ -502,6 +502,57 @@ twilio: - friendly_name: Local Conversations ``` +## Jira Seed Config + +```yaml +jira: + site: + name: acme + users: + - email: admin@example.com + display_name: Admin User + admin: true + api_token: jira_test_token + - email: dev@example.com + display_name: Developer + api_token: jira_dev_token + projects: + - key: ENG + name: Engineering + lead: admin@example.com + statuses: [To Do, In Progress, In Review, Done] + components: [API, Web] + versions: ["1.0.0"] + sprints: + - project: ENG + name: ENG Sprint 1 + state: active + issues: + - project: ENG + summary: Fix local checkout test + type: Bug + status: To Do + priority: High + assignee: dev@example.com + labels: [checkout] + sprint: ENG Sprint 1 + webhooks: + - name: Local issue events + url: http://localhost:3000/api/webhooks/jira + events: [jira:issue_created, jira:issue_updated, comment_created] + jql: project = ENG + oauth_apps: + - client_id: jira_example_client_id + client_secret: example_client_secret + name: My Jira App + redirect_uris: + - http://localhost:3000/api/auth/callback/atlassian + scopes: [read:jira-work, write:jira-work, read:jira-user, manage:jira-webhook, offline_access] + strict_scopes: false +``` + +Users authenticate with Basic auth `email:api_token`. Issue `parent` and link references accept an issue key or a seeded summary. + ## Custom emulators Custom definitions created with `defineEmulator` from `emulate` work in the CLI, in-process tests, and both framework adapters. The same definition provides typed state, HTTP handlers, reset, versioned snapshots, and optional inspection/persistence. See [Custom emulators](/docs/custom-emulators) for the quickstart, API reference, and complete lifecycle semantics. diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx index 0ca0c7d3..94375905 100644 --- a/apps/web/content/docs/index.mdx +++ b/apps/web/content/docs/index.mdx @@ -2,7 +2,7 @@ title: "Getting Started" --- -Local drop-in replacement for Vercel, GitHub, Google, Slack, Apple, Microsoft, Okta, AWS, Resend, Stripe, MongoDB Atlas, Clerk, Linear, and Twilio APIs. Built for CI and no-network sandboxes. Fully stateful, production-fidelity API emulation. Not mocks. +Local drop-in replacement for Vercel, GitHub, Google, Slack, Apple, Microsoft, Okta, AWS, Resend, Stripe, MongoDB Atlas, Clerk, Linear, Twilio, and Jira APIs. Built for CI and no-network sandboxes. Fully stateful, production-fidelity API emulation. Not mocks. ## Quick Start @@ -26,6 +26,7 @@ All services listen on IPv4 loopback (`127.0.0.1`) by default. No config file ne - **Clerk** on `http://localhost:4011` - **Linear** on `http://localhost:4012` - **Twilio** on `http://localhost:4013` +- **Jira** on `http://localhost:4014` ## CLI diff --git a/apps/web/content/docs/jira.mdx b/apps/web/content/docs/jira.mdx new file mode 100644 index 00000000..aa6b259c --- /dev/null +++ b/apps/web/content/docs/jira.mdx @@ -0,0 +1,193 @@ +--- +title: "Jira API" +navTitle: "Jira" +--- + +Stateful Jira Cloud emulation covering the platform REST API (v3 and v2), the Jira Software Agile API, JQL search, webhooks, and Atlassian OAuth 2.0 (3LO). Writes record changelog history and dispatch Jira-shaped webhooks. + +## Start + +```bash +# Jira only +npx emulate --service jira +``` + +When Jira is the only enabled service, it starts on `http://localhost:4000`. When all services are started, it uses `http://localhost:4014`. + +## URL Mapping + +| Real Jira URL | Emulator URL | +|---------------|--------------| +| `https://your-domain.atlassian.net/rest/api/3/...` | `$JIRA_EMULATOR_URL/rest/api/3/...` | +| `https://your-domain.atlassian.net/rest/api/2/...` | `$JIRA_EMULATOR_URL/rest/api/2/...` | +| `https://your-domain.atlassian.net/rest/agile/1.0/...` | `$JIRA_EMULATOR_URL/rest/agile/1.0/...` | +| `https://api.atlassian.com/ex/jira/{cloudId}/rest/...` | `$JIRA_EMULATOR_URL/ex/jira/{cloudId}/rest/...` | +| `https://auth.atlassian.com/authorize` | `$JIRA_EMULATOR_URL/authorize` | +| `https://auth.atlassian.com/oauth/token` | `$JIRA_EMULATOR_URL/oauth/token` | +| `https://api.atlassian.com/oauth/token/accessible-resources` | `$JIRA_EMULATOR_URL/oauth/token/accessible-resources` | +| `https://api.atlassian.com/me` | `$JIRA_EMULATOR_URL/me` | + +## Auth + +Use HTTP Basic auth with a seeded user's email and API token, exactly like Jira Cloud API tokens: + +```bash +curl -u admin@jira.local:jira_test_token "$JIRA_EMULATOR_URL/rest/api/3/myself" +``` + +Default credentials: `admin@jira.local` / `jira_test_token` (admin) and `dev@jira.local` / `jira_dev_token`. API tokens also work as `Authorization: Bearer `. Wrong credentials return `401` with `{"errorMessages":["Client must be authenticated to access this resource."],"errors":{}}`. Creating, editing, or deleting projects and webhooks requires an admin; components and versions also accept the project lead. + +OAuth scope checks are relaxed by default. Set `jira.strict_scopes: true` to require granular scopes such as `read:jira-work` and `write:jira-work` for OAuth access tokens. + +## Default Seed + +- Project `EMU` (id `10000`) with workflow `To Do`, `In Progress`, `Done` and issue types `Epic`, `Story`, `Task`, `Bug`, `Subtask` +- Scrum board `1` with active sprint `1` (`EMU Sprint 1`) +- Issue `EMU-1` assigned to `dev@jira.local`, with one comment +- Custom fields `customfield_10015` (Start date), `customfield_10016` (Story point estimate), `customfield_10020` (Sprint) +- OAuth app `jira_example_client_id` / `example_client_secret` +- Cloud ID `11111111-2222-4333-8444-555555555555` + +## Seed Config + +```yaml +jira: + site: + name: acme + users: + - email: admin@example.com + display_name: Admin User + admin: true + api_token: jira_test_token + - email: dev@example.com + display_name: Developer + api_token: jira_dev_token + statuses: + - name: In Review + category: indeterminate + custom_fields: + - name: Team + type: option + options: [Red, Blue] + projects: + - key: ENG + name: Engineering + lead: admin@example.com + statuses: [To Do, In Progress, In Review, Done] + components: [API, Web] + versions: ["1.0.0"] + sprints: + - project: ENG + name: ENG Sprint 1 + state: active + issues: + - project: ENG + summary: Fix local checkout test + type: Bug + description: Reproduce and fix the checkout failure. + status: To Do + priority: High + assignee: dev@example.com + labels: [checkout] + sprint: ENG Sprint 1 + comments: + - body: Reproduced locally. + author: admin@example.com + links: + - type: Blocks + inward: Fix local checkout test + outward: ENG-2 + webhooks: + - name: Local issue events + url: http://localhost:3000/api/webhooks/jira + events: [jira:issue_created, jira:issue_updated, comment_created] + jql: project = ENG + secret: webhook_secret + oauth_apps: + - client_id: jira_example_client_id + client_secret: example_client_secret + name: My Jira App + redirect_uris: + - http://localhost:3000/api/auth/callback/atlassian + scopes: [read:jira-work, write:jira-work, read:jira-user, manage:jira-webhook, offline_access] + strict_scopes: false +``` + +Issue `parent` and link `inward`/`outward` accept an issue key or a seeded summary. As in Jira's API, the outward issue is the source of a link: `{ type: Blocks, inward: A, outward: B }` means B blocks A. Statuses listed on a project that do not exist yet are created, with the category inferred from the name. + +## REST Surface + +Issues (`/rest/api/3` and `/rest/api/2`): + +- `POST /issue`, `POST /issue/bulk`, `GET|PUT|DELETE /issue/{idOrKey}` +- `PUT /issue/{idOrKey}/assignee`, `GET|POST /issue/{idOrKey}/transitions`, `GET /issue/{idOrKey}/changelog` +- `GET /issue/createmeta/{project}/issuetypes[/{issueTypeId}]`, `GET /issue/{idOrKey}/editmeta` +- `GET|POST /issue/{idOrKey}/comment`, `GET|PUT|DELETE /issue/{idOrKey}/comment/{id}`, `POST /comment/list` +- `GET|POST|DELETE /issue/{idOrKey}/watchers` +- `GET|POST /issue/{idOrKey}/worklog`, `GET|PUT|DELETE /issue/{idOrKey}/worklog/{id}` +- `POST /issueLink`, `GET|DELETE /issueLink/{id}`, `GET /issueLinkType` + +Search: `GET|POST /search/jql` (enhanced, `nextPageToken`, rejects unbounded queries), `POST /search/approximate-count`, legacy `GET|POST /search`, `GET /issue/picker`. + +Projects and configuration: `/project`, `/project/search`, `/project/{key}`, `/project/{key}/statuses|components|versions`, `/component`, `/version`, `/issuetype`, `/status`, `/statuscategory`, `/priority`, `/resolution`, `/field`, `/label`, `/mypermissions`, `/serverInfo`. + +Users: `/myself`, `/user`, `/user/search`, `/users/search`, `/user/assignable/search`, `/user/bulk`. + +Agile (`/rest/agile/1.0`, also `/rest/software/1.0`): boards, board issues, backlog, sprints, epics, sprint lifecycle (`future` to `active` to `closed`), moving issues to sprints and the backlog. + +## Fidelity Notes + +- REST API v3 bodies (descriptions, comments, worklog comments) must be Atlassian Document Format. Plain strings return Jira's `400` error. REST API v2 accepts and returns plain strings. +- `GET /issue` supports `fields` (`*all`, `summary,status`, `-comment`) and `expand` (`changelog`, `renderedFields`, `names`, `schema`, `transitions`). +- `/search/jql` returns only issue IDs and keys unless `fields` are requested. +- Workflows allow any status to move to any other status, like team-managed projects. Transition IDs are `11`, `21`, `31`, and so on by workflow position. Moving to a Done category sets the resolution; moving out clears it. +- Setting `status` through edit fails, as in Jira. Use transitions. +- Closing a sprint keeps completed issues in it and returns unfinished issues to the backlog. +- In `POST /issueLink`, `outwardIssue` is the source ("from") issue and receives the optional comment. In the issue view, the source lists the other issue as `outwardIssue` and the target lists it as `inwardIssue`. +- Errors use `{ "errorMessages": [...], "errors": { "field": "message" } }`. + +## JQL + +Supports `AND`, `OR`, `NOT`, parentheses, `=`, `!=`, `~`, `!~`, `>`, `>=`, `<`, `<=`, `IN`, `NOT IN`, `IS [NOT] EMPTY`, and `ORDER BY`. Fields include `project`, `key`, `summary`, `description`, `comment`, `text`, `status`, `statusCategory`, `assignee`, `reporter`, `creator`, `watcher`, `priority`, `issuetype`, `labels`, `resolution`, `parent`, `created`, `updated`, `duedate`, `resolutiondate`, `sprint`, `component`, `fixVersion`, and custom fields by `cf[id]` or name. Functions: `currentUser()`, `now()`, `startOfDay()`, `endOfDay()`, `startOfWeek()`, `startOfMonth()`, `startOfYear()` (with offsets like `startOfDay(-1d)`), `openSprints()`, `closedSprints()`, `futureSprints()`, `standardIssueTypes()`, `subTaskIssueTypes()`, `releasedVersions()`, `unreleasedVersions()`, and `linkedIssues(key[, "link text"])`. Relative dates such as `-7d` and `-2w` work. + +## Webhooks + +- Admin webhooks: `GET|POST /rest/webhooks/1.0/webhook`, `GET|PUT|DELETE /rest/webhooks/1.0/webhook/{id}` with a JQL filter in `filters["issue-related-events-section"]`, `excludeBody`, and `secret`. Secrets sign deliveries with `X-Hub-Signature: sha256=`. +- Dynamic webhooks for OAuth apps: `GET|POST|DELETE /rest/api/3/webhook`, `PUT /rest/api/3/webhook/refresh`. Deliveries include `matchedWebhookIds`. +- Events: `jira:issue_created`, `jira:issue_updated` (with `changelog` and `issue_event_type_name` such as `issue_generic`, `issue_assigned`, `issue_commented`), sent once per edit even when the edit also adds a comment, `jira:issue_deleted`, `comment_created`, `comment_updated`, `comment_deleted`. + +## OAuth 2.0 (3LO) + +- `GET /authorize?audience=api.atlassian.com&client_id=...&scope=...&redirect_uri=...&state=...&response_type=code` shows a user picker +- `POST /oauth/token` supports `authorization_code` and rotating `refresh_token` grants (refresh tokens need `offline_access`), with JSON or form bodies +- `GET /oauth/token/accessible-resources` returns the site cloud ID; call the API at `/ex/jira/{cloudId}/rest/api/3/...` +- `GET /me` returns the Atlassian profile + +## jira.js + +```typescript +import { createCloudClient } from "jira.js"; + +const client = createCloudClient({ + host: process.env.JIRA_EMULATOR_URL!, + auth: { type: "basic", email: "admin@jira.local", apiToken: "jira_test_token" }, +}); + +const issue = await client.issues.createIssue({ + fields: { + project: { key: "EMU" }, + issuetype: { name: "Task" }, + summary: "Created locally", + }, +}); +``` + +jira.js always sends OAuth 2.0 requests (clients configured with a `cloudId`) to `https://api.atlassian.com/ex/jira/{cloudId}`, so point those at the emulator with a proxy or a `fetch` wrapper, or use Basic auth with `host` as above. Any HTTP client can call `/ex/jira/{cloudId}/rest/api/3/...` on the emulator directly. + +## Inspector + +Open `GET /` for issues, projects, boards and sprints, users, webhooks and deliveries, and auth (API tokens, OAuth apps, cloud IDs). `GET /browse/{issueKey}` shows an issue page. + +## Current Limits + +Attachments, filters, dashboards, workflow and screen scheme administration, permission schemes, groups, Jira Service Management, Confluence, rate limiting, and exact search relevance are not implemented. diff --git a/apps/web/content/docs/meta.json b/apps/web/content/docs/meta.json index b8cfd4b7..a575fabd 100644 --- a/apps/web/content/docs/meta.json +++ b/apps/web/content/docs/meta.json @@ -14,6 +14,7 @@ "slack", "linear", "twilio", + "jira", "apple", "microsoft", "aws", diff --git a/apps/web/content/docs/programmatic-api.mdx b/apps/web/content/docs/programmatic-api.mdx index 8c2ae6e6..1f809678 100644 --- a/apps/web/content/docs/programmatic-api.mdx +++ b/apps/web/content/docs/programmatic-api.mdx @@ -88,7 +88,7 @@ afterAll(() => Promise.all([github.close(), vercel.close()])) service (required) - Service name: 'vercel', 'github', 'google', 'slack', 'apple', 'microsoft', 'okta', 'aws', 'resend', 'stripe', 'mongoatlas', 'clerk', 'linear', or 'twilio' + Service name: 'vercel', 'github', 'google', 'slack', 'apple', 'microsoft', 'okta', 'aws', 'resend', 'stripe', 'mongoatlas', 'clerk', 'linear', 'twilio', or 'jira' port @@ -149,7 +149,7 @@ Use `hostname: '0.0.0.0'` to allow connections from containers or other machines Each emulator is published as its own `@emulators/*` package. Install only the ones you need: ```bash -npm install @emulators/github @emulators/google @emulators/linear @emulators/twilio +npm install @emulators/github @emulators/google @emulators/linear @emulators/twilio @emulators/jira ``` ### Available packages @@ -168,6 +168,7 @@ npm install @emulators/github @emulators/google @emulators/linear @emulators/twi @emulators/slackSlack Web API, OAuth v2, webhooks @emulators/linearLinear GraphQL API, OAuth, webhooks @emulators/twilioTwilio Messaging, Verify, Voice, webhooks + @emulators/jiraJira Cloud REST API v2/v3, JQL, Agile, webhooks, OAuth 2.0 @emulators/appleApple Sign In / OIDC @emulators/microsoftMicrosoft Entra ID, Graph API @emulators/awsAWS S3, SQS, IAM, STS diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts index 69df36ac..a3f7c086 100644 --- a/apps/web/lib/docs-navigation.ts +++ b/apps/web/lib/docs-navigation.ts @@ -29,6 +29,7 @@ export const docsSections: NavSection[] = [ { name: "Slack API", label: "Slack", href: "/docs/slack" }, { name: "Linear API", label: "Linear", href: "/docs/linear" }, { name: "Twilio API", label: "Twilio", href: "/docs/twilio" }, + { name: "Jira API", label: "Jira", href: "/docs/jira" }, { name: "Apple Sign In", label: "Apple", href: "/docs/apple" }, { name: "Microsoft Entra ID", href: "/docs/microsoft" }, { name: "AWS", href: "/docs/aws" }, diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts index 7f275835..d5694cb5 100644 --- a/apps/web/lib/page-titles.ts +++ b/apps/web/lib/page-titles.ts @@ -11,6 +11,7 @@ export const PAGE_TITLES: Record = { slack: "Slack API", linear: "Linear API", twilio: "Twilio API", + jira: "Jira API", apple: "Apple Sign In", microsoft: "Microsoft Entra ID", aws: "AWS", diff --git a/apps/web/tests/fixtures/docs-baseline.json b/apps/web/tests/fixtures/docs-baseline.json index 77a42333..88378024 100644 --- a/apps/web/tests/fixtures/docs-baseline.json +++ b/apps/web/tests/fixtures/docs-baseline.json @@ -82,6 +82,7 @@ "Clerk Seed Config", "Linear Seed Config", "Twilio Seed Config", + "Jira Seed Config", "Custom emulators" ] }, @@ -234,6 +235,27 @@ "Current Limits" ] }, + "/docs/jira": { + "title": "Jira API | emulate", + "description": "Local drop-in replacement services for CI and no-network sandboxes. Fully stateful, production-fidelity API emulation.", + "ogImage": "/og/jira", + "ogTitle": "Jira API | emulate", + "headings": [ + "Start", + "URL Mapping", + "Auth", + "Default Seed", + "Seed Config", + "REST Surface", + "Fidelity Notes", + "JQL", + "Webhooks", + "OAuth 2.0 (3LO)", + "jira.js", + "Inspector", + "Current Limits" + ] + }, "/docs/apple": { "title": "Apple Sign In | emulate", "description": "Local drop-in replacement services for CI and no-network sandboxes. Fully stateful, production-fidelity API emulation.", diff --git a/emulate.config.example.yaml b/emulate.config.example.yaml index 5dbe32e0..7b6baada 100644 --- a/emulate.config.example.yaml +++ b/emulate.config.example.yaml @@ -379,3 +379,49 @@ twilio: conversations: services: - friendly_name: Local Conversations + +jira: + site: + name: acme + users: + - email: admin@example.com + display_name: Admin User + admin: true + api_token: jira_test_token + - email: dev@example.com + display_name: Developer + api_token: jira_dev_token + projects: + - key: ENG + name: Engineering + lead: admin@example.com + statuses: [To Do, In Progress, In Review, Done] + components: [API, Web] + versions: ["1.0.0"] + sprints: + - project: ENG + name: ENG Sprint 1 + state: active + issues: + - project: ENG + summary: Fix local checkout test + type: Bug + description: Reproduce and fix the checkout failure. + status: To Do + priority: High + assignee: dev@example.com + labels: [checkout] + sprint: ENG Sprint 1 + webhooks: + - name: Local issue events + url: http://localhost:3000/api/webhooks/jira + events: [jira:issue_created, jira:issue_updated, comment_created] + jql: project = ENG + oauth_apps: + - client_id: jira_example_client_id + client_secret: example_client_secret + name: My Jira App + redirect_uris: + - http://localhost:3000/api/auth/callback/atlassian + scopes: [read:jira-work, write:jira-work, read:jira-user, manage:jira-webhook, offline_access] + strict_scopes: false diff --git a/packages/@emulators/jira/README.md b/packages/@emulators/jira/README.md new file mode 100644 index 00000000..a97fceb7 --- /dev/null +++ b/packages/@emulators/jira/README.md @@ -0,0 +1,37 @@ +# @emulators/jira + +Stateful Jira Cloud REST API emulator for local development and CI. + +Part of [emulate](https://github.com/vercel-labs/emulate), local drop-in replacement services for CI and no-network sandboxes. + +## Install + +```sh +npm install @emulators/jira +``` + +Most users should run it through the main CLI: + +```sh +npx emulate --service jira +``` + +## Supported Surface + +- Jira platform REST API v3 and v2 under `/rest/api/3` and `/rest/api/2`. v3 bodies use Atlassian Document Format and v2 bodies use plain strings. +- Issues: create, bulk create, read with `fields` and `expand`, edit with `fields` and `update` operations, delete, assign, transitions, changelog, create and edit metadata. +- Comments, watchers, worklogs, and issue links. +- JQL search through `/search/jql` (with `nextPageToken`), `/search/approximate-count`, the legacy `/search`, and the issue picker. The JQL engine supports boolean logic, list and empty operators, text search, date math, `ORDER BY`, and common functions such as `currentUser()`, `openSprints()`, and `linkedIssues()`. +- Projects, components, versions, users, issue types, statuses, priorities, resolutions, fields, labels, and permissions. +- Jira Software Agile API under `/rest/agile/1.0` and `/rest/software/1.0`: boards, backlog, sprints and the sprint lifecycle, epics. +- Admin webhooks (`/rest/webhooks/1.0/webhook`) with JQL filters and `X-Hub-Signature` signing, plus dynamic webhooks for OAuth apps. +- Atlassian OAuth 2.0 (3LO): consent screen, authorization code and rotating refresh tokens, accessible resources, `/me`, and the `/ex/jira/{cloudId}` gateway path. +- Basic auth with `email:api_token`, and bearer API or OAuth tokens. +- Jira shaped errors: `{ "errorMessages": [], "errors": {} }`. +- Local inspector at `/` and issue pages at `/browse/{issueKey}`. + +Default credentials: `admin@jira.local` / `jira_test_token`. + +Conformance is checked against [jira.js](https://github.com/MrRefactoring/jira.js) with response schema validation enabled. + +This is not a complete Jira clone. Attachments, filters, dashboards, workflow and permission scheme administration, groups, and Jira Service Management are not implemented. diff --git a/packages/@emulators/jira/package.json b/packages/@emulators/jira/package.json new file mode 100644 index 00000000..876e9a8f --- /dev/null +++ b/packages/@emulators/jira/package.json @@ -0,0 +1,49 @@ +{ + "name": "@emulators/jira", + "version": "0.12.0", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "homepage": "https://emulate.dev", + "repository": { + "type": "git", + "url": "https://github.com/vercel-labs/emulate.git", + "directory": "packages/@emulators/jira" + }, + "bugs": { + "url": "https://github.com/vercel-labs/emulate/issues" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "LICENSE", + "THIRD_PARTY_NOTICES.md" + ], + "scripts": { + "build": "tsup --clean", + "dev": "tsup --watch", + "test": "vitest run", + "clean": "rm -rf dist .turbo", + "type-check": "tsc --noEmit", + "lint": "eslint src", + "prepack": "node ../../../scripts/prepare-package.mjs" + }, + "dependencies": { + "@emulators/core": "workspace:*" + }, + "devDependencies": { + "jira.js": "^6.2.0", + "tsup": "^8", + "typescript": "^5.7", + "vitest": "^4.1.0" + } +} diff --git a/packages/@emulators/jira/src/__tests__/agile.test.ts b/packages/@emulators/jira/src/__tests__/agile.test.ts new file mode 100644 index 00000000..f2c51353 --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/agile.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { api, createJiraTestApp, jiraTestBaseUrl, type JiraTestApp } from "./helpers.js"; + +const A = "/rest/agile/1.0"; + +describe("Jira Agile API", () => { + let t: JiraTestApp; + + beforeEach(async () => { + t = createJiraTestApp(); + for (const summary of ["Backlog item", "Another item"]) { + await api(t.app, "/rest/api/3/issue", { + method: "POST", + body: { fields: { project: { key: "EMU" }, issuetype: { name: "Story" }, summary } }, + }); + } + }); + + it("lists and reads boards", async () => { + const list = await api(t.app, `${A}/board?projectKeyOrId=EMU`); + expect(list.status).toBe(200); + expect(list.json.total).toBe(1); + const board = list.json.values[0]; + expect(board).toMatchObject({ id: 1, name: "EMU board", type: "scrum" }); + expect(board.self).toBe(`${jiraTestBaseUrl}${A}/board/1`); + expect(board.location.projectKey).toBe("EMU"); + + const one = await api(t.app, `${A}/board/1`); + expect(one.json.name).toBe("EMU board"); + expect((await api(t.app, `${A}/board/99`)).status).toBe(404); + + const config = await api(t.app, `${A}/board/1/configuration`); + expect(config.json.columnConfig.columns.map((c: any) => c.name)).toEqual(["To Do", "In Progress", "Done"]); + }); + + it("creates and deletes boards", async () => { + const created = await api(t.app, `${A}/board`, { + method: "POST", + body: { name: "Kanban", type: "kanban", location: { type: "project", projectKeyOrId: "EMU" } }, + }); + expect(created.status).toBe(201); + expect(created.json.type).toBe("kanban"); + const deleted = await api(t.app, `${A}/board/${created.json.id}`, { method: "DELETE" }); + expect(deleted.status).toBe(204); + }); + + it("lists board issues, backlog, and sprints", async () => { + const issues = await api(t.app, `${A}/board/1/issue?fields=summary`); + expect(issues.status).toBe(200); + expect(issues.json.total).toBe(3); + + const backlog = await api(t.app, `${A}/board/1/backlog`); + expect(backlog.json.issues.map((i: any) => i.key).sort()).toEqual(["EMU-2", "EMU-3"]); + + const sprints = await api(t.app, `${A}/board/1/sprint?state=active`); + expect(sprints.json.values).toHaveLength(1); + expect(sprints.json.values[0]).toMatchObject({ id: 1, name: "EMU Sprint 1", state: "active", originBoardId: 1 }); + + const filtered = await api(t.app, `${A}/board/1/issue?jql=summary~another`); + expect(filtered.json.issues.map((i: any) => i.key)).toEqual(["EMU-3"]); + }); + + it("creates a sprint, moves issues into it, and runs its lifecycle", async () => { + const created = await api(t.app, `${A}/sprint`, { + method: "POST", + body: { name: "Sprint 2", originBoardId: 1, goal: "Polish" }, + }); + expect(created.status).toBe(201); + expect(created.json.state).toBe("future"); + const id = created.json.id; + + const move = await api(t.app, `${A}/sprint/${id}/issue`, { method: "POST", body: { issues: ["EMU-2", "EMU-3"] } }); + expect(move.status).toBe(204); + const sprintIssues = await api(t.app, `${A}/sprint/${id}/issue`); + expect(sprintIssues.json.issues.map((i: any) => i.key).sort()).toEqual(["EMU-2", "EMU-3"]); + + const issue = (await api(t.app, "/rest/api/3/issue/EMU-2")).json; + expect(issue.fields.customfield_10020[0].name).toBe("Sprint 2"); + + const longName = await api(t.app, `${A}/sprint/${id}`, { method: "POST", body: { name: "x".repeat(31) } }); + expect(longName.status).toBe(400); + const badDate = await api(t.app, `${A}/sprint/${id}`, { method: "POST", body: { startDate: "soon" } }); + expect(badDate.status).toBe(400); + + const startWithoutDates = await api(t.app, `${A}/sprint/${id}`, { method: "POST", body: { state: "active" } }); + expect(startWithoutDates.status).toBe(400); + + const started = await api(t.app, `${A}/sprint/${id}`, { + method: "POST", + body: { state: "active", startDate: "2026-10-01T09:00:00.000Z", endDate: "2026-10-14T17:00:00.000Z" }, + }); + expect(started.status).toBe(200); + expect(started.json.state).toBe("active"); + + const done = (await api(t.app, "/rest/api/3/issue/EMU-2/transitions")).json.transitions.find( + (tr: any) => tr.name === "Done", + ); + await api(t.app, "/rest/api/3/issue/EMU-2/transitions", { method: "POST", body: { transition: { id: done.id } } }); + + const closed = await api(t.app, `${A}/sprint/${id}`, { method: "POST", body: { state: "closed" } }); + expect(closed.json.state).toBe("closed"); + expect(closed.json.completeDate).toBeTruthy(); + + // Completed issues stay in the closed sprint; open issues go back to the backlog. + const backlog = await api(t.app, `${A}/board/1/backlog`); + expect(backlog.json.issues.map((i: any) => i.key)).toEqual(["EMU-3"]); + const changelog = await api(t.app, "/rest/api/3/issue/EMU-3/changelog"); + expect(changelog.json.values.at(-1).items[0]).toMatchObject({ field: "Sprint", fromString: "Sprint 2", to: null }); + const search = await api(t.app, "/rest/api/3/search/jql", { + method: "POST", + body: { jql: `sprint = ${id} ORDER BY key`, fields: ["summary"] }, + }); + expect(search.json.issues.map((i: any) => i.key)).toEqual(["EMU-2", "EMU-3"]); + const open = await api(t.app, "/rest/api/3/search/jql", { + method: "POST", + body: { jql: "sprint in openSprints() ORDER BY key" }, + }); + expect(open.json.issues.map((i: any) => i.key)).toEqual(["EMU-1"]); + + const reopen = await api(t.app, `${A}/sprint/${id}`, { method: "POST", body: { state: "future" } }); + expect(reopen.status).toBe(400); + }); + + it("moves issues back to the backlog", async () => { + const res = await api(t.app, `${A}/backlog/issue`, { method: "POST", body: { issues: ["EMU-1"] } }); + expect(res.status).toBe(204); + const backlog = await api(t.app, `${A}/board/1/backlog`); + expect(backlog.json.issues.map((i: any) => i.key)).toContain("EMU-1"); + }); + + it("updates and deletes sprints", async () => { + const created = await api(t.app, `${A}/sprint`, { method: "POST", body: { name: "Tmp", originBoardId: 1 } }); + const put = await api(t.app, `${A}/sprint/${created.json.id}`, { + method: "PUT", + body: { name: "Renamed", state: "future", goal: "New goal" }, + }); + expect(put.json.name).toBe("Renamed"); + expect(put.json.goal).toBe("New goal"); + expect((await api(t.app, `${A}/sprint/${created.json.id}`)).json.name).toBe("Renamed"); + expect((await api(t.app, `${A}/sprint/${created.json.id}`, { method: "DELETE" })).status).toBe(204); + expect((await api(t.app, `${A}/sprint/${created.json.id}`)).status).toBe(404); + }); + + it("reads issues and epic children through the agile API", async () => { + const epic = await api(t.app, "/rest/api/3/issue", { + method: "POST", + body: { fields: { project: { key: "EMU" }, issuetype: { name: "Epic" }, summary: "Big epic" } }, + }); + await api(t.app, "/rest/api/3/issue/EMU-2", { + method: "PUT", + body: { fields: { parent: { key: epic.json.key } } }, + }); + + const issue = await api(t.app, `${A}/issue/EMU-2`); + expect(issue.json.key).toBe("EMU-2"); + const children = await api(t.app, `${A}/epic/${epic.json.key}/issue`); + expect(children.json.issues.map((i: any) => i.key)).toEqual(["EMU-2"]); + const board = await api(t.app, `${A}/board/1/epic`); + expect(board.json.values.map((e: any) => e.key)).toEqual([epic.json.key]); + }); + + it("serves the same endpoints under the /rest/software/1.0 alias", async () => { + const sprintIssues = await api(t.app, "/rest/software/1.0/sprint/1/issue"); + expect(sprintIssues.status).toBe(200); + expect(sprintIssues.json.issues.map((i: any) => i.key)).toEqual(["EMU-1"]); + const backlog = await api(t.app, "/rest/software/1.0/board/1/backlog"); + expect(backlog.json.total).toBe(2); + const move = await api(t.app, "/rest/agile/1.0/backlog/1/issue", { method: "POST", body: { issues: ["EMU-1"] } }); + expect(move.status).toBe(204); + }); +}); diff --git a/packages/@emulators/jira/src/__tests__/helpers.ts b/packages/@emulators/jira/src/__tests__/helpers.ts new file mode 100644 index 00000000..7643f297 --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/helpers.ts @@ -0,0 +1,115 @@ +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + Hono, + Store, + WebhookDispatcher, + authMiddleware, + cors, + createApiErrorHandler, + createErrorHandler, + serve, + type AppEnv, +} from "@emulators/core"; +import { jiraPlugin, DEFAULT_ADMIN_EMAIL, DEFAULT_API_TOKEN } from "../index.js"; + +export const jiraTestBaseUrl = "http://localhost:4302"; + +export interface JiraTestApp { + app: Hono; + store: Store; + webhooks: WebhookDispatcher; +} + +export function createJiraTestApp(): JiraTestApp { + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const app = new Hono(); + jiraPlugin.register(app, store, webhooks, jiraTestBaseUrl); + jiraPlugin.seed?.(store, jiraTestBaseUrl); + return { app, store, webhooks }; +} + +export interface JiraTestEmulator extends JiraTestApp { + url: string; + close: () => Promise; +} + +/** + * Serves the emulator on a random local port behind the same core middleware as `createServer`, + * so SDK tests exercise the full HTTP stack. + */ +export async function startJiraTestEmulator(): Promise { + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const app = new Hono(); + app.onError(createApiErrorHandler()); + app.use("*", cors()); + app.use("*", createErrorHandler()); + app.use("*", authMiddleware(new Map())); + + const server = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" }) as unknown as Server; + await new Promise((resolve, reject) => { + server.once("listening", () => resolve()); + server.once("error", reject); + }); + const { port } = server.address() as AddressInfo; + const url = `http://127.0.0.1:${port}`; + + jiraPlugin.register(app, store, webhooks, url); + jiraPlugin.seed?.(store, url); + + return { + app, + store, + webhooks, + url, + close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), + }; +} + +export function basicAuth(email = DEFAULT_ADMIN_EMAIL, token = DEFAULT_API_TOKEN): string { + return `Basic ${Buffer.from(`${email}:${token}`).toString("base64")}`; +} + +export interface RequestOptions { + method?: string; + body?: unknown; + auth?: string | null; + headers?: Record; +} + +export interface ApiResponse { + status: number; + headers: Headers; + text: string; + json: any; +} + +export async function api(app: Hono, path: string, opts: RequestOptions = {}): Promise { + const headers: Record = { Accept: "application/json", ...opts.headers }; + const auth = opts.auth === undefined ? basicAuth() : opts.auth; + if (auth) headers.Authorization = auth; + let body: string | undefined; + if (opts.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body); + } + const res = await app.request(`${jiraTestBaseUrl}${path}`, { method: opts.method ?? "GET", headers, body }); + const text = await res.text(); + let json: any = undefined; + try { + json = text ? JSON.parse(text) : undefined; + } catch { + json = undefined; + } + return { status: res.status, headers: res.headers, text, json }; +} + +export function adf(text: string) { + return { + type: "doc", + version: 1, + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }; +} diff --git a/packages/@emulators/jira/src/__tests__/issues.test.ts b/packages/@emulators/jira/src/__tests__/issues.test.ts new file mode 100644 index 00000000..b17fb821 --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/issues.test.ts @@ -0,0 +1,394 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_DEV_EMAIL, getJiraStore } from "../index.js"; +import { adf, api, createJiraTestApp, jiraTestBaseUrl, type JiraTestApp } from "./helpers.js"; + +async function createIssue(t: JiraTestApp, fields: Record, version = "3") { + return api(t.app, `/rest/api/${version}/issue`, { + method: "POST", + body: { fields: { project: { key: "EMU" }, issuetype: { name: "Task" }, ...fields } }, + }); +} + +describe("Jira issues", () => { + let t: JiraTestApp; + let devId: string; + + beforeEach(() => { + t = createJiraTestApp(); + devId = getJiraStore(t.store).users.findOneBy("email", DEFAULT_DEV_EMAIL)!.account_id; + }); + + describe("reading", () => { + it("finds issues and projects by key in any case", async () => { + expect((await api(t.app, "/rest/api/3/issue/emu-1")).json.key).toBe("EMU-1"); + expect((await api(t.app, "/rest/api/3/project/emu")).json.key).toBe("EMU"); + }); + + it("returns only the requested fields", async () => { + const res = await api(t.app, "/rest/api/3/issue/EMU-1?fields=summary,status"); + expect(Object.keys(res.json.fields).sort()).toEqual(["status", "summary"]); + const excluded = await api(t.app, "/rest/api/3/issue/EMU-1?fields=*all,-comment"); + expect(excluded.json.fields.comment).toBeUndefined(); + expect(excluded.json.fields.summary).toBe("Ship Jira emulator"); + }); + + it("returns the seeded issue with Jira shaped fields", async () => { + const res = await api(t.app, "/rest/api/3/issue/EMU-1"); + expect(res.status).toBe(200); + expect(res.json.key).toBe("EMU-1"); + expect(res.json.id).toBe("10000"); + expect(res.json.self).toBe(`${jiraTestBaseUrl}/rest/api/3/issue/10000`); + const f = res.json.fields; + expect(f.summary).toBe("Ship Jira emulator"); + expect(f.status.name).toBe("To Do"); + expect(f.status.statusCategory.key).toBe("new"); + expect(f.issuetype.name).toBe("Task"); + expect(f.project.key).toBe("EMU"); + expect(f.priority.name).toBe("Medium"); + expect(f.assignee.emailAddress).toBe(DEFAULT_DEV_EMAIL); + expect(f.reporter.emailAddress).toBe("admin@jira.local"); + expect(f.description.type).toBe("doc"); + expect(f.created).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+0000$/); + expect(f.comment.total).toBe(1); + expect(f.resolution).toBeNull(); + expect(f.labels).toEqual(["emulate"]); + expect(f.watches.watchCount).toBe(1); + }); + + it("reads the same issue by numeric id", async () => { + const res = await api(t.app, "/rest/api/3/issue/10000"); + expect(res.json.key).toBe("EMU-1"); + }); + + it("renders descriptions as plain strings in REST API v2", async () => { + const res = await api(t.app, "/rest/api/2/issue/EMU-1"); + expect(typeof res.json.fields.description).toBe("string"); + expect(res.json.fields.description).toContain("local Jira state"); + expect(typeof res.json.fields.comment.comments[0].body).toBe("string"); + }); + + it("returns 404 for unknown issues", async () => { + const res = await api(t.app, "/rest/api/3/issue/EMU-999"); + expect(res.status).toBe(404); + expect(res.json.errorMessages).toEqual(["Issue does not exist or you do not have permission to see it."]); + }); + + it("limits returned fields and supports expand", async () => { + const res = await api( + t.app, + "/rest/api/3/issue/EMU-1?fields=summary,status&expand=renderedFields,names,transitions", + ); + expect(Object.keys(res.json.fields).sort()).toEqual(["status", "summary"]); + expect(res.json.names.summary).toBe("Summary"); + expect(res.json.renderedFields.description).toContain("

"); + expect(res.json.transitions.map((tr: any) => tr.name)).toContain("In Progress"); + + const excluded = await api(t.app, "/rest/api/3/issue/EMU-1?fields=*all,-comment"); + expect(excluded.json.fields.comment).toBeUndefined(); + expect(excluded.json.fields.summary).toBeDefined(); + }); + }); + + describe("creating", () => { + it("creates an issue with ADF description and returns id, key, and self", async () => { + const res = await createIssue(t, { + summary: "New task", + description: adf("Hello ADF"), + assignee: { accountId: devId }, + labels: ["backend", "urgent"], + priority: { name: "High" }, + duedate: "2026-12-01", + customfield_10016: 5, + }); + expect(res.status).toBe(201); + expect(res.json).toEqual({ id: "10001", key: "EMU-2", self: `${jiraTestBaseUrl}/rest/api/3/issue/10001` }); + + const issue = (await api(t.app, "/rest/api/3/issue/EMU-2")).json; + expect(issue.fields.description).toEqual(adf("Hello ADF")); + expect(issue.fields.assignee.accountId).toBe(devId); + expect(issue.fields.reporter.emailAddress).toBe("admin@jira.local"); + expect(issue.fields.creator.emailAddress).toBe("admin@jira.local"); + expect(issue.fields.labels).toEqual(["backend", "urgent"]); + expect(issue.fields.priority.name).toBe("High"); + expect(issue.fields.duedate).toBe("2026-12-01"); + expect(issue.fields.customfield_10016).toBe(5); + expect(issue.fields.status.name).toBe("To Do"); + }); + + it("rejects plain string descriptions in v3 but accepts them in v2", async () => { + const v3 = await createIssue(t, { summary: "S", description: "plain" }); + expect(v3.status).toBe(400); + expect(v3.json.errors.description).toBe( + "Operation value must be an Atlassian Document (see the Atlassian Document Format)", + ); + + const v2 = await createIssue(t, { summary: "S", description: "line one\nline two" }, "2"); + expect(v2.status).toBe(201); + expect(v2.json.self).toContain("/rest/api/2/issue/"); + const read = await api(t.app, `/rest/api/3/issue/${v2.json.key}`); + expect(read.json.fields.description.type).toBe("doc"); + const readV2 = await api(t.app, `/rest/api/2/issue/${v2.json.key}`); + expect(readV2.json.fields.description).toBe("line one\nline two"); + }); + + it("validates required fields and references", async () => { + const noSummary = await createIssue(t, {}); + expect(noSummary.status).toBe(400); + expect(noSummary.json.errors.summary).toBe("You must specify a summary of the issue."); + + const noProject = await api(t.app, "/rest/api/3/issue", { + method: "POST", + body: { fields: { summary: "x", issuetype: { name: "Task" } } }, + }); + expect(noProject.json.errors.project).toBe("Specify a valid project ID or key"); + + const badType = await createIssue(t, { summary: "x", issuetype: { id: "99999" } }); + expect(badType.json.errors.issuetype).toBe("Specify a valid issue type"); + + const badUser = await createIssue(t, { summary: "x", assignee: { accountId: "ghost" } }); + expect(badUser.status).toBe(400); + expect(badUser.json.errors.assignee).toBeTruthy(); + + const unknownField = await createIssue(t, { summary: "x", customfield_99999: "nope" }); + expect(unknownField.json.errors.customfield_99999).toBe( + "Field 'customfield_99999' cannot be set. It is not on the appropriate screen, or unknown.", + ); + + const tooLong = await createIssue(t, { summary: "x".repeat(256) }); + expect(tooLong.json.errors.summary).toBe("Summary must be less than 255 characters."); + + const badJson = await api(t.app, "/rest/api/3/issue", { method: "POST", body: "{not json" }); + expect(badJson.status).toBe(400); + }); + + it("requires a parent for subtasks and links parents and children", async () => { + const orphan = await createIssue(t, { summary: "Sub", issuetype: { name: "Subtask" } }); + expect(orphan.status).toBe(400); + expect(orphan.json.errors.parent).toBeTruthy(); + + const sub = await createIssue(t, { summary: "Sub", issuetype: { name: "Subtask" }, parent: { key: "EMU-1" } }); + expect(sub.status).toBe(201); + const child = (await api(t.app, `/rest/api/3/issue/${sub.json.key}`)).json; + expect(child.fields.parent.key).toBe("EMU-1"); + expect(child.fields.parent.fields.summary).toBe("Ship Jira emulator"); + const parent = (await api(t.app, "/rest/api/3/issue/EMU-1")).json; + expect(parent.fields.subtasks.map((s: any) => s.key)).toEqual([sub.json.key]); + }); + + it("sets components and fix versions by name", async () => { + await api(t.app, "/rest/api/3/component", { method: "POST", body: { project: "EMU", name: "API" } }); + await api(t.app, "/rest/api/3/version", { method: "POST", body: { project: "EMU", name: "1.0" } }); + const res = await createIssue(t, { summary: "x", components: [{ name: "API" }], fixVersions: [{ name: "1.0" }] }); + expect(res.status).toBe(201); + const issue = (await api(t.app, `/rest/api/3/issue/${res.json.key}`)).json; + expect(issue.fields.components[0].name).toBe("API"); + expect(issue.fields.fixVersions[0].name).toBe("1.0"); + + const bad = await createIssue(t, { summary: "x", components: [{ name: "Nope" }] }); + expect(bad.status).toBe(400); + expect(bad.json.errors.components).toBeTruthy(); + }); + + it("creates issues in bulk and reports per-issue errors", async () => { + const res = await api(t.app, "/rest/api/3/issue/bulk", { + method: "POST", + body: { + issueUpdates: [ + { fields: { project: { key: "EMU" }, issuetype: { name: "Bug" }, summary: "Bulk one" } }, + { fields: { project: { key: "EMU" }, issuetype: { name: "Bug" } } }, + { fields: { project: { key: "EMU" }, issuetype: { name: "Story" }, summary: "Bulk two" } }, + ], + }, + }); + expect(res.status).toBe(201); + expect(res.json.issues.map((i: any) => i.key)).toEqual(["EMU-2", "EMU-3"]); + expect(res.json.errors).toHaveLength(1); + expect(res.json.errors[0].failedElementNumber).toBe(1); + expect(res.json.errors[0].elementErrors.errors.summary).toBeTruthy(); + }); + }); + + describe("editing", () => { + it("edits fields, records changelog history, and returns 204", async () => { + const res = await api(t.app, "/rest/api/3/issue/EMU-1", { + method: "PUT", + body: { + fields: { summary: "Renamed", priority: { name: "Highest" } }, + update: { labels: [{ add: "triage" }, { remove: "emulate" }] }, + }, + }); + expect(res.status).toBe(204); + + const issue = (await api(t.app, "/rest/api/3/issue/EMU-1?expand=changelog")).json; + expect(issue.fields.summary).toBe("Renamed"); + expect(issue.fields.priority.name).toBe("Highest"); + expect(issue.fields.labels).toEqual(["triage"]); + const items = issue.changelog.histories.flatMap((h: any) => h.items); + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "summary", fromString: "Ship Jira emulator", toString: "Renamed" }), + expect.objectContaining({ field: "priority", fromString: "Medium", toString: "Highest" }), + expect.objectContaining({ field: "labels", fromString: "emulate", toString: "triage" }), + ]), + ); + + const changelog = await api(t.app, "/rest/api/3/issue/EMU-1/changelog"); + expect(changelog.json.total).toBe(1); + expect(changelog.json.values[0].author.emailAddress).toBe("admin@jira.local"); + }); + + it("supports update set operations and returnIssue", async () => { + const res = await api(t.app, "/rest/api/3/issue/EMU-1?returnIssue=true", { + method: "PUT", + body: { update: { summary: [{ set: "Via update" }], labels: [{ set: ["a", "b"] }] } }, + }); + expect(res.status).toBe(200); + expect(res.json.fields.summary).toBe("Via update"); + expect(res.json.fields.labels).toEqual(["a", "b"]); + }); + + it("refuses to set status through edit", async () => { + const res = await api(t.app, "/rest/api/3/issue/EMU-1", { + method: "PUT", + body: { fields: { status: { name: "Done" } } }, + }); + expect(res.status).toBe(400); + expect(res.json.errors.status).toBe( + "Field 'status' cannot be set. It is not on the appropriate screen, or unknown.", + ); + }); + + it("assigns and unassigns through the assignee endpoint", async () => { + const admin = (await api(t.app, "/rest/api/3/myself")).json; + const assign = await api(t.app, "/rest/api/3/issue/EMU-1/assignee", { + method: "PUT", + body: { accountId: admin.accountId }, + }); + expect(assign.status).toBe(204); + expect((await api(t.app, "/rest/api/3/issue/EMU-1")).json.fields.assignee.accountId).toBe(admin.accountId); + + const unassign = await api(t.app, "/rest/api/3/issue/EMU-1/assignee", { + method: "PUT", + body: { accountId: null }, + }); + expect(unassign.status).toBe(204); + expect((await api(t.app, "/rest/api/3/issue/EMU-1")).json.fields.assignee).toBeNull(); + }); + + it("moves an issue to a new parent", async () => { + const epic = await createIssue(t, { summary: "Epic", issuetype: { name: "Epic" } }); + const res = await api(t.app, "/rest/api/3/issue/EMU-1", { + method: "PUT", + body: { fields: { parent: { key: epic.json.key } } }, + }); + expect(res.status).toBe(204); + expect((await api(t.app, "/rest/api/3/issue/EMU-1")).json.fields.parent.key).toBe(epic.json.key); + }); + }); + + describe("deleting", () => { + it("deletes issues and requires deleteSubtasks for parents", async () => { + const sub = await createIssue(t, { summary: "Sub", issuetype: { name: "Subtask" }, parent: { key: "EMU-1" } }); + const blocked = await api(t.app, "/rest/api/3/issue/EMU-1", { method: "DELETE" }); + expect(blocked.status).toBe(400); + + const ok = await api(t.app, "/rest/api/3/issue/EMU-1?deleteSubtasks=true", { method: "DELETE" }); + expect(ok.status).toBe(204); + expect((await api(t.app, "/rest/api/3/issue/EMU-1")).status).toBe(404); + expect((await api(t.app, `/rest/api/3/issue/${sub.json.key}`)).status).toBe(404); + }); + + it("never reuses issue keys after deletion", async () => { + const created = await createIssue(t, { summary: "Temp" }); + await api(t.app, `/rest/api/3/issue/${created.json.key}`, { method: "DELETE" }); + const next = await createIssue(t, { summary: "Next" }); + expect(next.json.key).not.toBe(created.json.key); + expect(next.json.id).not.toBe(created.json.id); + }); + }); + + describe("transitions", () => { + it("lists available transitions and moves the issue through the workflow", async () => { + const list = await api(t.app, "/rest/api/3/issue/EMU-1/transitions"); + expect(list.status).toBe(200); + const names = list.json.transitions.map((tr: any) => tr.name); + expect(names).toEqual(["In Progress", "Done"]); + const done = list.json.transitions.find((tr: any) => tr.name === "Done"); + expect(done.to.statusCategory.key).toBe("done"); + + const move = await api(t.app, "/rest/api/3/issue/EMU-1/transitions", { + method: "POST", + body: { transition: { id: done.id } }, + }); + expect(move.status).toBe(204); + + const issue = (await api(t.app, "/rest/api/3/issue/EMU-1?expand=changelog")).json; + expect(issue.fields.status.name).toBe("Done"); + expect(issue.fields.resolution.name).toBe("Done"); + expect(issue.fields.resolutiondate).toBeTruthy(); + const statusItem = issue.changelog.histories.flatMap((h: any) => h.items).find((i: any) => i.field === "status"); + expect(statusItem).toMatchObject({ fromString: "To Do", toString: "Done" }); + + const reopen = (await api(t.app, "/rest/api/3/issue/EMU-1/transitions")).json.transitions.find( + (tr: any) => tr.name === "To Do", + ); + await api(t.app, "/rest/api/3/issue/EMU-1/transitions", { + method: "POST", + body: { transition: { id: reopen.id } }, + }); + const reopened = (await api(t.app, "/rest/api/3/issue/EMU-1")).json; + expect(reopened.fields.resolution).toBeNull(); + expect(reopened.fields.resolutiondate).toBeNull(); + }); + + it("applies fields and an update comment during a transition", async () => { + const done = (await api(t.app, "/rest/api/3/issue/EMU-1/transitions")).json.transitions.find( + (tr: any) => tr.name === "Done", + ); + const res = await api(t.app, "/rest/api/3/issue/EMU-1/transitions", { + method: "POST", + body: { + transition: { id: done.id }, + fields: { resolution: { name: "Won't Do" } }, + update: { comment: [{ add: { body: adf("Closing") } }] }, + }, + }); + expect(res.status).toBe(204); + const issue = (await api(t.app, "/rest/api/3/issue/EMU-1")).json; + expect(issue.fields.resolution.name).toBe("Won't Do"); + expect(issue.fields.comment.total).toBe(2); + }); + + it("rejects unknown transitions", async () => { + const res = await api(t.app, "/rest/api/3/issue/EMU-1/transitions", { + method: "POST", + body: { transition: { id: "999" } }, + }); + expect(res.status).toBe(400); + expect(res.json.errorMessages).toEqual(["Transition id '999' is not valid for this issue."]); + }); + }); + + describe("metadata", () => { + it("serves create metadata for project issue types and fields", async () => { + const types = await api(t.app, "/rest/api/3/issue/createmeta/EMU/issuetypes"); + expect(types.status).toBe(200); + const task = types.json.issueTypes.find((type: any) => type.name === "Task"); + expect(task).toBeTruthy(); + + const fields = await api(t.app, `/rest/api/3/issue/createmeta/EMU/issuetypes/${task.id}`); + expect(fields.status).toBe(200); + const summary = fields.json.fields.find((f: any) => f.fieldId === "summary"); + expect(summary.required).toBe(true); + const priority = fields.json.fields.find((f: any) => f.fieldId === "priority"); + expect(priority.allowedValues.map((p: any) => p.name)).toContain("High"); + }); + + it("serves edit metadata keyed by field id", async () => { + const res = await api(t.app, "/rest/api/3/issue/EMU-1/editmeta"); + expect(res.status).toBe(200); + expect(res.json.fields.summary.name).toBe("Summary"); + expect(res.json.fields.labels.operations).toContain("add"); + }); + }); +}); diff --git a/packages/@emulators/jira/src/__tests__/jira-sdk.test.ts b/packages/@emulators/jira/src/__tests__/jira-sdk.test.ts new file mode 100644 index 00000000..fcf88734 --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/jira-sdk.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createAgileClient, createCloudClient } from "jira.js"; +import { DEFAULT_ADMIN_EMAIL, DEFAULT_API_TOKEN, DEFAULT_CLOUD_ID, getJiraStore } from "../index.js"; +import { adf, startJiraTestEmulator, type JiraTestEmulator } from "./helpers.js"; + +/** + * Drives the emulator through jira.js over a real local server with `onSchemaMismatch: "throw"`, + * so every response is validated against the client's Jira Cloud schemas. + */ +describe("jira.js conformance", () => { + let t: JiraTestEmulator; + let config: { + host: string; + auth: { type: "basic"; email: string; apiToken: string }; + onSchemaMismatch: "throw"; + retry: { maxAttempts: number }; + }; + + beforeEach(async () => { + t = await startJiraTestEmulator(); + config = { + host: t.url, + auth: { type: "basic", email: DEFAULT_ADMIN_EMAIL, apiToken: DEFAULT_API_TOKEN }, + onSchemaMismatch: "throw", + retry: { maxAttempts: 1 }, + }; + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + await t.close(); + }); + + it("covers the core issue workflow with basic auth", async () => { + const client = createCloudClient(config); + + const me = await client.myself.getCurrentUser(); + expect(me.emailAddress).toBe(DEFAULT_ADMIN_EMAIL); + + const info = await client.serverInfo.getServerInfo(); + expect(info.deploymentType).toBe("Cloud"); + + const projects = await client.projects.searchProjects(); + expect(projects.values?.[0]?.key).toBe("EMU"); + + const types = await client.issues.getCreateIssueMetaIssueTypes({ projectIdOrKey: "EMU" }); + const bug = types.issueTypes?.find((type) => type.name === "Bug"); + expect(bug).toBeTruthy(); + const meta = await client.issues.getCreateIssueMetaIssueTypeId({ projectIdOrKey: "EMU", issueTypeId: bug!.id! }); + expect(meta.fields?.some((field) => field.fieldId === "summary")).toBe(true); + + const created = await client.issues.createIssue({ + fields: { + project: { key: "EMU" }, + issuetype: { name: "Bug" }, + summary: "Created through jira.js", + description: adf("SDK description"), + labels: ["sdk"], + }, + }); + expect(created.key).toBe("EMU-2"); + + const issue = await client.issues.getIssue({ issueIdOrKey: created.key! }); + expect(issue.fields!.summary).toBe("Created through jira.js"); + expect(issue.fields!.labels).toEqual(["sdk"]); + + await client.issues.editIssue({ issueIdOrKey: created.key!, fields: { summary: "Edited through jira.js" } }); + + const transitions = await client.issues.getTransitions({ issueIdOrKey: created.key! }); + const done = transitions.transitions?.find((transition) => transition.name === "Done"); + await client.issues.doTransition({ issueIdOrKey: created.key!, transition: { id: done!.id } }); + + await client.issues.assignIssue({ issueIdOrKey: created.key!, accountId: me.accountId }); + + const comment = await client.issueComments.addComment({ issueIdOrKey: created.key!, body: adf("SDK comment") }); + expect(comment.id).toBeTruthy(); + const comments = await client.issueComments.getComments({ issueIdOrKey: created.key! }); + expect(comments.total).toBe(1); + + await client.issueLinks.linkIssues({ + type: { name: "Relates" }, + inwardIssue: { key: "EMU-1" }, + outwardIssue: { key: created.key }, + }); + + const worklog = await client.issueWorklogs.addWorklog({ issueIdOrKey: created.key!, timeSpent: "2h" }); + expect(worklog.timeSpentSeconds).toBe(7200); + + const changelog = await client.issues.getChangeLogs({ issueIdOrKey: created.key! }); + expect(changelog.values?.length).toBeGreaterThan(0); + + const search = await client.issueSearch.searchAndReconsileIssuesUsingJqlPost({ + jql: "project = EMU AND status = Done", + fields: ["summary", "status", "assignee"], + }); + expect(search.issues?.map((result) => result.key)).toEqual([created.key]); + expect(search.issues?.[0].fields?.summary).toBe("Edited through jira.js"); + + const count = await client.issueSearch.countIssues({ jql: "project = EMU" }); + expect(count.count).toBe(2); + + const fields = await client.issueFields.getFields(); + expect(fields.some((field) => field.id === "summary")).toBe(true); + await client.issuePriorities.searchPriorities(); + await client.issueTypes.getIssueAllTypes(); + await client.workflowStatuses.getStatuses(); + await client.issueLinkTypes.getIssueLinkTypes(); + await client.userSearch.findUsers({ query: "dev" }); + await client.issueWatchers.getIssueWatchers({ issueIdOrKey: created.key! }); + + await client.issues.deleteIssue({ issueIdOrKey: created.key! }); + expect(getJiraStore(t.store).issues.findOneBy("key", created.key!)).toBeUndefined(); + }); + + it("maps Jira errors to typed jira.js errors", async () => { + const client = createCloudClient(config); + await expect(client.issues.getIssue({ issueIdOrKey: "EMU-404" })).rejects.toMatchObject({ status: 404 }); + + const badAuth = createCloudClient({ ...config, auth: { ...config.auth, apiToken: "wrong" } }); + await expect(badAuth.myself.getCurrentUser()).rejects.toMatchObject({ status: 401 }); + }); + + it("drives boards and sprints through the agile client", async () => { + const agile = createAgileClient(config); + const boards = await agile.board.getAllBoards(); + const boardId = boards.values?.[0]?.id; + expect(boardId).toBe(1); + + const sprints = await agile.board.getAllSprints({ boardId: boardId! }); + expect(sprints.values?.[0]?.state).toBe("active"); + + const sprint = await agile.sprint.createSprint({ name: "SDK sprint", originBoardId: boardId! }); + expect(sprint.state).toBe("future"); + await agile.sprint.moveIssuesToSprintAndRank({ sprintId: sprint.id!, issues: ["EMU-1"] }); + const sprintIssues = await agile.sprint.getIssuesForSprint({ sprintId: sprint.id! }); + expect(sprintIssues.issues?.map((issue) => issue.key)).toEqual(["EMU-1"]); + + await agile.backlog.moveIssuesToBacklog({ issues: ["EMU-1"] }); + const backlog = await agile.board.getIssuesForBacklog({ boardId: boardId! }); + expect(backlog.issues?.map((issue) => issue.key)).toEqual(["EMU-1"]); + }); + + it("works over OAuth 2.0 through the api.atlassian.com gateway", async () => { + const js = getJiraStore(t.store); + const admin = js.users.findOneBy("email", DEFAULT_ADMIN_EMAIL)!; + js.oauthTokens.insert({ + token: "jira_sdk_oauth", + type: "access", + account_id: admin.account_id, + client_id: "jira_example_client_id", + scopes: ["read:jira-work", "write:jira-work", "read:jira-user"], + expires_at: null, + revoked: false, + }); + // jira.js hardcodes https://api.atlassian.com/ex/jira/{cloudId} for OAuth 2.0, so only that host is + // rerouted to the local server. The request still goes over HTTP through the full middleware stack. + const realFetch = globalThis.fetch; + vi.stubGlobal("fetch", (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (url.hostname !== "api.atlassian.com") return realFetch(request); + return realFetch(new Request(`${t.url}${url.pathname}${url.search}`, request)); + }); + const client = createCloudClient({ + auth: { type: "oauth2", accessToken: "jira_sdk_oauth", cloudId: DEFAULT_CLOUD_ID }, + onSchemaMismatch: "throw", + retry: { maxAttempts: 1 }, + }); + const me = await client.myself.getCurrentUser(); + expect(me.emailAddress).toBe(DEFAULT_ADMIN_EMAIL); + const issue = await client.issues.getIssue({ issueIdOrKey: "EMU-1" }); + expect(issue.self).toContain(`/ex/jira/${DEFAULT_CLOUD_ID}/rest/api/3/issue/`); + }); +}); diff --git a/packages/@emulators/jira/src/__tests__/jql.test.ts b/packages/@emulators/jira/src/__tests__/jql.test.ts new file mode 100644 index 00000000..eabca559 --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/jql.test.ts @@ -0,0 +1,285 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_DEV_EMAIL, getJiraStore, type JiraStore } from "../index.js"; +import { JqlError, parseJql, searchIssues } from "../jql.js"; +import { adf, api, createJiraTestApp, type JiraTestApp } from "./helpers.js"; + +describe("JQL parser", () => { + it("parses clauses, boolean logic, and ORDER BY", () => { + const q = parseJql( + 'project = EMU AND (status = "In Progress" OR labels IN (a, b)) AND NOT assignee IS EMPTY ORDER BY created DESC, key', + ); + expect(q.where).not.toBeNull(); + expect(q.orderBy).toEqual([ + { field: "created", direction: "DESC" }, + { field: "key", direction: "ASC" }, + ]); + }); + + it("parses an empty query and an ORDER BY only query", () => { + expect(parseJql("").where).toBeNull(); + const orderOnly = parseJql("order by updated asc"); + expect(orderOnly.where).toBeNull(); + expect(orderOnly.orderBy).toEqual([{ field: "updated", direction: "ASC" }]); + }); + + it("reports syntax errors", () => { + expect(() => parseJql("project =")).toThrow(JqlError); + expect(() => parseJql("project = EMU AND")).toThrow(/Error in the JQL Query/); + expect(() => parseJql('summary ~ "unterminated')).toThrow(JqlError); + expect(() => parseJql("(project = EMU")).toThrow(JqlError); + }); +}); + +describe("JQL evaluation", () => { + let t: JiraTestApp; + let js: JiraStore; + let devId: string; + + const keys = (jql: string, userEmail = "admin@jira.local") => { + const user = js.users.findOneBy("email", userEmail)!; + return searchIssues(js, jql, user).map((issue) => issue.key); + }; + + beforeEach(async () => { + t = createJiraTestApp(); + js = getJiraStore(t.store); + devId = js.users.findOneBy("email", DEFAULT_DEV_EMAIL)!.account_id; + await api(t.app, "/rest/api/3/project", { + method: "POST", + body: { key: "OPS", name: "Operations", projectTypeKey: "software" }, + }); + const create = (fields: Record) => + api(t.app, "/rest/api/3/issue", { + method: "POST", + body: { fields: { project: { key: "EMU" }, issuetype: { name: "Task" }, ...fields } }, + }); + await create({ + summary: "Login page crashes", + issuetype: { name: "Bug" }, + priority: { name: "High" }, + labels: ["frontend"], + }); // EMU-2 + await create({ + summary: "Add dark mode", + description: adf("Users want a dark theme"), + labels: ["frontend", "ux"], + duedate: "2026-10-15", + }); // EMU-3 + await create({ + summary: "Database migration", + assignee: { accountId: devId }, + customfield_10016: 8, + priority: { name: "Low" }, + }); // EMU-4 + await create({ summary: "Ops runbook", project: { key: "OPS" } }); // OPS-1 + await create({ summary: "Child task", issuetype: { name: "Subtask" }, parent: { key: "EMU-2" } }); // EMU-5 + const done = (await api(t.app, "/rest/api/3/issue/EMU-4/transitions")).json.transitions.find( + (tr: any) => tr.name === "Done", + ); + await api(t.app, "/rest/api/3/issue/EMU-4/transitions", { method: "POST", body: { transition: { id: done.id } } }); + }); + + it("filters by project, key, and id", () => { + expect(keys("project = OPS")).toEqual(["OPS-1"]); + expect(keys("project in (EMU, OPS) AND key = OPS-1")).toEqual(["OPS-1"]); + expect(keys("issuekey in (EMU-2, EMU-3) ORDER BY key ASC")).toEqual(["EMU-2", "EMU-3"]); + expect(keys("id = 10000")).toEqual(["EMU-1"]); + expect(keys('project = "Operations"')).toEqual(["OPS-1"]); + }); + + it("filters by status, status category, and resolution", () => { + expect(keys('status = "Done"')).toEqual(["EMU-4"]); + expect(keys("status != Done AND project = EMU ORDER BY key ASC")).toEqual(["EMU-1", "EMU-2", "EMU-3", "EMU-5"]); + expect(keys("statusCategory = Done")).toEqual(["EMU-4"]); + expect(keys("statusCategory != done AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("resolution = Unresolved AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("resolution is not EMPTY")).toEqual(["EMU-4"]); + }); + + it("filters by users with currentUser() and EMPTY", () => { + expect(keys("assignee = currentUser() ORDER BY key", DEFAULT_DEV_EMAIL)).toEqual(["EMU-1", "EMU-4"]); + expect(keys(`assignee = "${devId}" ORDER BY key`)).toEqual(["EMU-1", "EMU-4"]); + expect(keys(`assignee = "${DEFAULT_DEV_EMAIL}" ORDER BY key`)).toEqual(["EMU-1", "EMU-4"]); + expect(keys("assignee is EMPTY AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("reporter = currentUser() AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("watcher = currentUser() AND project = OPS")).toEqual(["OPS-1"]); + }); + + it("filters by text with ~ and !~", () => { + expect(keys('summary ~ "crash"')).toEqual(["EMU-2"]); + expect(keys('summary ~ "login crash*"')).toEqual(["EMU-2"]); + expect(keys('description ~ "dark theme"')).toEqual(["EMU-3"]); + expect(keys('text ~ "dark" ORDER BY key')).toEqual(["EMU-3"]); + expect(keys('project = EMU AND summary !~ "task" ORDER BY key')).toEqual(["EMU-1", "EMU-2", "EMU-3", "EMU-4"]); + expect(keys('summary ~ "\\"dark mode\\""')).toEqual(["EMU-3"]); + expect(keys('comment ~ "seeded"')).toEqual(["EMU-1"]); + }); + + it("filters by labels, issue type, priority, and parent", () => { + expect(keys("labels = frontend ORDER BY key")).toEqual(["EMU-2", "EMU-3"]); + expect(keys("labels in (ux, emulate) ORDER BY key")).toEqual(["EMU-1", "EMU-3"]); + expect(keys("labels is EMPTY AND project = EMU ORDER BY key")).toEqual(["EMU-4", "EMU-5"]); + expect(keys("issuetype = Bug")).toEqual(["EMU-2"]); + expect(keys("type in subTaskIssueTypes()")).toEqual(["EMU-5"]); + expect(keys("priority = High")).toEqual(["EMU-2"]); + expect(keys("priority > Medium")).toEqual(["EMU-2"]); + expect(keys("priority <= Low")).toEqual(["EMU-4"]); + expect(keys("parent = EMU-2")).toEqual(["EMU-5"]); + }); + + it("filters by dates, relative dates, and date functions", () => { + expect(keys('duedate = "2026-10-15"')).toEqual(["EMU-3"]); + expect(keys('duedate < "2026-10-16" AND duedate >= 2026-10-15')).toEqual(["EMU-3"]); + expect(keys("created >= -1d AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("created < -1d")).toEqual([]); + expect(keys("created >= startOfDay() AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("updated <= now() AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("resolutiondate >= startOfDay(-1d)")).toEqual(["EMU-4"]); + expect(keys("created >= startOfMonth(-1) AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("created >= startOfYear(-1y) AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("created > -1M AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("created < startOfMonth(-1)")).toEqual([]); + expect(keys("created < -1y")).toEqual([]); + }); + + it("filters by sprint and custom fields", () => { + expect(keys("sprint in openSprints()")).toEqual(["EMU-1"]); + expect(keys('sprint = "EMU Sprint 1"')).toEqual(["EMU-1"]); + expect(keys("sprint = 1")).toEqual(["EMU-1"]); + expect(keys("sprint is EMPTY AND project = OPS")).toEqual(["OPS-1"]); + expect(keys("cf[10016] > 5")).toEqual(["EMU-4"]); + expect(keys('"Story point estimate" = 8')).toEqual(["EMU-4"]); + }); + + it("supports NOT, OR, and parentheses", () => { + expect(keys("project = EMU AND NOT (labels = frontend OR status = Done) ORDER BY key")).toEqual(["EMU-1", "EMU-5"]); + expect(keys("issuetype = Bug OR project = OPS ORDER BY key")).toEqual(["EMU-2", "OPS-1"]); + }); + + it("orders results", () => { + expect(keys("project = EMU ORDER BY key DESC")).toEqual(["EMU-5", "EMU-4", "EMU-3", "EMU-2", "EMU-1"]); + expect(keys("project = EMU AND priority is not EMPTY ORDER BY priority DESC, key ASC")[0]).toBe("EMU-2"); + expect(keys("project = EMU ORDER BY summary ASC")[0]).toBe("EMU-3"); + expect(keys("project = EMU ORDER BY rank ASC")).toEqual(["EMU-1", "EMU-2", "EMU-3", "EMU-4", "EMU-5"]); + expect(() => searchIssues(js, "project = EMU ORDER BY bogus", js.users.all()[0])).toThrow( + "Field 'bogus' does not exist or you do not have permission to view it.", + ); + }); + + it("accepts every clause name for a field", () => { + expect(keys("type = Bug")).toEqual(keys("issuetype = Bug")); + expect(keys("label = frontend ORDER BY key")).toEqual(keys("labels = frontend ORDER BY key")); + expect(keys("issue = EMU-2")).toEqual(["EMU-2"]); + expect(keys("id = EMU-2")).toEqual(["EMU-2"]); + expect(keys("watchers = currentUser() AND project = OPS")).toEqual(["OPS-1"]); + expect(() => searchIssues(js, "rank = 1", js.users.all()[0])).toThrow(JqlError); + }); + + it("finds linked issues", async () => { + await api(t.app, "/rest/api/3/issueLink", { + method: "POST", + body: { type: { name: "Blocks" }, inwardIssue: { key: "EMU-1" }, outwardIssue: { key: "EMU-3" } }, + }); + expect(keys("issue in linkedIssues(EMU-1)")).toEqual(["EMU-3"]); + expect(keys('issue in linkedIssues(EMU-3, "is blocked by")')).toEqual([]); + expect(keys('issue in linkedIssues(EMU-3, "blocks")')).toEqual(["EMU-1"]); + }); + + it("rejects unknown fields, values, and functions", () => { + const user = js.users.all()[0]; + expect(() => searchIssues(js, "bogus = 1", user)).toThrow( + "Field 'bogus' does not exist or you do not have permission to view it.", + ); + expect(() => searchIssues(js, "project = NOPE", user)).toThrow( + "The value 'NOPE' does not exist for the field 'project'.", + ); + expect(() => searchIssues(js, "status = Blocked", user)).toThrow( + "The value 'Blocked' does not exist for the field 'status'.", + ); + expect(() => searchIssues(js, "assignee = noSuchFn()", user)).toThrow("Unable to find JQL function 'noSuchFn()'."); + }); +}); + +describe("search endpoints", () => { + let t: JiraTestApp; + + beforeEach(async () => { + t = createJiraTestApp(); + for (const summary of ["one", "two", "three"]) { + await api(t.app, "/rest/api/3/issue", { + method: "POST", + body: { fields: { project: { key: "EMU" }, issuetype: { name: "Task" }, summary } }, + }); + } + }); + + it("paginates /search/jql with nextPageToken and requested fields", async () => { + const first = await api( + t.app, + "/rest/api/3/search/jql?jql=project%3DEMU%20ORDER%20BY%20key%20ASC&maxResults=3&fields=summary,status", + ); + expect(first.status).toBe(200); + expect(first.json.issues.map((i: any) => i.key)).toEqual(["EMU-1", "EMU-2", "EMU-3"]); + expect(Object.keys(first.json.issues[0].fields).sort()).toEqual(["status", "summary"]); + expect(first.json.isLast).toBe(false); + expect(first.json.nextPageToken).toBeTruthy(); + + const second = await api(t.app, "/rest/api/3/search/jql", { + method: "POST", + body: { jql: "project = EMU ORDER BY key ASC", maxResults: 3, nextPageToken: first.json.nextPageToken }, + }); + expect(second.json.issues.map((i: any) => i.key)).toEqual(["EMU-4"]); + expect(second.json.isLast).toBe(true); + expect(second.json.nextPageToken).toBeUndefined(); + }); + + it("returns only ids by default from /search/jql", async () => { + const res = await api(t.app, "/rest/api/3/search/jql?jql=project%3DEMU"); + expect(res.json.issues[0].id).toBeTruthy(); + expect(res.json.issues[0].fields).toEqual({}); + }); + + it("rejects unbounded /search/jql queries and bad JQL", async () => { + const unbounded = await api(t.app, "/rest/api/3/search/jql", { method: "POST", body: { jql: "order by created" } }); + expect(unbounded.status).toBe(400); + expect(unbounded.json.errorMessages[0]).toContain("Unbounded JQL queries are not allowed"); + + const bad = await api(t.app, "/rest/api/3/search/jql", { method: "POST", body: { jql: "project = NOPE" } }); + expect(bad.status).toBe(400); + expect(bad.json.errorMessages).toEqual(["The value 'NOPE' does not exist for the field 'project'."]); + }); + + it("serves the legacy /search endpoint with startAt and total", async () => { + const res = await api( + t.app, + "/rest/api/2/search?jql=project%3DEMU%20ORDER%20BY%20key%20ASC&startAt=1&maxResults=2", + ); + expect(res.status).toBe(200); + expect(res.json.total).toBe(4); + expect(res.json.startAt).toBe(1); + expect(res.json.issues.map((i: any) => i.key)).toEqual(["EMU-2", "EMU-3"]); + expect(res.json.issues[0].fields.summary).toBe("one"); + + const post = await api(t.app, "/rest/api/3/search", { + method: "POST", + body: { jql: "summary ~ two", fields: ["summary"], expand: ["names"] }, + }); + expect(post.json.total).toBe(1); + expect(post.json.names.summary).toBe("Summary"); + }); + + it("returns an approximate count", async () => { + const res = await api(t.app, "/rest/api/3/search/approximate-count", { + method: "POST", + body: { jql: "project = EMU" }, + }); + expect(res.status).toBe(200); + expect(res.json).toEqual({ count: 4 }); + }); + + it("serves the issue picker", async () => { + const res = await api(t.app, "/rest/api/3/issue/picker?query=thr"); + expect(res.status).toBe(200); + expect(res.json.sections[0].issues.map((i: any) => i.key)).toEqual(["EMU-4"]); + }); +}); diff --git a/packages/@emulators/jira/src/__tests__/metadata.test.ts b/packages/@emulators/jira/src/__tests__/metadata.test.ts new file mode 100644 index 00000000..e6463f2b --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/metadata.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_DEV_API_TOKEN, DEFAULT_DEV_EMAIL, getJiraStore } from "../index.js"; +import { api, basicAuth, createJiraTestApp, jiraTestBaseUrl, type JiraTestApp } from "./helpers.js"; + +describe("Jira metadata", () => { + let t: JiraTestApp; + + beforeEach(() => { + t = createJiraTestApp(); + }); + + describe("users", () => { + it("looks up a user by accountId and 404s for unknown ids", async () => { + const dev = getJiraStore(t.store).users.findOneBy("email", DEFAULT_DEV_EMAIL)!; + const res = await api(t.app, `/rest/api/3/user?accountId=${dev.account_id}`); + expect(res.status).toBe(200); + expect(res.json.displayName).toBe("Developer"); + + const missing = await api(t.app, "/rest/api/3/user?accountId=nope"); + expect(missing.status).toBe(404); + expect(missing.json.errorMessages[0]).toContain("nope"); + }); + + it("searches users by query across name and email", async () => { + const res = await api(t.app, "/rest/api/3/user/search?query=dev"); + expect(res.status).toBe(200); + expect(res.json.map((u: any) => u.emailAddress)).toEqual([DEFAULT_DEV_EMAIL]); + + const all = await api(t.app, "/rest/api/3/users/search"); + expect(all.json.length).toBe(2); + }); + + it("lists assignable users for a project", async () => { + const res = await api(t.app, "/rest/api/3/user/assignable/search?project=EMU&query=admin"); + expect(res.status).toBe(200); + expect(res.json).toHaveLength(1); + expect(res.json[0].emailAddress).toBe("admin@jira.local"); + }); + + it("returns bulk users as a paginated page", async () => { + const users = getJiraStore(t.store).users.all(); + const res = await api( + t.app, + `/rest/api/3/user/bulk?accountId=${users[0].account_id}&accountId=${users[1].account_id}`, + ); + expect(res.status).toBe(200); + expect(res.json.total).toBe(2); + expect(res.json.values).toHaveLength(2); + expect(res.json.isLast).toBe(true); + }); + }); + + describe("projects", () => { + it("lists projects as an array and as a paginated search", async () => { + const list = await api(t.app, "/rest/api/3/project"); + expect(list.status).toBe(200); + expect(list.json[0].key).toBe("EMU"); + expect(list.json[0].id).toBe("10000"); + + const search = await api(t.app, "/rest/api/3/project/search?query=emu"); + expect(search.status).toBe(200); + expect(search.json.total).toBe(1); + expect(search.json.values[0].key).toBe("EMU"); + expect(search.json.isLast).toBe(true); + }); + + it("gets a project by key or id with issue types and lead", async () => { + const byKey = await api(t.app, "/rest/api/3/project/EMU"); + expect(byKey.status).toBe(200); + expect(byKey.json.self).toBe(`${jiraTestBaseUrl}/rest/api/3/project/10000`); + expect(byKey.json.lead.emailAddress).toBe("admin@jira.local"); + expect(byKey.json.issueTypes.map((type: any) => type.name)).toContain("Bug"); + + const byId = await api(t.app, "/rest/api/3/project/10000"); + expect(byId.json.key).toBe("EMU"); + + const missing = await api(t.app, "/rest/api/3/project/NOPE"); + expect(missing.status).toBe(404); + expect(missing.json.errorMessages).toEqual(["No project could be found with key 'NOPE'."]); + }); + + it("creates, updates, and deletes a project", async () => { + const me = (await api(t.app, "/rest/api/3/myself")).json; + const created = await api(t.app, "/rest/api/3/project", { + method: "POST", + body: { key: "OPS", name: "Operations", projectTypeKey: "software", leadAccountId: me.accountId }, + }); + expect(created.status).toBe(201); + expect(created.json.key).toBe("OPS"); + expect(created.json.self).toBe(`${jiraTestBaseUrl}/rest/api/3/project/${created.json.id}`); + + const duplicate = await api(t.app, "/rest/api/3/project", { + method: "POST", + body: { key: "OPS", name: "Other", projectTypeKey: "software", leadAccountId: me.accountId }, + }); + expect(duplicate.status).toBe(400); + expect(duplicate.json.errors.projectKey).toContain("already exists"); + + const badKey = await api(t.app, "/rest/api/3/project", { + method: "POST", + body: { key: "lower", name: "Lower", projectTypeKey: "software", leadAccountId: me.accountId }, + }); + expect(badKey.status).toBe(400); + expect(badKey.json.errors.projectKey).toBeTruthy(); + + const updated = await api(t.app, "/rest/api/3/project/OPS", { + method: "PUT", + body: { name: "Ops", description: "Runbooks" }, + }); + expect(updated.status).toBe(200); + expect(updated.json.name).toBe("Ops"); + expect(updated.json.description).toBe("Runbooks"); + + const deleted = await api(t.app, "/rest/api/3/project/OPS", { method: "DELETE" }); + expect(deleted.status).toBe(204); + expect((await api(t.app, "/rest/api/3/project/OPS")).status).toBe(404); + }); + + it("forbids non-admins from creating projects", async () => { + const res = await api(t.app, "/rest/api/3/project", { + method: "POST", + auth: basicAuth(DEFAULT_DEV_EMAIL, DEFAULT_DEV_API_TOKEN), + body: { key: "DEV", name: "Dev", projectTypeKey: "software" }, + }); + expect(res.status).toBe(403); + }); + + it("lists project statuses grouped by issue type", async () => { + const res = await api(t.app, "/rest/api/3/project/EMU/statuses"); + expect(res.status).toBe(200); + const task = res.json.find((entry: any) => entry.name === "Task"); + expect(task.statuses.map((s: any) => s.name)).toEqual(["To Do", "In Progress", "Done"]); + }); + + it("manages project components and versions", async () => { + const component = await api(t.app, "/rest/api/3/component", { + method: "POST", + body: { project: "EMU", name: "API", description: "Backend" }, + }); + expect(component.status).toBe(201); + expect(component.json.name).toBe("API"); + const components = await api(t.app, "/rest/api/3/project/EMU/components"); + expect(components.json.map((c: any) => c.name)).toEqual(["API"]); + + const version = await api(t.app, "/rest/api/3/version", { + method: "POST", + body: { projectId: 10000, name: "1.0.0", releaseDate: "2026-10-01" }, + }); + expect(version.status).toBe(201); + expect(version.json.released).toBe(false); + const released = await api(t.app, `/rest/api/3/version/${version.json.id}`, { + method: "PUT", + body: { released: true }, + }); + expect(released.json.released).toBe(true); + const versions = await api(t.app, "/rest/api/3/project/EMU/versions"); + expect(versions.json.map((v: any) => v.name)).toEqual(["1.0.0"]); + }); + + it("validates component and version changes", async () => { + const dev = basicAuth(DEFAULT_DEV_EMAIL, DEFAULT_DEV_API_TOKEN); + const denied = await api(t.app, "/rest/api/3/component", { + method: "POST", + body: { project: "EMU", name: "API" }, + auth: dev, + }); + expect(denied.status).toBe(403); + + const api1 = await api(t.app, "/rest/api/3/component", { method: "POST", body: { project: "EMU", name: "API" } }); + await api(t.app, "/rest/api/3/component", { method: "POST", body: { project: "EMU", name: "Web" } }); + const clash = await api(t.app, `/rest/api/3/component/${api1.json.id}`, { + method: "PUT", + body: { name: "web" }, + }); + expect(clash.status).toBe(400); + expect(clash.json.errors.name).toMatch(/already exists/); + const deniedDelete = await api(t.app, `/rest/api/3/component/${api1.json.id}`, { method: "DELETE", auth: dev }); + expect(deniedDelete.status).toBe(403); + + const badDate = await api(t.app, "/rest/api/3/version", { + method: "POST", + body: { project: "EMU", name: "2.0.0", releaseDate: "next week" }, + }); + expect(badDate.status).toBe(400); + expect(badDate.json.errors.releaseDate).toBeTruthy(); + }); + + it("lets the project lead manage components", async () => { + const store = getJiraStore(t.store); + const dev = store.users.findOneBy("email", DEFAULT_DEV_EMAIL)!; + const project = store.projects.findOneBy("key", "EMU")!; + store.projects.update(project.id, { lead_account_id: dev.account_id }); + const created = await api(t.app, "/rest/api/3/component", { + method: "POST", + body: { project: "EMU", name: "API" }, + auth: basicAuth(DEFAULT_DEV_EMAIL, DEFAULT_DEV_API_TOKEN), + }); + expect(created.status).toBe(201); + }); + }); + + describe("configuration", () => { + it("lists issue types, including per project", async () => { + const all = await api(t.app, "/rest/api/3/issuetype"); + expect(all.status).toBe(200); + const subtask = all.json.find((type: any) => type.name === "Subtask"); + expect(subtask.subtask).toBe(true); + expect(subtask.hierarchyLevel).toBe(-1); + + const one = await api(t.app, `/rest/api/3/issuetype/${subtask.id}`); + expect(one.json.name).toBe("Subtask"); + + const forProject = await api(t.app, "/rest/api/3/issuetype/project?projectId=10000"); + expect(forProject.json.length).toBe(5); + }); + + it("lists statuses, status categories, priorities, and resolutions", async () => { + const statuses = await api(t.app, "/rest/api/3/status"); + expect(statuses.json.map((s: any) => s.statusCategory.key)).toEqual(["new", "indeterminate", "done"]); + const byName = await api(t.app, "/rest/api/3/status/In%20Progress"); + expect(byName.json.name).toBe("In Progress"); + + const categories = await api(t.app, "/rest/api/3/statuscategory"); + expect(categories.json.map((c: any) => c.key)).toContain("done"); + + const priorities = await api(t.app, "/rest/api/3/priority"); + expect(priorities.json.map((p: any) => p.name)).toEqual(["Highest", "High", "Medium", "Low", "Lowest"]); + const medium = await api(t.app, "/rest/api/3/priority/3"); + expect(medium.json.name).toBe("Medium"); + + const resolutions = await api(t.app, "/rest/api/3/resolution"); + expect(resolutions.json.map((r: any) => r.name)).toContain("Done"); + }); + + it("lists system and custom fields with clause names", async () => { + const res = await api(t.app, "/rest/api/3/field"); + expect(res.status).toBe(200); + const summary = res.json.find((f: any) => f.id === "summary"); + expect(summary.custom).toBe(false); + expect(summary.clauseNames).toContain("summary"); + const sprint = res.json.find((f: any) => f.name === "Sprint"); + expect(sprint.id).toBe("customfield_10020"); + expect(sprint.custom).toBe(true); + expect(sprint.clauseNames).toContain("cf[10020]"); + }); + + it("lists issue link types", async () => { + const res = await api(t.app, "/rest/api/3/issueLinkType"); + expect(res.json.issueLinkTypes.map((l: any) => l.name)).toEqual(["Blocks", "Cloners", "Duplicate", "Relates"]); + }); + + it("reports permissions for the current user", async () => { + const res = await api(t.app, "/rest/api/3/mypermissions?permissions=BROWSE_PROJECTS,ADMINISTER"); + expect(res.status).toBe(200); + expect(res.json.permissions.BROWSE_PROJECTS.havePermission).toBe(true); + expect(res.json.permissions.ADMINISTER.havePermission).toBe(true); + + const dev = await api(t.app, "/rest/api/3/mypermissions?permissions=ADMINISTER", { + auth: basicAuth(DEFAULT_DEV_EMAIL, DEFAULT_DEV_API_TOKEN), + }); + expect(dev.json.permissions.ADMINISTER.havePermission).toBe(false); + }); + + it("names permission keys with empty underscore segments", async () => { + const res = await api(t.app, "/rest/api/3/mypermissions?permissions=_,EDIT__ISSUES"); + expect(res.status).toBe(200); + expect(res.json.permissions._.name).toBe(""); + expect(res.json.permissions.EDIT__ISSUES.name).toBe("Edit Issues"); + }); + }); +}); diff --git a/packages/@emulators/jira/src/__tests__/oauth.test.ts b/packages/@emulators/jira/src/__tests__/oauth.test.ts new file mode 100644 index 00000000..85c7c3e5 --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/oauth.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_CLOUD_ID, getJiraStore } from "../index.js"; +import { adf, api, createJiraTestApp, jiraTestBaseUrl, type JiraTestApp } from "./helpers.js"; + +const CLIENT_ID = "jira_example_client_id"; +const CLIENT_SECRET = "example_client_secret"; +const REDIRECT = "http://localhost:3000/api/auth/callback/atlassian"; + +describe("Atlassian OAuth 2.0 (3LO) and the API gateway", () => { + let t: JiraTestApp; + + beforeEach(() => { + t = createJiraTestApp(); + }); + + const authorizeUrl = (params: Record = {}) => { + const search = new URLSearchParams({ + audience: "api.atlassian.com", + client_id: CLIENT_ID, + scope: "read:jira-work write:jira-work read:jira-user offline_access", + redirect_uri: REDIRECT, + state: "xyz", + response_type: "code", + prompt: "consent", + ...params, + }); + return `${jiraTestBaseUrl}/authorize?${search}`; + }; + + async function authorize(scope?: string): Promise { + const admin = getJiraStore(t.store).users.findOneBy("email", "admin@jira.local")!; + const form = new URLSearchParams({ + account_id: admin.account_id, + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + scope: scope ?? "read:jira-work write:jira-work read:jira-user offline_access", + state: "xyz", + }); + const res = await t.app.request(`${jiraTestBaseUrl}/authorize/callback`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: form.toString(), + }); + expect(res.status).toBe(302); + const location = new URL(res.headers.get("location")!); + expect(location.origin + location.pathname).toBe(REDIRECT); + expect(location.searchParams.get("state")).toBe("xyz"); + return location.searchParams.get("code")!; + } + + async function exchange(body: Record) { + return api(t.app, "/oauth/token", { method: "POST", auth: null, body }); + } + + it("renders the consent page listing users", async () => { + const res = await t.app.request(authorizeUrl()); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("admin@jira.local"); + expect(html).toContain("My Jira App"); + expect(html).toContain("read:jira-work"); + }); + + it("rejects unknown clients, bad redirect URIs, and unregistered scopes", async () => { + expect((await t.app.request(authorizeUrl({ client_id: "nope" }))).status).toBe(400); + expect((await t.app.request(authorizeUrl({ redirect_uri: "https://evil.test/cb" }))).status).toBe(400); + expect((await t.app.request(authorizeUrl({ scope: "read:jira-work delete:everything" }))).status).toBe(400); + expect((await t.app.request(authorizeUrl({ audience: "wrong" }))).status).toBe(400); + }); + + it("exchanges an authorization code for tokens once", async () => { + const code = await authorize(); + const res = await exchange({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + redirect_uri: REDIRECT, + }); + expect(res.status).toBe(200); + expect(res.json.token_type).toBe("Bearer"); + expect(res.json.expires_in).toBe(3600); + expect(res.json.scope).toBe("read:jira-work write:jira-work read:jira-user offline_access"); + expect(res.json.access_token).toBeTruthy(); + expect(res.json.refresh_token).toBeTruthy(); + + const reuse = await exchange({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + redirect_uri: REDIRECT, + }); + expect(reuse.status).toBe(403); + expect(reuse.json.error).toBe("invalid_grant"); + }); + + it("rejects a wrong client secret and omits refresh tokens without offline_access", async () => { + const code = await authorize("read:jira-work"); + const bad = await exchange({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + client_secret: "wrong", + code, + redirect_uri: REDIRECT, + }); + expect(bad.status).toBe(401); + + const ok = await exchange({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: await authorize("read:jira-work"), + redirect_uri: REDIRECT, + }); + expect(ok.json.refresh_token).toBeUndefined(); + }); + + it("accepts form encoded token requests", async () => { + const code = await authorize(); + const res = await t.app.request(`${jiraTestBaseUrl}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + redirect_uri: REDIRECT, + }).toString(), + }); + expect(res.status).toBe(200); + }); + + it("rotates refresh tokens", async () => { + const tokens = ( + await exchange({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: await authorize(), + redirect_uri: REDIRECT, + }) + ).json; + const refreshed = await exchange({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + refresh_token: tokens.refresh_token, + }); + expect(refreshed.status).toBe(200); + expect(refreshed.json.access_token).not.toBe(tokens.access_token); + expect(refreshed.json.refresh_token).not.toBe(tokens.refresh_token); + + const replay = await exchange({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + refresh_token: tokens.refresh_token, + }); + expect(replay.status).toBe(403); + }); + + it("lists accessible resources, serves /me, and routes the gateway by cloud id", async () => { + const { access_token } = ( + await exchange({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: await authorize(), + redirect_uri: REDIRECT, + }) + ).json; + const bearer = `Bearer ${access_token}`; + + const resources = await api(t.app, "/oauth/token/accessible-resources", { auth: bearer }); + expect(resources.status).toBe(200); + expect(resources.json).toEqual([ + expect.objectContaining({ + id: DEFAULT_CLOUD_ID, + url: jiraTestBaseUrl, + scopes: expect.arrayContaining(["read:jira-work"]), + }), + ]); + + const me = await api(t.app, "/me", { auth: bearer }); + expect(me.json.email).toBe("admin@jira.local"); + expect(me.json.account_status).toBe("active"); + + const myself = await api(t.app, `/ex/jira/${DEFAULT_CLOUD_ID}/rest/api/3/myself`, { auth: bearer }); + expect(myself.status).toBe(200); + expect(myself.json.self).toBe( + `${jiraTestBaseUrl}/ex/jira/${DEFAULT_CLOUD_ID}/rest/api/3/user?accountId=${myself.json.accountId}`, + ); + + const created = await api(t.app, `/ex/jira/${DEFAULT_CLOUD_ID}/rest/api/3/issue`, { + method: "POST", + auth: bearer, + body: { + fields: { project: { key: "EMU" }, issuetype: { name: "Task" }, summary: "Via gateway", description: adf("x") }, + }, + }); + expect(created.status).toBe(201); + expect(created.json.self).toContain(`/ex/jira/${DEFAULT_CLOUD_ID}/rest/api/3/issue/`); + + const wrongSite = await api(t.app, "/ex/jira/not-a-site/rest/api/3/myself", { auth: bearer }); + expect(wrongSite.status).toBe(404); + }); + + it("rejects revoked, expired, and refresh tokens used as access tokens", async () => { + const tokens = ( + await exchange({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: await authorize(), + redirect_uri: REDIRECT, + }) + ).json; + expect((await api(t.app, "/rest/api/3/myself", { auth: `Bearer ${tokens.refresh_token}` })).status).toBe(401); + + const js = getJiraStore(t.store); + const record = js.oauthTokens.findOneBy("token", tokens.access_token)!; + js.oauthTokens.update(record.id, { expires_at: new Date(Date.now() - 1000).toISOString() }); + expect((await api(t.app, "/rest/api/3/myself", { auth: `Bearer ${tokens.access_token}` })).status).toBe(401); + }); + + it("enforces OAuth scopes when strict scopes are enabled", async () => { + t.store.setData("jira.strict_scopes", true); + const { access_token } = ( + await exchange({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: await authorize("read:jira-work"), + redirect_uri: REDIRECT, + }) + ).json; + const bearer = `Bearer ${access_token}`; + expect((await api(t.app, "/rest/api/3/issue/EMU-1", { auth: bearer })).status).toBe(200); + const write = await api(t.app, "/rest/api/3/issue/EMU-1", { + method: "PUT", + auth: bearer, + body: { fields: { summary: "nope" } }, + }); + expect(write.status).toBe(401); + + // API tokens carry the user's full permissions and are not scope limited. + expect( + (await api(t.app, "/rest/api/3/issue/EMU-1", { method: "PUT", body: { fields: { summary: "ok" } } })).status, + ).toBe(204); + }); +}); diff --git a/packages/@emulators/jira/src/__tests__/platform.test.ts b/packages/@emulators/jira/src/__tests__/platform.test.ts new file mode 100644 index 00000000..84117095 --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/platform.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_ADMIN_EMAIL, DEFAULT_API_TOKEN } from "../index.js"; +import { api, basicAuth, createJiraTestApp, jiraTestBaseUrl, type JiraTestApp } from "./helpers.js"; + +describe("Jira platform basics", () => { + let t: JiraTestApp; + + beforeEach(() => { + t = createJiraTestApp(); + }); + + it("serves serverInfo without authentication", async () => { + const res = await api(t.app, "/rest/api/3/serverInfo", { auth: null }); + expect(res.status).toBe(200); + expect(res.json.baseUrl).toBe(jiraTestBaseUrl); + expect(res.json.deploymentType).toBe("Cloud"); + expect(Array.isArray(res.json.versionNumbers)).toBe(true); + }); + + it("rejects unauthenticated REST calls with a Jira error body", async () => { + const res = await api(t.app, "/rest/api/3/myself", { auth: null }); + expect(res.status).toBe(401); + expect(res.json.errorMessages).toEqual(["Client must be authenticated to access this resource."]); + expect(res.json.errors).toEqual({}); + }); + + it("rejects basic auth with a wrong API token", async () => { + const res = await api(t.app, "/rest/api/3/myself", { auth: basicAuth(DEFAULT_ADMIN_EMAIL, "wrong") }); + expect(res.status).toBe(401); + }); + + it("returns the current user for basic auth with email and API token", async () => { + const res = await api(t.app, "/rest/api/3/myself"); + expect(res.status).toBe(200); + expect(res.json.emailAddress).toBe(DEFAULT_ADMIN_EMAIL); + expect(res.json.accountType).toBe("atlassian"); + expect(res.json.active).toBe(true); + expect(typeof res.json.accountId).toBe("string"); + expect(res.json.self).toBe(`${jiraTestBaseUrl}/rest/api/3/user?accountId=${res.json.accountId}`); + expect(res.json.avatarUrls["48x48"]).toBeTruthy(); + }); + + it("accepts the API token as a bearer token", async () => { + const res = await api(t.app, "/rest/api/3/myself", { auth: `Bearer ${DEFAULT_API_TOKEN}` }); + expect(res.status).toBe(200); + expect(res.json.emailAddress).toBe(DEFAULT_ADMIN_EMAIL); + }); + + it("serves the same resources under REST API v2", async () => { + const res = await api(t.app, "/rest/api/2/myself"); + expect(res.status).toBe(200); + expect(res.json.self).toContain("/rest/api/2/user?accountId="); + }); + + it("returns Jira shaped 404s for unknown REST routes", async () => { + const res = await api(t.app, "/rest/api/3/does-not-exist"); + expect(res.status).toBe(404); + expect(res.json.errorMessages.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/@emulators/jira/src/__tests__/seed-inspector.test.ts b/packages/@emulators/jira/src/__tests__/seed-inspector.test.ts new file mode 100644 index 00000000..c02e7310 --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/seed-inspector.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { getJiraStore, seedFromConfig } from "../index.js"; +import { api, basicAuth, createJiraTestApp, jiraTestBaseUrl, type JiraTestApp } from "./helpers.js"; + +describe("seedFromConfig", () => { + let t: JiraTestApp; + + beforeEach(() => { + t = createJiraTestApp(); + seedFromConfig(t.store, jiraTestBaseUrl, { + site: { name: "acme" }, + users: [ + { email: "alice@example.com", display_name: "Alice", admin: true, api_token: "alice_token" }, + { email: "bob@example.com", display_name: "Bob" }, + ], + statuses: [{ name: "In Review", category: "indeterminate" }], + custom_fields: [{ id: "customfield_10050", name: "Team", type: "option", options: ["Red", "Blue"] }], + projects: [ + { + key: "WEB", + name: "Website", + lead: "alice@example.com", + statuses: ["To Do", "In Progress", "In Review", "Done"], + components: ["Frontend"], + versions: [{ name: "2.0", released: false }], + }, + ], + sprints: [{ project: "WEB", name: "WEB Sprint 1", state: "active" }], + issues: [ + { + project: "WEB", + summary: "Build landing page", + type: "Story", + description: "Hero and pricing", + status: "In Review", + priority: "High", + assignee: "bob@example.com", + reporter: "alice@example.com", + labels: ["marketing"], + components: ["Frontend"], + fix_versions: ["2.0"], + sprint: "WEB Sprint 1", + custom_fields: { customfield_10050: "Red" }, + comments: [{ body: "Looks great", author: "alice@example.com" }], + }, + { project: "WEB", summary: "Fix footer", type: "Bug", status: "Done", parent: undefined }, + { project: "WEB", summary: "Footer subtask", type: "Subtask", parent: "Fix footer" }, + ], + links: [{ type: "Relates", inward: "Build landing page", outward: "Fix footer" }], + webhooks: [ + { name: "Seeded", url: "https://example.test/hook", events: ["jira:issue_created"], jql: "project = WEB" }, + ], + oauth_apps: [ + { client_id: "acme_app", client_secret: "acme_secret", name: "Acme", redirect_uris: ["http://localhost/cb"] }, + ], + }); + }); + + it("creates users with API tokens that can authenticate", async () => { + const res = await api(t.app, "/rest/api/3/myself", { auth: basicAuth("alice@example.com", "alice_token") }); + expect(res.status).toBe(200); + expect(res.json.displayName).toBe("Alice"); + }); + + it("creates projects with custom workflows, components, versions, and a board", async () => { + const project = await api(t.app, "/rest/api/3/project/WEB"); + expect(project.json.lead.emailAddress).toBe("alice@example.com"); + expect(project.json.components.map((c: any) => c.name)).toEqual(["Frontend"]); + expect(project.json.versions.map((v: any) => v.name)).toEqual(["2.0"]); + const statuses = await api(t.app, "/rest/api/3/project/WEB/statuses"); + expect(statuses.json[0].statuses.map((s: any) => s.name)).toEqual(["To Do", "In Progress", "In Review", "Done"]); + const boards = await api(t.app, "/rest/agile/1.0/board?projectKeyOrId=WEB"); + expect(boards.json.values[0].name).toBe("WEB board"); + }); + + it("creates issues with every seeded relation", async () => { + const issue = (await api(t.app, "/rest/api/3/issue/WEB-1")).json; + expect(issue.fields.summary).toBe("Build landing page"); + expect(issue.fields.issuetype.name).toBe("Story"); + expect(issue.fields.status.name).toBe("In Review"); + expect(issue.fields.priority.name).toBe("High"); + expect(issue.fields.assignee.emailAddress).toBe("bob@example.com"); + expect(issue.fields.reporter.emailAddress).toBe("alice@example.com"); + expect(issue.fields.description.content[0].content[0].text).toBe("Hero and pricing"); + expect(issue.fields.components[0].name).toBe("Frontend"); + expect(issue.fields.fixVersions[0].name).toBe("2.0"); + expect(issue.fields.customfield_10020[0].name).toBe("WEB Sprint 1"); + expect(issue.fields.customfield_10050.value).toBe("Red"); + expect(issue.fields.comment.comments[0].author.emailAddress).toBe("alice@example.com"); + expect(issue.fields.issuelinks[0].inwardIssue.key).toBe("WEB-2"); + + const done = (await api(t.app, "/rest/api/3/issue/WEB-2")).json; + expect(done.fields.status.name).toBe("Done"); + expect(done.fields.resolution.name).toBe("Done"); + expect(done.fields.subtasks[0].key).toBe("WEB-3"); + }); + + it("is idempotent", async () => { + seedFromConfig(t.store, jiraTestBaseUrl, { + projects: [{ key: "WEB", name: "Website" }], + issues: [{ project: "WEB", summary: "Build landing page" }], + }); + const js = getJiraStore(t.store); + expect(js.projects.all().filter((p) => p.key === "WEB")).toHaveLength(1); + expect(js.issues.all().filter((i) => i.summary === "Build landing page")).toHaveLength(1); + }); + + it("seeds webhooks, OAuth apps, and site name", async () => { + const js = getJiraStore(t.store); + expect(js.webhooks.all()[0]).toMatchObject({ name: "Seeded", jql_filter: "project = WEB", kind: "admin" }); + expect(js.oauthApps.findOneBy("client_id", "acme_app")?.name).toBe("Acme"); + expect(js.sites.all()[0].name).toBe("acme"); + }); + + it("seeds issues into closed sprints as sprint history", async () => { + seedFromConfig(t.store, jiraTestBaseUrl, { + sprints: [{ project: "WEB", name: "WEB Sprint 0", state: "closed" }], + issues: [{ project: "WEB", summary: "Old work", status: "Done", sprint: "WEB Sprint 0" }], + }); + const search = await api(t.app, "/rest/api/3/search/jql", { + method: "POST", + body: { jql: 'sprint = "WEB Sprint 0"', fields: ["customfield_10020"] }, + }); + expect(search.json.issues).toHaveLength(1); + expect(search.json.issues[0].fields.customfield_10020[0].state).toBe("closed"); + }); + + it("lets config toggle strict scopes", () => { + seedFromConfig(t.store, jiraTestBaseUrl, { strict_scopes: true }); + expect(t.store.getData("jira.strict_scopes")).toBe(true); + }); +}); + +describe("inspector and browse pages", () => { + let t: JiraTestApp; + + beforeEach(() => { + t = createJiraTestApp(); + }); + + it("renders the inspector with issue, project, and auth tabs", async () => { + const res = await t.app.request(`${jiraTestBaseUrl}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Jira Inspector"); + expect(html).toContain("EMU-1"); + expect(html).toContain("Ship Jira emulator"); + + for (const tab of ["projects", "boards", "users", "webhooks", "auth"]) { + const page = await t.app.request(`${jiraTestBaseUrl}/?tab=${tab}`); + expect(page.status).toBe(200); + } + const auth = await (await t.app.request(`${jiraTestBaseUrl}/?tab=auth`)).text(); + expect(auth).toContain("admin@jira.local"); + expect(auth).toContain("jira_example_client_id"); + expect(auth).not.toContain("jira_test_token"); + expect(auth).not.toContain("example_client_secret"); + }); + + it("escapes user content in the inspector", async () => { + await api(t.app, "/rest/api/3/issue/EMU-1", { + method: "PUT", + body: { fields: { summary: "" } }, + }); + const html = await (await t.app.request(`${jiraTestBaseUrl}/`)).text(); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + }); + + it("renders an issue page at /browse/:key", async () => { + const res = await t.app.request(`${jiraTestBaseUrl}/browse/EMU-1`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Ship Jira emulator"); + expect(html).toContain("To Do"); + expect((await t.app.request(`${jiraTestBaseUrl}/browse/EMU-404`)).status).toBe(404); + }); +}); diff --git a/packages/@emulators/jira/src/__tests__/subresources.test.ts b/packages/@emulators/jira/src/__tests__/subresources.test.ts new file mode 100644 index 00000000..b50e0930 --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/subresources.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_DEV_API_TOKEN, DEFAULT_DEV_EMAIL, getJiraStore } from "../index.js"; +import { adf, api, basicAuth, createJiraTestApp, type JiraTestApp } from "./helpers.js"; + +describe("Jira issue sub-resources", () => { + let t: JiraTestApp; + + beforeEach(() => { + t = createJiraTestApp(); + }); + + describe("comments", () => { + it("adds, lists, reads, updates, and deletes comments", async () => { + const created = await api(t.app, "/rest/api/3/issue/EMU-1/comment", { + method: "POST", + body: { body: adf("First!") }, + }); + expect(created.status).toBe(201); + expect(created.json.body).toEqual(adf("First!")); + expect(created.json.author.emailAddress).toBe("admin@jira.local"); + const id = created.json.id; + + const list = await api(t.app, "/rest/api/3/issue/EMU-1/comment?orderBy=-created"); + expect(list.status).toBe(200); + expect(list.json.total).toBe(2); + expect(list.json.comments[0].id).toBe(id); + + const paged = await api(t.app, "/rest/api/3/issue/EMU-1/comment?startAt=1&maxResults=1"); + expect(paged.json.comments).toHaveLength(1); + expect(paged.json.startAt).toBe(1); + + const one = await api(t.app, `/rest/api/3/issue/EMU-1/comment/${id}`); + expect(one.json.id).toBe(id); + + const updated = await api(t.app, `/rest/api/3/issue/EMU-1/comment/${id}`, { + method: "PUT", + body: { body: adf("Edited") }, + }); + expect(updated.status).toBe(200); + expect(updated.json.body).toEqual(adf("Edited")); + + const deleted = await api(t.app, `/rest/api/3/issue/EMU-1/comment/${id}`, { method: "DELETE" }); + expect(deleted.status).toBe(204); + expect((await api(t.app, `/rest/api/3/issue/EMU-1/comment/${id}`)).status).toBe(404); + }); + + it("requires ADF comment bodies in v3 and accepts strings in v2", async () => { + const bad = await api(t.app, "/rest/api/3/issue/EMU-1/comment", { method: "POST", body: { body: "plain" } }); + expect(bad.status).toBe(400); + + const ok = await api(t.app, "/rest/api/2/issue/EMU-1/comment", { method: "POST", body: { body: "plain" } }); + expect(ok.status).toBe(201); + expect(ok.json.body).toBe("plain"); + }); + + it("only lets authors or admins edit comments", async () => { + const created = await api(t.app, "/rest/api/3/issue/EMU-1/comment", { + method: "POST", + body: { body: adf("Admin comment") }, + }); + const res = await api(t.app, `/rest/api/3/issue/EMU-1/comment/${created.json.id}`, { + method: "PUT", + auth: basicAuth(DEFAULT_DEV_EMAIL, DEFAULT_DEV_API_TOKEN), + body: { body: adf("Hijack") }, + }); + expect(res.status).toBe(403); + }); + + it("fetches comments across issues by id list", async () => { + const a = await api(t.app, "/rest/api/3/issue/EMU-1/comment", { method: "POST", body: { body: adf("a") } }); + const res = await api(t.app, "/rest/api/3/comment/list", { method: "POST", body: { ids: [Number(a.json.id)] } }); + expect(res.status).toBe(200); + expect(res.json.values.map((c: any) => c.id)).toEqual([a.json.id]); + }); + }); + + describe("watchers", () => { + it("adds and removes watchers", async () => { + const dev = getJiraStore(t.store).users.findOneBy("email", DEFAULT_DEV_EMAIL)!; + const initial = await api(t.app, "/rest/api/3/issue/EMU-1/watchers"); + expect(initial.status).toBe(200); + expect(initial.json.watchCount).toBe(1); + expect(initial.json.isWatching).toBe(true); + + const add = await api(t.app, "/rest/api/3/issue/EMU-1/watchers", { + method: "POST", + body: JSON.stringify(dev.account_id), + }); + expect(add.status).toBe(204); + const after = await api(t.app, "/rest/api/3/issue/EMU-1/watchers"); + expect(after.json.watchers.map((w: any) => w.accountId)).toContain(dev.account_id); + + const remove = await api(t.app, `/rest/api/3/issue/EMU-1/watchers?accountId=${dev.account_id}`, { + method: "DELETE", + }); + expect(remove.status).toBe(204); + expect((await api(t.app, "/rest/api/3/issue/EMU-1/watchers")).json.watchCount).toBe(1); + }); + + it("adds the current user when the body is empty", async () => { + const devAuth = basicAuth(DEFAULT_DEV_EMAIL, DEFAULT_DEV_API_TOKEN); + const res = await api(t.app, "/rest/api/3/issue/EMU-1/watchers", { method: "POST", auth: devAuth }); + expect(res.status).toBe(204); + const watchers = await api(t.app, "/rest/api/3/issue/EMU-1/watchers", { auth: devAuth }); + expect(watchers.json.isWatching).toBe(true); + }); + }); + + describe("worklogs", () => { + it("logs work, parses durations, and totals time spent", async () => { + const created = await api(t.app, "/rest/api/3/issue/EMU-1/worklog", { + method: "POST", + body: { timeSpent: "1h 30m", started: "2026-09-30T09:00:00.000+0000", comment: adf("Pairing") }, + }); + expect(created.status).toBe(201); + expect(created.json.timeSpentSeconds).toBe(5400); + expect(created.json.timeSpent).toBe("1h 30m"); + + await api(t.app, "/rest/api/3/issue/EMU-1/worklog", { method: "POST", body: { timeSpentSeconds: 1800 } }); + + const list = await api(t.app, "/rest/api/3/issue/EMU-1/worklog"); + expect(list.json.total).toBe(2); + + const issue = await api(t.app, "/rest/api/3/issue/EMU-1"); + expect(issue.json.fields.timespent).toBe(7200); + + const updated = await api(t.app, `/rest/api/3/issue/EMU-1/worklog/${created.json.id}`, { + method: "PUT", + body: { timeSpent: "2h" }, + }); + expect(updated.json.timeSpentSeconds).toBe(7200); + + const deleted = await api(t.app, `/rest/api/3/issue/EMU-1/worklog/${created.json.id}`, { method: "DELETE" }); + expect(deleted.status).toBe(204); + }); + + it("rejects worklogs without a valid duration", async () => { + const res = await api(t.app, "/rest/api/3/issue/EMU-1/worklog", { method: "POST", body: { timeSpent: "soon" } }); + expect(res.status).toBe(400); + expect(res.json.errors.timeLogged).toBeTruthy(); + }); + }); + + describe("issue links", () => { + it("links issues and shows the link on both sides", async () => { + const other = await api(t.app, "/rest/api/3/issue", { + method: "POST", + body: { fields: { project: { key: "EMU" }, issuetype: { name: "Bug" }, summary: "Blocker" } }, + }); + const link = await api(t.app, "/rest/api/3/issueLink", { + method: "POST", + body: { type: { name: "Blocks" }, inwardIssue: { key: "EMU-1" }, outwardIssue: { key: other.json.key } }, + }); + expect(link.status).toBe(201); + + // outwardIssue is the source: the blocker blocks EMU-1, so EMU-1 "is blocked by" it. + const inward = (await api(t.app, "/rest/api/3/issue/EMU-1")).json.fields.issuelinks; + expect(inward).toHaveLength(1); + expect(inward[0].type.name).toBe("Blocks"); + expect(inward[0].inwardIssue.key).toBe(other.json.key); + + const outward = (await api(t.app, `/rest/api/3/issue/${other.json.key}`)).json.fields.issuelinks; + expect(outward[0].outwardIssue.key).toBe("EMU-1"); + + const fetched = await api(t.app, `/rest/api/3/issueLink/${inward[0].id}`); + expect(fetched.json.inwardIssue.key).toBe("EMU-1"); + expect(fetched.json.outwardIssue.key).toBe(other.json.key); + + const deleted = await api(t.app, `/rest/api/3/issueLink/${inward[0].id}`, { method: "DELETE" }); + expect(deleted.status).toBe(204); + expect((await api(t.app, "/rest/api/3/issue/EMU-1")).json.fields.issuelinks).toHaveLength(0); + }); + + it("adds the optional link comment to the outward (from) issue", async () => { + const other = await api(t.app, "/rest/api/3/issue", { + method: "POST", + body: { fields: { project: { key: "EMU" }, issuetype: { name: "Bug" }, summary: "Source" } }, + }); + await api(t.app, "/rest/api/3/issueLink", { + method: "POST", + body: { + type: { name: "Blocks" }, + inwardIssue: { key: "EMU-1" }, + outwardIssue: { key: other.json.key }, + comment: { body: adf("Linking these") }, + }, + }); + expect((await api(t.app, `/rest/api/3/issue/${other.json.key}/comment`)).json.total).toBe(1); + expect((await api(t.app, "/rest/api/3/issue/EMU-1/comment")).json.total).toBe(1); + }); + + it("keeps JQL linkedIssues consistent with the issue view", async () => { + const other = await api(t.app, "/rest/api/3/issue", { + method: "POST", + body: { fields: { project: { key: "EMU" }, issuetype: { name: "Bug" }, summary: "Blocker" } }, + }); + await api(t.app, "/rest/api/3/issue/EMU-1", { + method: "PUT", + body: { + update: { issuelinks: [{ add: { type: { name: "Blocks" }, outwardIssue: { key: other.json.key } } }] }, + }, + }); + // EMU-1 now shows the other issue as outwardIssue, which reads "EMU-1 blocks ". + const search = async (jql: string) => + (await api(t.app, "/rest/api/3/search/jql", { method: "POST", body: { jql } })).json.issues.map( + (i: any) => i.key, + ); + expect(await search('issue in linkedIssues(EMU-1, "blocks")')).toEqual([other.json.key]); + expect(await search('issue in linkedIssues(EMU-1, "is blocked by")')).toEqual([]); + expect(await search(`issue in linkedIssues(${other.json.key}, "is blocked by")`)).toEqual(["EMU-1"]); + }); + + it("validates link type and issues", async () => { + const badType = await api(t.app, "/rest/api/3/issueLink", { + method: "POST", + body: { type: { name: "Nope" }, inwardIssue: { key: "EMU-1" }, outwardIssue: { key: "EMU-1" } }, + }); + expect(badType.status).toBe(404); + const badIssue = await api(t.app, "/rest/api/3/issueLink", { + method: "POST", + body: { type: { name: "Blocks" }, inwardIssue: { key: "EMU-1" }, outwardIssue: { key: "EMU-404" } }, + }); + expect(badIssue.status).toBe(404); + }); + + it("links issues through the update operation on edit", async () => { + const other = await api(t.app, "/rest/api/3/issue", { + method: "POST", + body: { fields: { project: { key: "EMU" }, issuetype: { name: "Task" }, summary: "Related" } }, + }); + const res = await api(t.app, "/rest/api/3/issue/EMU-1", { + method: "PUT", + body: { + update: { issuelinks: [{ add: { type: { name: "Relates" }, outwardIssue: { key: other.json.key } } }] }, + }, + }); + expect(res.status).toBe(204); + const links = (await api(t.app, "/rest/api/3/issue/EMU-1")).json.fields.issuelinks; + expect(links[0].outwardIssue.key).toBe(other.json.key); + }); + }); +}); diff --git a/packages/@emulators/jira/src/__tests__/webhooks.test.ts b/packages/@emulators/jira/src/__tests__/webhooks.test.ts new file mode 100644 index 00000000..07f6b471 --- /dev/null +++ b/packages/@emulators/jira/src/__tests__/webhooks.test.ts @@ -0,0 +1,289 @@ +import { createHmac } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getJiraStore } from "../index.js"; +import { adf, api, createJiraTestApp, type JiraTestApp } from "./helpers.js"; + +interface Captured { + url: string; + headers: Record; + body: string; + json: any; +} + +describe("Jira webhooks", () => { + let t: JiraTestApp; + let originalFetch: typeof fetch; + let captured: Captured[]; + + beforeEach(() => { + t = createJiraTestApp(); + originalFetch = globalThis.fetch; + captured = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = String(init?.body ?? ""); + captured.push({ + url, + headers: Object.fromEntries(new Headers(init?.headers).entries()), + body, + json: body ? JSON.parse(body) : undefined, + }); + return new Response("ok", { status: 200 }); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + const register = (body: Record) => + api(t.app, "/rest/webhooks/1.0/webhook", { method: "POST", body }); + + const createIssue = (summary: string, project = "EMU") => + api(t.app, "/rest/api/3/issue", { + method: "POST", + body: { fields: { project: { key: project }, issuetype: { name: "Task" }, summary } }, + }); + + it("manages admin webhooks", async () => { + const created = await register({ + name: "My hook", + url: "https://example.test/hook", + events: ["jira:issue_created"], + filters: { "issue-related-events-section": "project = EMU" }, + }); + expect(created.status).toBe(201); + expect(created.json.name).toBe("My hook"); + expect(created.json.enabled).toBe(true); + expect(created.json.self).toMatch(/\/rest\/webhooks\/1\.0\/webhook\/\d+$/); + const id = created.json.self.split("/").pop(); + + const list = await api(t.app, "/rest/webhooks/1.0/webhook"); + expect(list.json).toHaveLength(1); + + const updated = await api(t.app, `/rest/webhooks/1.0/webhook/${id}`, { + method: "PUT", + body: { name: "Renamed", url: "https://example.test/hook2", events: ["jira:issue_updated"], enabled: false }, + }); + expect(updated.json.name).toBe("Renamed"); + expect(updated.json.enabled).toBe(false); + + const deleted = await api(t.app, `/rest/webhooks/1.0/webhook/${id}`, { method: "DELETE" }); + expect(deleted.status).toBe(204); + expect((await api(t.app, `/rest/webhooks/1.0/webhook/${id}`)).status).toBe(404); + }); + + it("validates webhook registrations", async () => { + const noUrl = await register({ name: "x", events: ["jira:issue_created"] }); + expect(noUrl.status).toBe(400); + const badEvent = await register({ name: "x", url: "https://example.test", events: ["jira:bogus"] }); + expect(badEvent.status).toBe(400); + const badJql = await register({ + name: "x", + url: "https://example.test", + events: ["jira:issue_created"], + filters: { "issue-related-events-section": "project = NOPE" }, + }); + expect(badJql.status).toBe(400); + }); + + it("delivers signed issue_created payloads that match the JQL filter", async () => { + await register({ + name: "Signed", + url: "https://example.test/hook", + events: ["jira:issue_created"], + filters: { "issue-related-events-section": "project = EMU" }, + secret: "s3cret", + }); + await api(t.app, "/rest/api/3/project", { + method: "POST", + body: { key: "OPS", name: "Ops", projectTypeKey: "software" }, + }); + await createIssue("Ignored", "OPS"); + expect(captured).toHaveLength(0); + + const res = await createIssue("Delivered"); + expect(captured).toHaveLength(1); + const delivery = captured[0]; + expect(delivery.url).toBe("https://example.test/hook"); + expect(delivery.json.webhookEvent).toBe("jira:issue_created"); + expect(delivery.json.issue_event_type_name).toBe("issue_created"); + expect(delivery.json.issue.key).toBe(res.json.key); + expect(delivery.json.issue.fields.summary).toBe("Delivered"); + expect(delivery.json.user.emailAddress).toBe("admin@jira.local"); + expect(typeof delivery.json.timestamp).toBe("number"); + const expected = createHmac("sha256", "s3cret").update(delivery.body).digest("hex"); + expect(delivery.headers["x-hub-signature"]).toBe(`sha256=${expected}`); + expect(delivery.headers["x-atlassian-webhook-identifier"]).toBeTruthy(); + + const stored = getJiraStore(t.store).webhookDeliveries.all(); + expect(stored).toHaveLength(1); + expect(stored[0].status).toBe(200); + }); + + it("delivers issue_updated with changelog and transition event types", async () => { + await register({ name: "Updates", url: "https://example.test/u", events: ["jira:issue_updated"] }); + + await api(t.app, "/rest/api/3/issue/EMU-1", { method: "PUT", body: { fields: { summary: "Changed" } } }); + expect(captured).toHaveLength(1); + expect(captured[0].json.issue_event_type_name).toBe("issue_updated"); + expect(captured[0].json.changelog.items[0]).toMatchObject({ field: "summary", toString: "Changed" }); + + const done = (await api(t.app, "/rest/api/3/issue/EMU-1/transitions")).json.transitions.find( + (tr: any) => tr.name === "Done", + ); + await api(t.app, "/rest/api/3/issue/EMU-1/transitions", { method: "POST", body: { transition: { id: done.id } } }); + expect(captured[1].json.issue_event_type_name).toBe("issue_generic"); + expect(captured[1].json.issue.fields.status.name).toBe("Done"); + + const me = (await api(t.app, "/rest/api/3/myself")).json; + await api(t.app, "/rest/api/3/issue/EMU-1/assignee", { method: "PUT", body: { accountId: me.accountId } }); + expect(captured[2].json.issue_event_type_name).toBe("issue_assigned"); + }); + + it("sends one issue_updated for an edit that changes a field and adds a comment", async () => { + await register({ + name: "Edits", + url: "https://example.test/e", + events: ["comment_created", "jira:issue_updated"], + }); + await api(t.app, "/rest/api/3/issue/EMU-1", { + method: "PUT", + body: { fields: { summary: "Changed" }, update: { comment: [{ add: { body: adf("Why") } }] } }, + }); + expect(captured.map((c) => c.json.webhookEvent)).toEqual(["comment_created", "jira:issue_updated"]); + expect(captured[1].json.issue_event_type_name).toBe("issue_updated"); + expect(captured[1].json.changelog.items[0]).toMatchObject({ field: "summary", toString: "Changed" }); + expect(captured[1].json.comment.body).toBe("Why"); + }); + + it("delivers comment events and the matching issue_commented update", async () => { + await register({ + name: "Comments", + url: "https://example.test/c", + events: ["comment_created", "comment_updated", "comment_deleted", "jira:issue_updated"], + }); + const created = await api(t.app, "/rest/api/3/issue/EMU-1/comment", { method: "POST", body: { body: adf("Hi") } }); + const events = captured.map((c) => c.json.webhookEvent); + expect(events).toEqual(["comment_created", "jira:issue_updated"]); + expect(captured[0].json.comment.id).toBe(created.json.id); + expect(captured[0].json.issue.key).toBe("EMU-1"); + expect(captured[1].json.issue_event_type_name).toBe("issue_commented"); + + await api(t.app, `/rest/api/3/issue/EMU-1/comment/${created.json.id}`, { + method: "PUT", + body: { body: adf("Edit") }, + }); + await api(t.app, `/rest/api/3/issue/EMU-1/comment/${created.json.id}`, { method: "DELETE" }); + expect(captured.map((c) => c.json.webhookEvent).slice(2)).toEqual(["comment_updated", "comment_deleted"]); + }); + + it("delivers issue_deleted and skips disabled webhooks", async () => { + await register({ name: "Deletes", url: "https://example.test/d", events: ["jira:issue_deleted"] }); + await register({ name: "Off", url: "https://example.test/off", events: ["jira:issue_deleted"], enabled: false }); + await api(t.app, "/rest/api/3/issue/EMU-1", { method: "DELETE" }); + expect(captured).toHaveLength(1); + expect(captured[0].json.webhookEvent).toBe("jira:issue_deleted"); + expect(captured[0].json.issue.key).toBe("EMU-1"); + }); + + it("omits the body when excludeBody is set", async () => { + await register({ + name: "NoBody", + url: "https://example.test/n", + events: ["jira:issue_created"], + excludeBody: true, + }); + await createIssue("x"); + expect(captured).toHaveLength(1); + expect(captured[0].body).toBe(""); + }); + + it("records failed deliveries", async () => { + globalThis.fetch = (async () => { + throw new Error("connection refused"); + }) as typeof fetch; + await register({ name: "Down", url: "https://example.test/down", events: ["jira:issue_created"] }); + const res = await createIssue("still created"); + expect(res.status).toBe(201); + const [delivery] = getJiraStore(t.store).webhookDeliveries.all(); + expect(delivery.status).toBeNull(); + expect(delivery.error).toContain("connection refused"); + }); + + describe("dynamic webhooks", () => { + const oauth = "Bearer jira_oauth_dynamic"; + + beforeEach(() => { + const js = getJiraStore(t.store); + const admin = js.users.findOneBy("email", "admin@jira.local")!; + js.oauthTokens.insert({ + token: "jira_oauth_dynamic", + type: "access", + account_id: admin.account_id, + client_id: "jira_example_client_id", + scopes: ["read:jira-work", "manage:jira-webhook"], + expires_at: null, + revoked: false, + }); + }); + + it("registers, lists, refreshes, and deletes webhooks for OAuth apps", async () => { + const created = await api(t.app, "/rest/api/3/webhook", { + method: "POST", + auth: oauth, + body: { + url: "https://app.test/webhook", + webhooks: [ + { events: ["jira:issue_created"], jqlFilter: "project = EMU" }, + { events: ["jira:bogus"], jqlFilter: "project = EMU" }, + ], + }, + }); + expect(created.status).toBe(200); + const [ok, failed] = created.json.webhookRegistrationResult; + expect(ok.createdWebhookId).toBeTruthy(); + expect(failed.errors.length).toBeGreaterThan(0); + + const list = await api(t.app, "/rest/api/3/webhook", { auth: oauth }); + expect(list.json.total).toBe(1); + expect(list.json.values[0]).toMatchObject({ + id: ok.createdWebhookId, + jqlFilter: "project = EMU", + events: ["jira:issue_created"], + }); + expect(list.json.values[0].expirationDate).toBeTruthy(); + + await createIssue("dynamic"); + expect(captured).toHaveLength(1); + expect(captured[0].url).toBe("https://app.test/webhook"); + expect(captured[0].json.matchedWebhookIds).toEqual([ok.createdWebhookId]); + + const refreshed = await api(t.app, "/rest/api/3/webhook/refresh", { + method: "PUT", + auth: oauth, + body: { webhookIds: [ok.createdWebhookId] }, + }); + expect(refreshed.json.expirationDate).toBeTruthy(); + + const deleted = await api(t.app, "/rest/api/3/webhook", { + method: "DELETE", + auth: oauth, + body: { webhookIds: [ok.createdWebhookId] }, + }); + expect(deleted.status).toBe(202); + expect((await api(t.app, "/rest/api/3/webhook", { auth: oauth })).json.total).toBe(0); + }); + + it("rejects dynamic webhook registration from API token users", async () => { + const res = await api(t.app, "/rest/api/3/webhook", { + method: "POST", + body: { + url: "https://app.test/webhook", + webhooks: [{ events: ["jira:issue_created"], jqlFilter: "project = EMU" }], + }, + }); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/packages/@emulators/jira/src/adf.ts b/packages/@emulators/jira/src/adf.ts new file mode 100644 index 00000000..7cb9712c --- /dev/null +++ b/packages/@emulators/jira/src/adf.ts @@ -0,0 +1,96 @@ +import { escapeHtml } from "@emulators/core"; +import type { AdfNode } from "./entities.js"; + +export function isAdfDoc(value: unknown): value is AdfNode { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const doc = value as AdfNode; + return doc.type === "doc" && doc.version === 1 && Array.isArray(doc.content); +} + +/** Plain text (one paragraph per line) to an ADF document. */ +export function textToAdf(text: string): AdfNode { + return { + type: "doc", + version: 1, + content: text.split("\n").map((line) => ({ + type: "paragraph", + content: line ? [{ type: "text", text: line }] : [], + })), + }; +} + +const TEXT_BLOCKS = new Set(["paragraph", "heading", "codeBlock"]); + +function inlineText(node: AdfNode): string { + switch (node.type) { + case "text": + return node.text ?? ""; + case "hardBreak": + return "\n"; + case "mention": + case "emoji": + return String(node.attrs?.text ?? node.attrs?.shortName ?? node.attrs?.id ?? ""); + case "inlineCard": + return String(node.attrs?.url ?? ""); + default: + return (node.content ?? []).map(inlineText).join(""); + } +} + +/** Flattens an ADF document to plain text, one line per paragraph, heading, or code block. */ +export function adfToText(node: AdfNode | null | undefined): string { + if (!node) return ""; + const lines: string[] = []; + const walk = (current: AdfNode): void => { + if (TEXT_BLOCKS.has(current.type)) lines.push(inlineText(current)); + else if (current.type === "text" || current.type === "hardBreak") lines.push(inlineText(current)); + else for (const child of current.content ?? []) walk(child); + }; + walk(node); + return lines.join("\n"); +} + +/** Minimal HTML rendering used for `renderedFields`. */ +export function adfToHtml(node: AdfNode | null | undefined): string { + if (!node) return ""; + const render = (current: AdfNode): string => { + const children = (current.content ?? []).map(render).join(""); + switch (current.type) { + case "doc": + return children; + case "paragraph": + return `

${children}

`; + case "heading": { + const level = Math.min(6, Math.max(1, Number(current.attrs?.level ?? 1))); + return `${children}`; + } + case "bulletList": + return `
    ${children}
`; + case "orderedList": + return `
    ${children}
`; + case "listItem": + return `
  • ${children}
  • `; + case "codeBlock": + return `
    ${children}
    `; + case "blockquote": + return `
    ${children}
    `; + case "rule": + return "
    "; + case "hardBreak": + return "
    "; + case "text": { + let text = escapeHtml(current.text ?? ""); + for (const mark of current.marks ?? []) { + if (mark.type === "strong") text = `${text}`; + else if (mark.type === "em") text = `${text}`; + else if (mark.type === "code") text = `${text}`; + else if (mark.type === "link") text = `${text}`; + } + return text; + } + default: + return children || escapeHtml(adfToText(current)); + } + }; + return render(node); +} diff --git a/packages/@emulators/jira/src/context.ts b/packages/@emulators/jira/src/context.ts new file mode 100644 index 00000000..fd7f7b7f --- /dev/null +++ b/packages/@emulators/jira/src/context.ts @@ -0,0 +1,190 @@ +import type { AppEnv, Context, Store } from "@emulators/core"; +import { getJiraStore, type JiraStore } from "./store.js"; +import type { JiraUser } from "./entities.js"; + +export const GATEWAY_HEADER = "x-emulate-jira-cloud-id"; + +/** Route prefix for the platform REST API, matching both v2 and v3. */ +export const API_V = "/rest/api/:v{[23]}"; + +export const READ = ["read:jira-work"]; +export const WRITE = ["write:jira-work"]; +export const MANAGE = ["manage:jira-project", "manage:jira-configuration"]; + +export class JiraError extends Error { + constructor( + public status: number, + public errorMessages: string[] = [], + public errors: Record = {}, + ) { + super(errorMessages[0] ?? Object.values(errors)[0] ?? "Jira error"); + this.name = "JiraError"; + } +} + +export const issueNotFound = () => + new JiraError(404, ["Issue does not exist or you do not have permission to see it."]); + +export const fieldError = (field: string, message: string) => new JiraError(400, [], { [field]: message }); + +export interface JiraRequest { + c: Context; + store: Store; + js: JiraStore; + version: "2" | "3"; + /** Root used for `self` links: the base URL, or the api.atlassian.com style gateway prefix. */ + siteUrl: string; + baseUrl: string; + user: JiraUser; + /** OAuth scopes granted to the token, or null for API tokens with the user's full permissions. */ + scopes: string[] | null; + /** OAuth client that owns the access token, when the request used OAuth. */ + clientId: string | null; +} + +/** Request for an endpoint that also serves anonymous callers, so there may be no user. */ +export interface PublicJiraRequest extends Omit { + user: JiraUser | null; + scopes: string[] | null; +} + +export interface HandlerOptions { + /** Defaults to true. When false, anonymous requests are allowed and the handler gets a `PublicJiraRequest`. */ + auth?: boolean; + /** OAuth scopes required when strict scope checking is enabled. Any one of them is enough. */ + scopes?: string[]; +} + +type JiraHandler = (r: JiraRequest) => Response | Promise; +type PublicJiraHandler = (r: PublicJiraRequest) => Response | Promise; +type RouteHandler = (c: Context) => Promise; + +export function jiraErrorResponse(c: Context, err: JiraError): Response { + return c.json({ errorMessages: err.errorMessages, errors: err.errors }, err.status); +} + +export function makeHandler(store: Store, baseUrl: string) { + function handle(fn: PublicJiraHandler, opts: HandlerOptions & { auth: false }): RouteHandler; + function handle(fn: JiraHandler, opts?: HandlerOptions): RouteHandler; + function handle(fn: JiraHandler | PublicJiraHandler, opts: HandlerOptions = {}) { + return async (c: Context): Promise => { + try { + const js = getJiraStore(store); + const version = c.req.param("v") === "2" ? "2" : "3"; + const gatewayCloudId = c.req.header(GATEWAY_HEADER); + const siteUrl = gatewayCloudId ? `${baseUrl}/ex/jira/${gatewayCloudId}` : baseUrl; + let user: JiraUser | undefined; + let scopes: string[] | null = null; + let clientId: string | null = null; + const auth = authenticate(c, js); + if (auth) { + user = auth.user; + scopes = auth.scopes; + clientId = auth.clientId; + } else if (opts.auth !== false) { + throw new JiraError(401, ["Client must be authenticated to access this resource."]); + } + if (opts.scopes && scopes && store.getData("jira.strict_scopes")) { + if (!opts.scopes.some((scope) => scopes!.includes(scope))) { + throw new JiraError(401, [ + `Unauthorized; scope does not match. Required one of: ${opts.scopes.join(", ")}`, + ]); + } + } + const request = { c, store, js, version, siteUrl, baseUrl, scopes, clientId } as const; + if (opts.auth === false) return await (fn as PublicJiraHandler)({ ...request, user: user ?? null }); + // Authenticated routes threw above when there was no user. + return await (fn as JiraHandler)({ ...request, user: user! }); + } catch (err) { + if (err instanceof JiraError) return jiraErrorResponse(c, err); + throw err; + } + }; + } + return handle; +} + +interface AuthResult { + user: JiraUser; + scopes: string[] | null; + clientId: string | null; +} + +/** Returns the authenticated user, undefined when no credentials were sent, or throws 401 for bad credentials. */ +export function authenticate(c: Context, js: JiraStore): AuthResult | undefined { + const header = c.req.header("Authorization"); + if (!header) return undefined; + const unauthorized = () => new JiraError(401, ["Client must be authenticated to access this resource."]); + + const basic = /^Basic\s+(.+)$/i.exec(header); + if (basic) { + const decoded = Buffer.from(basic[1].trim(), "base64").toString("utf-8"); + const sep = decoded.indexOf(":"); + if (sep < 0) throw unauthorized(); + const email = decoded.slice(0, sep).toLowerCase(); + const secret = decoded.slice(sep + 1); + const user = js.users.all().find((u) => u.email.toLowerCase() === email); + if (!user || !user.active) throw unauthorized(); + const valid = js.apiTokens.findBy("account_id", user.account_id).some((t) => t.token === secret); + if (!valid) throw unauthorized(); + return { user, scopes: null, clientId: null }; + } + + const bearer = /^Bearer\s+(.+)$/i.exec(header); + if (bearer) { + const value = bearer[1].trim(); + const apiToken = js.apiTokens.findOneBy("token", value); + if (apiToken) { + const user = js.users.findOneBy("account_id", apiToken.account_id); + if (user?.active) return { user, scopes: null, clientId: null }; + throw unauthorized(); + } + const oauth = js.oauthTokens.findOneBy("token", value); + if (!oauth || oauth.type !== "access" || oauth.revoked) throw unauthorized(); + if (oauth.expires_at && new Date(oauth.expires_at).getTime() <= Date.now()) throw unauthorized(); + const user = js.users.findOneBy("account_id", oauth.account_id); + if (!user?.active) throw unauthorized(); + return { user, scopes: oauth.scopes, clientId: oauth.client_id }; + } + + throw unauthorized(); +} + +export async function readJson(c: Context): Promise { + const text = await c.req.text(); + if (!text.trim()) return {}; + try { + return JSON.parse(text); + } catch (err) { + const detail = err instanceof Error ? err.message : "Invalid JSON"; + throw new JiraError(400, [`Unexpected character in request body: ${detail}`]); + } +} + +export function intParam(value: string | undefined, fallback: number, max = Number.MAX_SAFE_INTEGER): number { + if (value === undefined || value === "") return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return Math.min(parsed, max); +} + +export interface PageParams { + startAt: number; + maxResults: number; +} + +/** Reads `startAt` and `maxResults` query params. `maxResults` defaults to `defaultMax` and is capped at `cap`. */ +export function pageParams(c: Context, defaultMax: number, cap?: number): PageParams { + return { + startAt: intParam(c.req.query("startAt"), 0), + maxResults: intParam(c.req.query("maxResults"), defaultMax, cap), + }; +} + +/** Splits `a,b` query values and repeated `?x=a&x=b` params into one list. */ +export function listParam(c: Context, name: string): string[] { + return (c.req.queries(name) ?? []) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean); +} diff --git a/packages/@emulators/jira/src/entities.ts b/packages/@emulators/jira/src/entities.ts new file mode 100644 index 00000000..ac0a9e34 --- /dev/null +++ b/packages/@emulators/jira/src/entities.ts @@ -0,0 +1,226 @@ +import type { Entity } from "@emulators/core"; + +export type JiraStatusCategory = "new" | "indeterminate" | "done"; +export type JiraAccountType = "atlassian" | "app" | "customer"; +export type JiraBoardType = "scrum" | "kanban"; +export type JiraSprintState = "future" | "active" | "closed"; +export type JiraCustomFieldType = "string" | "number" | "date" | "datetime" | "option" | "array" | "user" | "sprint"; + +/** Atlassian Document Format node. Kept loose because ADF is an open tree. */ +export interface AdfNode { + type: string; + version?: number; + text?: string; + attrs?: Record; + marks?: Array<{ type: string; attrs?: Record }>; + content?: AdfNode[]; +} + +export interface JiraSite extends Entity { + cloud_id: string; + name: string; +} + +export interface JiraUser extends Entity { + account_id: string; + email: string; + display_name: string; + active: boolean; + account_type: JiraAccountType; + time_zone: string; + locale: string; + admin: boolean; +} + +export interface JiraApiToken extends Entity { + token: string; + account_id: string; + label: string; +} + +export interface JiraProject extends Entity { + key: string; + name: string; + description: string; + lead_account_id: string | null; + project_type_key: "software" | "business" | "service_desk"; + issue_sequence: number; + status_ids: number[]; + issue_type_ids: number[]; +} + +export interface JiraIssueType extends Entity { + name: string; + description: string; + subtask: boolean; + hierarchy_level: number; +} + +export interface JiraStatus extends Entity { + name: string; + description: string; + category: JiraStatusCategory; +} + +export interface JiraPriority extends Entity { + name: string; + description: string; + color: string; +} + +export interface JiraResolution extends Entity { + name: string; + description: string; +} + +export interface JiraCustomField extends Entity { + field_id: string; + name: string; + type: JiraCustomFieldType; + options: string[]; +} + +export interface JiraIssue extends Entity { + key: string; + number: number; + project_id: number; + issue_type_id: number; + summary: string; + description: AdfNode | null; + environment: AdfNode | null; + status_id: number; + status_changed_at: string; + priority_id: number | null; + resolution_id: number | null; + resolution_date: string | null; + assignee_id: string | null; + reporter_id: string | null; + creator_id: string | null; + parent_id: number | null; + labels: string[]; + component_ids: number[]; + fix_version_ids: number[]; + due_date: string | null; + custom_fields: Record; + watcher_ids: string[]; + sprint_id: number | null; + closed_sprint_ids: number[]; +} + +export interface JiraComment extends Entity { + issue_id: number; + author_id: string | null; + update_author_id: string | null; + body: AdfNode; +} + +export interface JiraChangelogItem { + field: string; + fieldtype: "jira" | "custom"; + fieldId: string; + from: string | null; + fromString: string | null; + to: string | null; + toString: string | null; +} + +export interface JiraChangelog extends Entity { + issue_id: number; + author_id: string | null; + items: JiraChangelogItem[]; +} + +export interface JiraWorklog extends Entity { + issue_id: number; + author_id: string | null; + update_author_id: string | null; + comment: AdfNode | null; + started: string; + time_spent_seconds: number; +} + +export interface JiraIssueLinkType extends Entity { + name: string; + inward: string; + outward: string; +} + +export interface JiraIssueLink extends Entity { + type_id: number; + inward_issue_id: number; + outward_issue_id: number; +} + +export interface JiraComponent extends Entity { + project_id: number; + name: string; + description: string; + lead_account_id: string | null; +} + +export interface JiraVersion extends Entity { + project_id: number; + name: string; + description: string; + released: boolean; + archived: boolean; + start_date: string | null; + release_date: string | null; +} + +export interface JiraBoard extends Entity { + name: string; + type: JiraBoardType; + project_id: number; +} + +export interface JiraSprint extends Entity { + board_id: number; + name: string; + state: JiraSprintState; + goal: string; + start_date: string | null; + end_date: string | null; + complete_date: string | null; +} + +export interface JiraWebhook extends Entity { + name: string; + url: string; + events: string[]; + jql_filter: string | null; + enabled: boolean; + exclude_body: boolean; + secret: string | null; + kind: "admin" | "dynamic"; + client_id: string | null; + expiration_date: string | null; +} + +export interface JiraWebhookDelivery extends Entity { + webhook_id: number; + event: string; + url: string; + status: number | null; + error: string | null; + payload: unknown; + headers: Record; +} + +export interface JiraOAuthApp extends Entity { + client_id: string; + client_secret: string; + name: string; + redirect_uris: string[]; + scopes: string[]; +} + +export interface JiraOAuthToken extends Entity { + token: string; + type: "access" | "refresh"; + account_id: string; + client_id: string; + scopes: string[]; + expires_at: string | null; + revoked: boolean; +} diff --git a/packages/@emulators/jira/src/fields.ts b/packages/@emulators/jira/src/fields.ts new file mode 100644 index 00000000..5c8b347c --- /dev/null +++ b/packages/@emulators/jira/src/fields.ts @@ -0,0 +1,122 @@ +import type { JiraCustomField } from "./entities.js"; +import type { JiraStore } from "./store.js"; + +export interface FieldSchema { + type: string; + items?: string; + system?: string; + custom?: string; + customId?: number; +} + +export interface FieldDef { + id: string; + name: string; + custom: boolean; + orderable: boolean; + navigable: boolean; + searchable: boolean; + clauseNames: string[]; + schema: FieldSchema; +} + +function sys( + id: string, + name: string, + type: string, + clauseNames: string[] = [id], + extra: Partial & { items?: string } = {}, +): FieldDef { + const { items, ...rest } = extra; + return { + id, + name, + custom: false, + orderable: true, + navigable: true, + searchable: true, + clauseNames, + schema: { type, ...(items ? { items } : {}), system: id }, + ...rest, + }; +} + +export const SYSTEM_FIELDS: FieldDef[] = [ + sys("issuekey", "Key", "string", ["id", "issue", "issuekey", "key"], { orderable: false }), + sys("summary", "Summary", "string"), + sys("description", "Description", "string"), + sys("environment", "Environment", "string"), + sys("issuetype", "Issue Type", "issuetype", ["issuetype", "type"]), + sys("project", "Project", "project"), + sys("status", "Status", "status", ["status"], { orderable: false }), + sys("statusCategory", "Status Category", "statusCategory", ["statusCategory"], { orderable: false }), + sys("priority", "Priority", "priority"), + sys("resolution", "Resolution", "resolution"), + sys("resolutiondate", "Resolved", "datetime", ["resolutiondate", "resolved"], { orderable: false }), + sys("assignee", "Assignee", "user"), + sys("reporter", "Reporter", "user"), + sys("creator", "Creator", "user", ["creator"], { orderable: false }), + sys("labels", "Labels", "array", ["labels"], { items: "string" }), + sys("components", "Components", "array", ["component"], { items: "component" }), + sys("fixVersions", "Fix versions", "array", ["fixVersion"], { items: "version" }), + sys("parent", "Parent", "issuelink", ["parent"]), + sys("duedate", "Due date", "date", ["due", "duedate"]), + sys("created", "Created", "datetime", ["created", "createdDate"], { orderable: false }), + sys("updated", "Updated", "datetime", ["updated", "updatedDate"], { orderable: false }), + sys("statuscategorychangedate", "Status Category Changed", "datetime", ["statusCategoryChangedDate"], { + orderable: false, + }), + sys("comment", "Comment", "comments-page", ["comment"]), + sys("issuelinks", "Linked Issues", "array", ["issueLink"], { items: "issuelinks" }), + sys("subtasks", "Sub-tasks", "array", ["subtasks"], { items: "issuelinks", orderable: false }), + sys("watches", "Watchers", "watches", ["watcher", "watchers"], { orderable: false }), + sys("worklog", "Log Work", "array", ["worklogComment", "worklogDate"], { items: "worklog" }), + sys("timespent", "Time Spent", "number", ["timespent"], { orderable: false }), +]; + +const CUSTOM_TYPES: Record = { + string: { schema: { type: "string" }, custom: "com.atlassian.jira.plugin.system.customfieldtypes:textfield" }, + number: { schema: { type: "number" }, custom: "com.atlassian.jira.plugin.system.customfieldtypes:float" }, + date: { schema: { type: "date" }, custom: "com.atlassian.jira.plugin.system.customfieldtypes:datepicker" }, + datetime: { + schema: { type: "datetime" }, + custom: "com.atlassian.jira.plugin.system.customfieldtypes:datetime", + }, + option: { schema: { type: "option" }, custom: "com.atlassian.jira.plugin.system.customfieldtypes:select" }, + array: { + schema: { type: "array", items: "string" }, + custom: "com.atlassian.jira.plugin.system.customfieldtypes:labels", + }, + user: { schema: { type: "user" }, custom: "com.atlassian.jira.plugin.system.customfieldtypes:userpicker" }, + sprint: { schema: { type: "array", items: "json" }, custom: "com.pyxis.greenhopper.jira:gh-sprint" }, +}; + +export function customFieldDef(field: JiraCustomField): FieldDef { + const numericId = Number(field.field_id.replace("customfield_", "")); + const typeInfo = CUSTOM_TYPES[field.type]; + return { + id: field.field_id, + name: field.name, + custom: true, + orderable: true, + navigable: true, + searchable: true, + clauseNames: [`cf[${numericId}]`, field.name], + schema: { ...typeInfo.schema, custom: typeInfo.custom, customId: numericId }, + }; +} + +export function allFields(js: JiraStore): FieldDef[] { + return [...SYSTEM_FIELDS, ...js.customFields.all().map(customFieldDef)]; +} + +/** Resolves a field by id (`summary`, `customfield_10020`), display name, or JQL clause name (`cf[10020]`). */ +export function findField(js: JiraStore, ref: string): FieldDef | undefined { + const lower = ref.toLowerCase(); + return allFields(js).find( + (field) => + field.id.toLowerCase() === lower || + field.name.toLowerCase() === lower || + field.clauseNames.some((clause) => clause.toLowerCase() === lower), + ); +} diff --git a/packages/@emulators/jira/src/formatters.ts b/packages/@emulators/jira/src/formatters.ts new file mode 100644 index 00000000..ee1960a1 --- /dev/null +++ b/packages/@emulators/jira/src/formatters.ts @@ -0,0 +1,193 @@ +import type { + JiraComponent, + JiraIssueType, + JiraPriority, + JiraProject, + JiraResolution, + JiraStatus, + JiraStatusCategory, + JiraUser, + JiraVersion, +} from "./entities.js"; +import type { JiraRequest } from "./context.js"; + +export type Fmt = Pick & { user?: JiraUser }; + +export function restUrl(r: Fmt, path: string): string { + return `${r.siteUrl}/rest/api/${r.version}${path}`; +} + +export function avatarUrls(seed: string) { + const url = (size: number) => + `https://avatar-management.services.atlassian.com/default/${size}?seed=${encodeURIComponent(seed)}`; + return { "48x48": url(48), "24x24": url(24), "16x16": url(16), "32x32": url(32) }; +} + +export function formatUser(r: Fmt, user: JiraUser | undefined | null) { + if (!user) return null; + return { + self: restUrl(r, `/user?accountId=${user.account_id}`), + accountId: user.account_id, + accountType: user.account_type, + emailAddress: user.email, + avatarUrls: avatarUrls(user.account_id), + displayName: user.display_name, + active: user.active, + timeZone: user.time_zone, + locale: user.locale, + }; +} + +export function formatUserById(r: Fmt, accountId: string | null | undefined) { + if (!accountId) return null; + return formatUser(r, r.js.users.findOneBy("account_id", accountId)); +} + +const CATEGORY_INFO: Record = { + new: { id: 2, name: "To Do", colorName: "blue-gray" }, + indeterminate: { id: 4, name: "In Progress", colorName: "yellow" }, + done: { id: 3, name: "Done", colorName: "green" }, +}; + +export function formatStatusCategory(r: Fmt, category: JiraStatusCategory) { + const info = CATEGORY_INFO[category]; + return { + self: restUrl(r, `/statuscategory/${info.id}`), + id: info.id, + key: category, + colorName: info.colorName, + name: info.name, + }; +} + +export function allStatusCategories(r: Fmt) { + return [ + { self: restUrl(r, "/statuscategory/1"), id: 1, key: "undefined", colorName: "medium-gray", name: "No Category" }, + ...(["new", "done", "indeterminate"] as const).map((key) => formatStatusCategory(r, key)), + ]; +} + +export function formatStatus(r: Fmt, status: JiraStatus | undefined) { + if (!status) return null; + return { + self: restUrl(r, `/status/${status.id}`), + description: status.description, + iconUrl: `${r.siteUrl}/images/icons/statuses/generic.png`, + name: status.name, + id: String(status.id), + statusCategory: formatStatusCategory(r, status.category), + }; +} + +export function formatIssueType(r: Fmt, type: JiraIssueType | undefined) { + if (!type) return null; + return { + self: restUrl(r, `/issuetype/${type.id}`), + id: String(type.id), + description: type.description, + iconUrl: `${r.siteUrl}/images/icons/issuetypes/${type.name.toLowerCase().replace(/[^a-z]/g, "")}.svg`, + name: type.name, + subtask: type.subtask, + avatarId: 10300 + type.id - 10000, + hierarchyLevel: type.hierarchy_level, + }; +} + +export function formatPriority(r: Fmt, priority: JiraPriority | undefined | null) { + if (!priority) return null; + return { + self: restUrl(r, `/priority/${priority.id}`), + iconUrl: `${r.siteUrl}/images/icons/priorities/${priority.name.toLowerCase()}.svg`, + name: priority.name, + id: String(priority.id), + }; +} + +export function formatPriorityFull(r: Fmt, priority: JiraPriority) { + return { + ...formatPriority(r, priority), + statusColor: priority.color, + description: priority.description, + isDefault: priority.name === "Medium", + }; +} + +export function formatResolution(r: Fmt, resolution: JiraResolution | undefined | null) { + if (!resolution) return null; + return { + self: restUrl(r, `/resolution/${resolution.id}`), + id: String(resolution.id), + description: resolution.description, + name: resolution.name, + }; +} + +export function formatProjectRef(r: Fmt, project: JiraProject | undefined) { + if (!project) return null; + return { + self: restUrl(r, `/project/${project.id}`), + id: String(project.id), + key: project.key, + name: project.name, + projectTypeKey: project.project_type_key, + simplified: true, + avatarUrls: avatarUrls(`project-${project.key}`), + }; +} + +export function formatComponent(r: Fmt, component: JiraComponent) { + const project = r.js.projects.get(component.project_id); + return { + self: restUrl(r, `/component/${component.id}`), + id: String(component.id), + name: component.name, + description: component.description, + lead: formatUserById(r, component.lead_account_id) ?? undefined, + assigneeType: "PROJECT_DEFAULT", + project: project?.key, + projectId: component.project_id, + }; +} + +export function formatVersion(r: Fmt, version: JiraVersion) { + return { + self: restUrl(r, `/version/${version.id}`), + id: String(version.id), + name: version.name, + description: version.description, + archived: version.archived, + released: version.released, + ...(version.start_date ? { startDate: version.start_date } : {}), + ...(version.release_date ? { releaseDate: version.release_date } : {}), + projectId: version.project_id, + }; +} + +export function projectIssueTypes(r: Fmt, project: JiraProject) { + return project.issue_type_ids + .map((id) => r.js.issueTypes.get(id)) + .filter((type): type is JiraIssueType => Boolean(type)); +} + +export function projectStatuses(r: Fmt, project: JiraProject) { + return project.status_ids + .map((id) => r.js.statuses.get(id)) + .filter((status): status is JiraStatus => Boolean(status)); +} + +export function formatProject(r: Fmt, project: JiraProject) { + return { + expand: "description,lead,issueTypes,url,projectKeys,permissions,insight", + ...formatProjectRef(r, project), + description: project.description, + lead: formatUserById(r, project.lead_account_id), + components: r.js.components.findBy("project_id", project.id).map((component) => formatComponent(r, component)), + issueTypes: projectIssueTypes(r, project).map((type) => formatIssueType(r, type)), + assigneeType: "UNASSIGNED", + versions: r.js.versions.findBy("project_id", project.id).map((version) => formatVersion(r, version)), + roles: {}, + style: "next-gen", + isPrivate: false, + properties: {}, + }; +} diff --git a/packages/@emulators/jira/src/ids.ts b/packages/@emulators/jira/src/ids.ts new file mode 100644 index 00000000..13055093 --- /dev/null +++ b/packages/@emulators/jira/src/ids.ts @@ -0,0 +1,19 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +export function accountId(): string { + return `712020:${randomUUID()}`; +} + +export function cloudId(): string { + return randomUUID(); +} + +export function secretToken(prefix: string): string { + return `${prefix}_${randomBytes(24).toString("base64url")}`; +} + +/** Jira timestamps look like 2026-09-30T19:41:49.123+0000. */ +export function jiraTime(iso: string | null | undefined): string | null { + if (!iso) return null; + return new Date(iso).toISOString().replace("Z", "+0000"); +} diff --git a/packages/@emulators/jira/src/index.ts b/packages/@emulators/jira/src/index.ts new file mode 100644 index 00000000..7a1e077d --- /dev/null +++ b/packages/@emulators/jira/src/index.ts @@ -0,0 +1,59 @@ +import type { AppEnv, Hono, RouteContext, ServicePlugin, Store, TokenMap, WebhookDispatcher } from "@emulators/core"; +import { getJiraStore } from "./store.js"; +import { GATEWAY_HEADER, jiraErrorResponse, JiraError } from "./context.js"; +import { seedDefaults } from "./seed.js"; +import { platformRoutes } from "./routes/platform.js"; +import { projectRoutes } from "./routes/projects.js"; +import { issueRoutes } from "./routes/issues.js"; +import { searchRoutes } from "./routes/search.js"; +import { agileRoutes } from "./routes/agile.js"; +import { webhookRoutes } from "./routes/webhooks.js"; +import { oauthRoutes } from "./routes/oauth.js"; +import { inspectorRoutes } from "./routes/inspector.js"; +import { subresourceRoutes } from "./routes/subresources.js"; + +export { getJiraStore, type JiraStore } from "./store.js"; +export * from "./entities.js"; +export * from "./seed.js"; + +export const jiraPlugin: ServicePlugin = { + name: "jira", + register(app: Hono, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void { + // api.atlassian.com style gateway: /ex/jira/{cloudId}/rest/... serves the same API as /rest/... + app.use("/ex/jira/:cloudId/:rest{.*}", async (c) => { + const cloud = c.req.param("cloudId"); + if (!getJiraStore(store).sites.findOneBy("cloud_id", cloud)) { + return jiraErrorResponse(c, new JiraError(404, [`Site ${cloud} not found`])); + } + const url = new URL(c.req.url); + const target = new URL(`/${c.req.param("rest")}${url.search}`, url.origin); + const headers = new Headers(c.req.raw.headers); + headers.set(GATEWAY_HEADER, cloud); + const method = c.req.method; + const body = method === "GET" || method === "HEAD" ? undefined : await c.req.arrayBuffer(); + return app.fetch(new Request(target, { method, headers, body })); + }); + + const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; + platformRoutes(ctx); + projectRoutes(ctx); + searchRoutes(ctx); + issueRoutes(ctx); + subresourceRoutes(ctx); + agileRoutes(ctx); + webhookRoutes(ctx); + oauthRoutes(ctx); + inspectorRoutes(ctx); + + for (const method of ["GET", "POST", "PUT", "DELETE", "PATCH"]) { + app.on(method, "/rest/:rest{.*}", (c) => + jiraErrorResponse(c, new JiraError(404, ["No resource found for this path."])), + ); + } + }, + seed(store: Store, baseUrl: string): void { + seedDefaults(store, baseUrl); + }, +}; + +export default jiraPlugin; diff --git a/packages/@emulators/jira/src/issue-format.ts b/packages/@emulators/jira/src/issue-format.ts new file mode 100644 index 00000000..3bb342e0 --- /dev/null +++ b/packages/@emulators/jira/src/issue-format.ts @@ -0,0 +1,362 @@ +import type { + AdfNode, + JiraCustomField, + JiraChangelog, + JiraComment, + JiraIssue, + JiraSprint, + JiraStatus, + JiraWorklog, +} from "./entities.js"; +import { adfToHtml, adfToText } from "./adf.js"; +import { allFields } from "./fields.js"; +import { + formatComponent, + formatIssueType, + formatPriority, + formatProjectRef, + formatResolution, + formatStatus, + formatUserById, + formatVersion, + projectStatuses, + restUrl, + type Fmt, +} from "./formatters.js"; +import { jiraTime } from "./ids.js"; + +export function formatBody(r: Fmt, doc: AdfNode | null): AdfNode | string | null { + if (!doc) return null; + return r.version === "2" ? adfToText(doc) : doc; +} + +export function issueSelf(r: Fmt, issue: JiraIssue): string { + return restUrl(r, `/issue/${issue.id}`); +} + +export function formatIssueRef(r: Fmt, issue: JiraIssue) { + return { + id: String(issue.id), + key: issue.key, + self: issueSelf(r, issue), + fields: { + summary: issue.summary, + status: formatStatus(r, r.js.statuses.get(issue.status_id)), + priority: formatPriority(r, issue.priority_id ? r.js.priorities.get(issue.priority_id) : null), + issuetype: formatIssueType(r, r.js.issueTypes.get(issue.issue_type_id)), + }, + }; +} + +export function formatComment(r: Fmt, comment: JiraComment) { + return { + self: restUrl(r, `/issue/${comment.issue_id}/comment/${comment.id}`), + id: String(comment.id), + author: formatUserById(r, comment.author_id), + body: formatBody(r, comment.body), + updateAuthor: formatUserById(r, comment.update_author_id ?? comment.author_id), + created: jiraTime(comment.created_at), + updated: jiraTime(comment.updated_at), + jsdPublic: true, + }; +} + +export function formatWorklog(r: Fmt, worklog: JiraWorklog) { + return { + self: restUrl(r, `/issue/${worklog.issue_id}/worklog/${worklog.id}`), + author: formatUserById(r, worklog.author_id), + updateAuthor: formatUserById(r, worklog.update_author_id ?? worklog.author_id), + ...(worklog.comment ? { comment: formatBody(r, worklog.comment) } : {}), + created: jiraTime(worklog.created_at), + updated: jiraTime(worklog.updated_at), + started: jiraTime(worklog.started), + timeSpent: formatDuration(worklog.time_spent_seconds), + timeSpentSeconds: worklog.time_spent_seconds, + id: String(worklog.id), + issueId: String(worklog.issue_id), + }; +} + +export function formatSprint(r: Fmt, sprint: JiraSprint) { + return { + id: sprint.id, + self: `${r.siteUrl}/rest/agile/1.0/sprint/${sprint.id}`, + state: sprint.state, + name: sprint.name, + ...(sprint.start_date ? { startDate: sprint.start_date } : {}), + ...(sprint.end_date ? { endDate: sprint.end_date } : {}), + ...(sprint.complete_date ? { completeDate: sprint.complete_date } : {}), + createdDate: sprint.created_at, + originBoardId: sprint.board_id, + boardId: sprint.board_id, + goal: sprint.goal, + }; +} + +export function formatChangelog(r: Fmt, entry: JiraChangelog) { + return { + id: String(entry.id), + author: formatUserById(r, entry.author_id), + created: jiraTime(entry.created_at), + items: entry.items, + }; +} + +/** Jira's default time tracking units, largest first: a week is 5 days and a day is 8 hours. */ +const DURATION_UNITS = { w: 5 * 8 * 3600, d: 8 * 3600, h: 3600, m: 60 }; + +/** Formats seconds as Jira does, such as `1w 2d 3h 30m`. */ +export function formatDuration(seconds: number): string { + const parts: string[] = []; + let remaining = seconds; + for (const [unit, size] of Object.entries(DURATION_UNITS)) { + const count = Math.floor(remaining / size); + if (count > 0) { + parts.push(`${count}${unit}`); + remaining -= count * size; + } + } + return parts.join(" ") || "0m"; +} + +/** Parses Jira durations such as `1w 2d 3h 30m`. A bare number means minutes. */ +export function parseDuration(value: string): number | null { + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) return Number(trimmed) * 60; + const parts = trimmed.split(/\s+/); + let total = 0; + for (const part of parts) { + const match = /^(\d+(?:\.\d+)?)([wdhm])$/.exec(part); + if (!match) return null; + total += Number(match[1]) * DURATION_UNITS[match[2] as keyof typeof DURATION_UNITS]; + } + return total > 0 ? Math.round(total) : null; +} + +/** Transition IDs are derived from the status position in the project workflow (11, 21, 31, ...). */ +export function issueTransitions(r: Fmt, issue: JiraIssue) { + const project = r.js.projects.get(issue.project_id); + if (!project) return []; + return projectStatuses(r, project) + .map((status, index) => ({ id: String((index + 1) * 10 + 1), status })) + .filter((entry) => entry.status.id !== issue.status_id); +} + +export function formatTransition(r: Fmt, transition: { id: string; status: JiraStatus }) { + return { + id: transition.id, + name: transition.status.name, + to: formatStatus(r, transition.status), + hasScreen: false, + isGlobal: true, + isInitial: false, + isAvailable: true, + isConditional: false, + isLooped: false, + }; +} + +export interface FieldSelection { + all: boolean; + include: Set; + exclude: Set; +} + +/** Parses Jira's `fields` parameter: `*all`, `*navigable`, `summary,status`, `-comment`. */ +export function parseFieldSelection(values: string[], defaultAll: boolean): FieldSelection { + const include = new Set(); + const exclude = new Set(); + let all = false; + let sawInclude = false; + for (const value of values) { + if (value === "*all" || value === "*navigable") { + all = true; + sawInclude = true; + } else if (value.startsWith("-")) { + exclude.add(value.slice(1)); + } else { + include.add(value); + sawInclude = true; + } + } + if (!sawInclude) all = defaultAll; + return { all, include, exclude }; +} + +export interface IssueFormatOptions { + fields?: FieldSelection; + expand?: string[]; +} + +/** Formats an issue's links from its own side: each entry names the other issue after its role. */ +function formatIssueLinks(r: Fmt, issue: JiraIssue) { + const js = r.js; + return [...js.issueLinks.findBy("inward_issue_id", issue.id), ...js.issueLinks.findBy("outward_issue_id", issue.id)] + .sort((a, b) => a.id - b.id) + .map((link) => { + const type = js.issueLinkTypes.get(link.type_id); + // A link's outward issue is its source: "B blocks A" is stored as outward B, inward A. + // Each side names the other issue after its own role, so B shows outwardIssue A and A shows inwardIssue B. + const isSource = link.outward_issue_id === issue.id; + const other = js.issues.get(isSource ? link.inward_issue_id : link.outward_issue_id); + return { + id: String(link.id), + self: restUrl(r, `/issueLink/${link.id}`), + type: type + ? { + id: String(type.id), + name: type.name, + inward: type.inward, + outward: type.outward, + self: restUrl(r, `/issueLinkType/${type.id}`), + } + : null, + ...(other ? { [isSource ? "outwardIssue" : "inwardIssue"]: formatIssueRef(r, other) } : {}), + }; + }); +} + +/** + * Getters for every field of an issue, in Jira's response order. They are lazy so a request for a few + * fields (search with `fields=key,summary`) does not load comments, worklogs, and links for every issue. + */ +export function issueFieldGetters(r: Fmt, issue: JiraIssue): Record unknown> { + const js = r.js; + const worklogs = () => js.worklogs.findBy("issue_id", issue.id).sort((a, b) => a.id - b.id); + const timeSpent = () => worklogs().reduce((sum, worklog) => sum + worklog.time_spent_seconds, 0) || null; + const parent = issue.parent_id ? js.issues.get(issue.parent_id) : undefined; + + const getters: Record unknown> = { + statuscategorychangedate: () => jiraTime(issue.status_changed_at), + issuetype: () => formatIssueType(r, js.issueTypes.get(issue.issue_type_id)), + timespent: timeSpent, + aggregatetimespent: timeSpent, + project: () => formatProjectRef(r, js.projects.get(issue.project_id)), + fixVersions: () => + issue.fix_version_ids + .map((id) => js.versions.get(id)) + .filter(Boolean) + .map((version) => formatVersion(r, version!)), + resolution: () => formatResolution(r, issue.resolution_id ? js.resolutions.get(issue.resolution_id) : null), + resolutiondate: () => jiraTime(issue.resolution_date), + workratio: () => -1, + watches: () => ({ + self: restUrl(r, `/issue/${issue.key}/watchers`), + watchCount: issue.watcher_ids.length, + isWatching: r.user ? issue.watcher_ids.includes(r.user.account_id) : false, + }), + lastViewed: () => null, + created: () => jiraTime(issue.created_at), + priority: () => formatPriority(r, issue.priority_id ? js.priorities.get(issue.priority_id) : null), + labels: () => [...issue.labels], + summary: () => issue.summary, + issuelinks: () => formatIssueLinks(r, issue), + assignee: () => formatUserById(r, issue.assignee_id), + updated: () => jiraTime(issue.updated_at), + status: () => formatStatus(r, js.statuses.get(issue.status_id)), + components: () => + issue.component_ids + .map((id) => js.components.get(id)) + .filter(Boolean) + .map((component) => formatComponent(r, component!)), + description: () => formatBody(r, issue.description), + environment: () => formatBody(r, issue.environment), + duedate: () => issue.due_date, + creator: () => formatUserById(r, issue.creator_id), + reporter: () => formatUserById(r, issue.reporter_id), + subtasks: () => + js.issues + .findBy("parent_id", issue.id) + .filter((child) => js.issueTypes.get(child.issue_type_id)?.subtask) + .sort((a, b) => a.id - b.id) + .map((child) => formatIssueRef(r, child)), + ...(parent ? { parent: () => formatIssueRef(r, parent) } : {}), + comment: () => { + const comments = js.comments.findBy("issue_id", issue.id).sort((a, b) => a.id - b.id); + return { + comments: comments.map((comment) => formatComment(r, comment)), + self: restUrl(r, `/issue/${issue.id}/comment`), + maxResults: comments.length, + total: comments.length, + startAt: 0, + }; + }, + worklog: () => { + const all = worklogs(); + return { + startAt: 0, + maxResults: 20, + total: all.length, + worklogs: all.slice(0, 20).map((worklog) => formatWorklog(r, worklog)), + }; + }, + votes: () => ({ self: restUrl(r, `/issue/${issue.key}/votes`), votes: 0, hasVoted: false }), + }; + + for (const field of js.customFields.all()) { + getters[field.field_id] = () => customFieldValue(r, issue, field); + } + return getters; +} + +function customFieldValue(r: Fmt, issue: JiraIssue, field: JiraCustomField): unknown { + const value = issue.custom_fields[field.field_id]; + switch (field.type) { + case "sprint": { + const sprints = [...issue.closed_sprint_ids, ...(issue.sprint_id ? [issue.sprint_id] : [])] + .map((id) => r.js.sprints.get(id)) + .filter(Boolean) + .map((sprint) => formatSprint(r, sprint!)); + return sprints.length > 0 ? sprints : null; + } + case "user": + return formatUserById(r, value as string | null); + case "option": + return value == null ? null : { self: restUrl(r, `/customFieldOption/${field.id}`), value, id: String(field.id) }; + default: + return value ?? null; + } +} + +export function formatIssue(r: Fmt, issue: JiraIssue, opts: IssueFormatOptions = {}) { + const selection = opts.fields ?? { all: true, include: new Set(), exclude: new Set() }; + const expand = new Set(opts.expand ?? []); + const fields: Record = {}; + for (const [key, get] of Object.entries(issueFieldGetters(r, issue))) { + if (selection.exclude.has(key)) continue; + if (selection.all || selection.include.has(key)) fields[key] = get(); + } + + const result: Record = { + expand: "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations", + id: String(issue.id), + self: issueSelf(r, issue), + key: issue.key, + fields, + }; + + if (expand.has("renderedFields")) { + result.renderedFields = { + description: adfToHtml(issue.description), + environment: adfToHtml(issue.environment), + created: issue.created_at, + updated: issue.updated_at, + }; + } + if (expand.has("names") || expand.has("schema")) { + const defs = allFields(r.js); + if (expand.has("names")) result.names = Object.fromEntries(defs.map((field) => [field.id, field.name])); + if (expand.has("schema")) result.schema = Object.fromEntries(defs.map((field) => [field.id, field.schema])); + } + if (expand.has("transitions")) { + result.transitions = issueTransitions(r, issue).map((transition) => formatTransition(r, transition)); + } + if (expand.has("changelog")) { + const histories = r.js.changelogs + .findBy("issue_id", issue.id) + .sort((a, b) => a.id - b.id) + .map((entry) => formatChangelog(r, entry)); + result.changelog = { startAt: 0, maxResults: histories.length, total: histories.length, histories }; + } + return result; +} diff --git a/packages/@emulators/jira/src/issue-service.ts b/packages/@emulators/jira/src/issue-service.ts new file mode 100644 index 00000000..ada6ffda --- /dev/null +++ b/packages/@emulators/jira/src/issue-service.ts @@ -0,0 +1,633 @@ +import type { Store } from "@emulators/core"; +import type { AdfNode, JiraChangelog, JiraChangelogItem, JiraComment, JiraIssue, JiraUser } from "./entities.js"; +import { JiraError, fieldError } from "./context.js"; +import { isAdfDoc, textToAdf, adfToText } from "./adf.js"; +import { + findComponent, + findIssue, + findIssueType, + findPriority, + findProject, + findResolution, + findUser, + findVersion, +} from "./lookup.js"; +import { insertFrom, type JiraStore } from "./store.js"; +import { touchIssue } from "./services.js"; +import { issueTransitions } from "./issue-format.js"; +import type { Fmt } from "./formatters.js"; + +export interface Actor extends Fmt { + store: Store; + user: JiraUser; +} + +const NOT_SETTABLE = (field: string) => + fieldError(field, `Field '${field}' cannot be set. It is not on the appropriate screen, or unknown.`); + +type RefValue = { id?: string | number; key?: string; name?: string; accountId?: string; value?: string } | null; + +export function readBody(actor: Pick, field: string, value: unknown): AdfNode | null { + if (value === null || value === undefined || value === "") return null; + if (actor.version === "2") { + if (typeof value === "string") return textToAdf(value); + if (isAdfDoc(value)) return value; + throw fieldError(field, `The value for ${field} must be a string.`); + } + if (!isAdfDoc(value)) { + throw fieldError(field, "Operation value must be an Atlassian Document (see the Atlassian Document Format)"); + } + return value; +} + +function refOf(value: unknown): RefValue { + if (value === null) return null; + if (typeof value === "string" || typeof value === "number") return { id: value, name: String(value) }; + if (typeof value === "object") return value as RefValue; + return {}; +} + +function resolveUserRef(js: JiraStore, field: string, value: unknown): string | null { + const ref = refOf(value); + if (ref === null) return null; + const key = ref.accountId ?? (ref.id !== undefined ? String(ref.id) : undefined) ?? ref.name; + if (key === undefined || key === null) return null; + if (key === "-1") return null; + const user = findUser(js, key); + if (!user) throw fieldError(field, `Specify a valid value for ${field}`); + return user.account_id; +} + +function validateParent(js: JiraStore, draft: JiraIssue, parentId: number | null): void { + const type = js.issueTypes.get(draft.issue_type_id); + if (parentId === null) { + if (type?.subtask) throw fieldError("parent", "Subtasks must have a parent issue."); + return; + } + const parent = js.issues.get(parentId); + if (!parent) throw fieldError("parent", "Could not find the parent issue."); + if (parent.id === draft.id) throw fieldError("parent", "An issue cannot be its own parent."); + const parentType = js.issueTypes.get(parent.issue_type_id); + if (!type || !parentType || parentType.hierarchy_level !== type.hierarchy_level + 1) { + throw fieldError("parent", "Given parent work item does not belong to appropriate hierarchy."); + } + if (type.subtask && parent.project_id !== draft.project_id) { + throw fieldError("parent", "Subtasks must be in the same project as their parent."); + } +} + +/** Applies one `fields` entry to a draft issue, validating the value. */ +export function setField(actor: Actor, draft: JiraIssue, field: string, value: unknown, mode: "create" | "edit"): void { + const js = actor.js; + switch (field) { + case "project": + case "issuetype": + if (mode === "edit" && field === "project") throw NOT_SETTABLE(field); + if (field === "issuetype") { + const ref = refOf(value); + const type = ref ? findIssueType(js, ref.id ?? ref.name) : undefined; + const project = js.projects.get(draft.project_id); + if (!type || !project?.issue_type_ids.includes(type.id)) { + throw fieldError("issuetype", "Specify a valid issue type"); + } + draft.issue_type_id = type.id; + } + return; + case "summary": { + const summary = typeof value === "string" ? value.trim() : ""; + if (!summary) throw fieldError("summary", "You must specify a summary of the issue."); + if (summary.length > 255) throw fieldError("summary", "Summary must be less than 255 characters."); + draft.summary = summary; + return; + } + case "description": + case "environment": + draft[field] = readBody(actor, field, value); + return; + case "priority": { + const ref = refOf(value); + if (ref === null) { + draft.priority_id = null; + return; + } + const priority = findPriority(js, ref.id ?? ref.name); + if (!priority) throw fieldError("priority", `Specify a valid value for priority`); + draft.priority_id = priority.id; + return; + } + case "assignee": + case "reporter": + draft[field === "assignee" ? "assignee_id" : "reporter_id"] = resolveUserRef(js, field, value); + return; + case "labels": { + const labels = Array.isArray(value) ? value : value == null ? [] : null; + if (!labels) throw fieldError("labels", "The labels field must be an array of strings."); + draft.labels = labels.map((label) => validateLabel(String(label))); + return; + } + case "duedate": + if (value !== null && value !== "" && !/^\d{4}-\d{2}-\d{2}$/.test(String(value))) { + throw fieldError("duedate", "Error parsing date string: " + String(value)); + } + draft.due_date = value ? String(value) : null; + return; + case "parent": { + const ref = refOf(value); + const parent = ref ? findIssue(js, ref.key ?? ref.id) : null; + if (ref && !parent) throw fieldError("parent", "Could not find the parent issue."); + draft.parent_id = parent?.id ?? null; + return; + } + case "components": + case "fixVersions": { + if (!Array.isArray(value)) throw fieldError(field, `Field ${field} must be an array.`); + draft[field === "components" ? "component_ids" : "fix_version_ids"] = value.map((entry) => + resolveProjectItem(actor, draft, field, entry), + ); + return; + } + case "resolution": { + const ref = refOf(value); + if (ref === null) { + draft.resolution_id = null; + draft.resolution_date = null; + return; + } + const resolution = findResolution(js, ref.id ?? ref.name); + if (!resolution) throw fieldError("resolution", "Specify a valid value for resolution"); + if (!draft.resolution_id) draft.resolution_date = new Date().toISOString(); + draft.resolution_id = resolution.id; + return; + } + case "status": + throw NOT_SETTABLE("status"); + default: + setCustomField(actor, draft, field, value); + } +} + +function validateLabel(label: string): string { + if (/\s/.test(label)) throw fieldError("labels", `The label '${label}' contains spaces which is invalid.`); + if (label.length > 255) throw fieldError("labels", "The label must not exceed 255 characters."); + return label; +} + +function resolveProjectItem(actor: Actor, draft: JiraIssue, field: string, entry: unknown): number { + const ref = refOf(entry) ?? {}; + const find = field === "components" ? findComponent : findVersion; + const item = find(actor.js, draft.project_id, { id: ref.id, name: ref.name }); + if (!item) { + const label = field === "components" ? "Component" : "Version"; + throw fieldError(field, `${label} name '${ref.name ?? ref.id}' is not valid`); + } + return item.id; +} + +function setCustomField(actor: Actor, draft: JiraIssue, field: string, value: unknown): void { + const def = actor.js.customFields.findOneBy("field_id", field); + if (!def) throw NOT_SETTABLE(field); + if (value === null || value === undefined) { + if (def.type === "sprint") draft.sprint_id = null; + else draft.custom_fields = { ...draft.custom_fields, [field]: null }; + return; + } + const invalid = () => + fieldError(field, `Operation value must be a ${def.type === "number" ? "number" : "valid value"}`); + let stored: unknown = value; + switch (def.type) { + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) throw invalid(); + break; + case "string": + if (typeof value !== "string") throw invalid(); + break; + case "date": + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw invalid(); + break; + case "datetime": + if (typeof value !== "string" || Number.isNaN(Date.parse(value))) throw invalid(); + break; + case "array": + if (!Array.isArray(value)) throw invalid(); + stored = value.map(String); + break; + case "option": { + const option = typeof value === "string" ? value : (value as { value?: string }).value; + if (!option || (def.options.length > 0 && !def.options.includes(option))) throw invalid(); + stored = option; + break; + } + case "user": + stored = resolveUserRef(actor.js, field, value); + break; + case "sprint": { + const sprintId = typeof value === "number" ? value : Number((value as { id?: number }).id ?? value); + const sprint = actor.js.sprints.get(sprintId); + if (!sprint) throw fieldError(field, `Sprint with id ${String(value)} does not exist.`); + if (sprint.state === "closed") throw fieldError(field, "Issue can be assigned only to active or future sprints."); + draft.sprint_id = sprint.id; + return; + } + } + draft.custom_fields = { ...draft.custom_fields, [field]: stored }; +} + +export interface PendingLink { + typeId: number; + otherId: number; + /** True for `outwardIssue`, which makes the edited issue the source ("this issue blocks the other"). */ + issueIsSource: boolean; +} + +export interface UpdateSideEffects { + comments: unknown[]; + links: PendingLink[]; +} + +/** Applies `update` operations (`set`, `add`, `remove`, `edit`). Comment and link additions are returned for the caller. */ +export function applyUpdateOps( + actor: Actor, + draft: JiraIssue, + update: Record, + mode: "create" | "edit", +): UpdateSideEffects { + const effects: UpdateSideEffects = { comments: [], links: [] }; + for (const [field, opsValue] of Object.entries(update ?? {})) { + if (!Array.isArray(opsValue)) throw fieldError(field, "Field operations must be an array."); + for (const op of opsValue as Array>) { + const [verb, value] = Object.entries(op)[0] ?? []; + if (field === "comment") { + if (verb !== "add") throw fieldError("comment", "Only add is supported for comments."); + effects.comments.push((value as { body?: unknown })?.body); + continue; + } + if (field === "issuelinks") { + if (verb !== "add") throw fieldError("issuelinks", "Only add is supported for issue links."); + effects.links.push(resolvePendingLink(actor.js, value as LinkInput)); + continue; + } + if (verb === "set" || verb === "edit") { + setField(actor, draft, field, value, mode); + } else if (verb === "add" || verb === "remove") { + applyListOp(actor, draft, field, verb, value); + } else { + throw fieldError(field, `Unsupported operation '${verb}' for field ${field}.`); + } + } + } + return effects; +} + +interface LinkInput { + type?: { id?: string | number; name?: string }; + inwardIssue?: { id?: string | number; key?: string }; + outwardIssue?: { id?: string | number; key?: string }; +} + +function resolvePendingLink(js: JiraStore, input: LinkInput): PendingLink { + const type = findLinkType(js, input?.type); + if (!type) throw fieldError("issuelinks", "No issue link type with name or id specified found."); + const otherRef = input.outwardIssue ?? input.inwardIssue; + const other = otherRef ? findIssue(js, otherRef.key ?? otherRef.id) : undefined; + if (!other) throw fieldError("issuelinks", "The linked issue does not exist."); + return { typeId: type.id, otherId: other.id, issueIsSource: Boolean(input.outwardIssue) }; +} + +export function findLinkType(js: JiraStore, ref: { id?: string | number; name?: string } | undefined) { + if (!ref) return undefined; + if (ref.id !== undefined) return js.issueLinkTypes.get(Number(ref.id)); + return js.issueLinkTypes.all().find((type) => type.name.toLowerCase() === String(ref.name ?? "").toLowerCase()); +} + +export function createLink(js: JiraStore, typeId: number, inwardId: number, outwardId: number) { + const existing = js.issueLinks + .findBy("inward_issue_id", inwardId) + .find((link) => link.outward_issue_id === outwardId && link.type_id === typeId); + if (existing) return existing; + const link = insertFrom(js.issueLinks, 10000, { + type_id: typeId, + inward_issue_id: inwardId, + outward_issue_id: outwardId, + }); + touchIssue(js, inwardId); + touchIssue(js, outwardId); + return link; +} + +function applyLinks(js: JiraStore, issue: JiraIssue, links: PendingLink[]): void { + for (const link of links) { + if (link.issueIsSource) createLink(js, link.typeId, link.otherId, issue.id); + else createLink(js, link.typeId, issue.id, link.otherId); + } +} + +function applyListOp(actor: Actor, draft: JiraIssue, field: string, verb: "add" | "remove", value: unknown): void { + if (field === "labels") { + const label = validateLabel(String(value)); + draft.labels = + verb === "add" + ? draft.labels.includes(label) + ? draft.labels + : [...draft.labels, label] + : draft.labels.filter((existing) => existing !== label); + return; + } + if (field === "components" || field === "fixVersions") { + const key = field === "components" ? "component_ids" : "fix_version_ids"; + const id = resolveProjectItem(actor, draft, field, value); + draft[key] = verb === "add" ? [...new Set([...draft[key], id])] : draft[key].filter((existing) => existing !== id); + return; + } + const def = actor.js.customFields.findOneBy("field_id", field); + if (def?.type === "array") { + const current = (draft.custom_fields[field] as string[] | null) ?? []; + const item = String(value); + const next = verb === "add" ? [...new Set([...current, item])] : current.filter((existing) => existing !== item); + draft.custom_fields = { ...draft.custom_fields, [field]: next }; + return; + } + throw fieldError(field, `Field '${field}' does not support the ${verb} operation.`); +} + +export interface CreateIssueResult { + issue: JiraIssue; + comments: JiraComment[]; +} + +export function createIssue( + actor: Actor, + body: { fields?: Record; update?: Record }, +): CreateIssueResult { + const js = actor.js; + const fields = body?.fields ?? {}; + const projectRef = refOf(fields.project); + const project = projectRef ? findProject(js, projectRef.key ?? projectRef.id) : undefined; + if (!project) throw fieldError("project", "Specify a valid project ID or key"); + if (fields.issuetype === undefined) throw fieldError("issuetype", "Specify an issue type"); + if (fields.summary === undefined && !hasSetOp(body.update, "summary")) { + throw fieldError("summary", "You must specify a summary of the issue."); + } + + const initial = js.statuses.get(project.status_ids[0]); + if (!initial) throw new JiraError(400, ["The project has no workflow statuses."]); + const now = new Date().toISOString(); + const draft: JiraIssue = { + id: 0, + created_at: now, + updated_at: now, + key: "", + number: 0, + project_id: project.id, + issue_type_id: 0, + summary: "", + description: null, + environment: null, + status_id: initial.id, + status_changed_at: now, + priority_id: js.priorities.findOneBy("name", "Medium")?.id ?? null, + resolution_id: null, + resolution_date: null, + assignee_id: null, + reporter_id: actor.user.account_id, + creator_id: actor.user.account_id, + parent_id: null, + labels: [], + component_ids: [], + fix_version_ids: [], + due_date: null, + custom_fields: {}, + watcher_ids: [actor.user.account_id], + sprint_id: null, + closed_sprint_ids: [], + }; + + const ordered = ["issuetype", ...Object.keys(fields).filter((key) => key !== "issuetype" && key !== "project")]; + for (const field of ordered) setField(actor, draft, field, fields[field], "create"); + const effects = applyUpdateOps(actor, draft, body.update ?? {}, "create"); + validateParent(js, draft, draft.parent_id); + if (initial.category === "done" && !draft.resolution_id) { + draft.resolution_id = js.resolutions.all()[0]?.id ?? null; + draft.resolution_date = draft.resolution_id ? now : null; + } + + const comments = effects.comments.map((raw) => readBody(actor, "comment", raw)!); + const number = project.issue_sequence + 1; + js.projects.update(project.id, { issue_sequence: number }); + const { id: _id, created_at: _c, updated_at: _u, ...data } = draft; + const issue = insertFrom(js.issues, 10000, { ...data, key: `${project.key}-${number}`, number }); + applyLinks(js, issue, effects.links); + return { issue, comments: comments.map((body) => addComment(actor, issue, body)) }; +} + +function hasSetOp(update: Record | undefined, field: string): boolean { + const ops = update?.[field]; + return Array.isArray(ops) && ops.some((op) => op && typeof op === "object" && "set" in op); +} + +export function addComment(actor: Actor, issue: JiraIssue, body: AdfNode): JiraComment { + const comment = insertFrom(actor.js.comments, 10000, { + issue_id: issue.id, + author_id: actor.user.account_id, + update_author_id: actor.user.account_id, + body, + }); + touchIssue(actor.js, issue.id); + return comment; +} + +export interface EditIssueResult { + issue: JiraIssue; + items: JiraChangelogItem[]; + changelog: JiraChangelog | null; + comments: JiraComment[]; +} + +/** Edits an issue from `fields` and `update`, then records a changelog entry for what changed. */ +export function editIssue( + actor: Actor, + issue: JiraIssue, + body: { fields?: Record; update?: Record }, + extra?: (draft: JiraIssue) => void, +): EditIssueResult { + const draft: JiraIssue = structuredClone(issue); + for (const [field, value] of Object.entries(body?.fields ?? {})) setField(actor, draft, field, value, "edit"); + const effects = applyUpdateOps(actor, draft, body?.update ?? {}, "edit"); + extra?.(draft); + if (draft.parent_id !== issue.parent_id || draft.issue_type_id !== issue.issue_type_id) { + validateParent(actor.js, draft, draft.parent_id); + } + const comments = effects.comments.map((raw) => readBody(actor, "comment", raw)!); + + const items = diffIssues(actor.js, issue, draft); + let updated = issue; + let changelog: JiraChangelog | null = null; + if (items.length > 0) { + const { id: _id, created_at: _c, updated_at: _u, ...data } = draft; + updated = actor.js.issues.update(issue.id, data)!; + changelog = insertFrom(actor.js.changelogs, 10000, { issue_id: issue.id, author_id: actor.user.account_id, items }); + } + applyLinks(actor.js, updated, effects.links); + const added = comments.map((commentBody) => addComment(actor, updated, commentBody)); + return { issue: actor.js.issues.get(issue.id)!, items, changelog, comments: added }; +} + +export function transitionIssue( + actor: Actor, + issue: JiraIssue, + body: { transition?: { id?: string | number }; fields?: Record; update?: Record }, +): EditIssueResult { + const transitionId = String(body?.transition?.id ?? ""); + const transition = issueTransitions(actor, issue).find((entry) => entry.id === transitionId); + if (!transition) throw new JiraError(400, [`Transition id '${transitionId}' is not valid for this issue.`]); + const target = transition.status; + const fields = { ...(body.fields ?? {}) }; + const explicitResolution = "resolution" in fields; + return editIssue(actor, issue, { fields, update: body.update }, (draft) => { + draft.status_id = target.id; + draft.status_changed_at = new Date().toISOString(); + if (target.category === "done") { + if (!draft.resolution_id && !explicitResolution) { + draft.resolution_id = + actor.js.resolutions.findOneBy("name", "Done")?.id ?? actor.js.resolutions.all()[0]?.id ?? null; + } + draft.resolution_date ??= new Date().toISOString(); + } else { + draft.resolution_id = null; + draft.resolution_date = null; + } + }); +} + +/** A field recorded in the changelog when an edit changes it. */ +interface TrackedField { + field: string; + fieldId: string; + fieldtype?: "jira" | "custom"; + /** Raw value reported as `from` and `to`. */ + id?: (issue: JiraIssue) => string | null; + /** Display value reported as `fromString` and `toString`. */ + text: (issue: JiraIssue) => string | null; + /** Decides whether the field changed. Defaults to comparing `id` and `text`. */ + compare?: (issue: JiraIssue) => string; +} + +function trackedFields(js: JiraStore): TrackedField[] { + const str = (value: number | null) => (value === null ? null : String(value)); + const named = + (get: (id: number) => { name: string } | undefined) => + (id: number | null): string | null => + id === null ? null : (get(id)?.name ?? null); + const names = (ids: number[], get: (id: number) => { name: string } | undefined) => + ids + .map((id) => get(id)?.name) + .filter(Boolean) + .join(" ") || null; + const user = (id: string | null) => (id ? (js.users.findOneBy("account_id", id)?.display_name ?? id) : null); + const adf = (key: "description" | "environment"): TrackedField => ({ + field: key, + fieldId: key, + text: (issue) => (issue[key] ? adfToText(issue[key]) : null), + compare: (issue) => JSON.stringify(issue[key]), + }); + const issueType = named((id) => js.issueTypes.get(id)); + const status = named((id) => js.statuses.get(id)); + const priority = named((id) => js.priorities.get(id)); + const resolution = named((id) => js.resolutions.get(id)); + const sprint = named((id) => js.sprints.get(id)); + const sprintField = js.customFields.all().find((field) => field.type === "sprint"); + + const system: TrackedField[] = [ + { field: "summary", fieldId: "summary", text: (issue) => issue.summary }, + adf("description"), + adf("environment"), + { + field: "issuetype", + fieldId: "issuetype", + id: (i) => str(i.issue_type_id), + text: (i) => issueType(i.issue_type_id), + }, + { field: "status", fieldId: "status", id: (i) => str(i.status_id), text: (i) => status(i.status_id) }, + { field: "priority", fieldId: "priority", id: (i) => str(i.priority_id), text: (i) => priority(i.priority_id) }, + { + field: "resolution", + fieldId: "resolution", + id: (i) => str(i.resolution_id), + text: (i) => resolution(i.resolution_id), + }, + { field: "assignee", fieldId: "assignee", id: (i) => i.assignee_id, text: (i) => user(i.assignee_id) }, + { field: "reporter", fieldId: "reporter", id: (i) => i.reporter_id, text: (i) => user(i.reporter_id) }, + { field: "duedate", fieldId: "duedate", id: (i) => i.due_date, text: (i) => i.due_date }, + { field: "labels", fieldId: "labels", text: (i) => i.labels.join(" ") || null }, + { + field: "IssueParentAssociation", + fieldId: "parent", + id: (i) => str(i.parent_id), + text: (i) => (i.parent_id ? (js.issues.get(i.parent_id)?.key ?? null) : null), + }, + { + field: "Component", + fieldId: "components", + id: (i) => i.component_ids.join(",") || null, + text: (i) => names(i.component_ids, (id) => js.components.get(id)), + }, + { + field: "Fix Version", + fieldId: "fixVersions", + id: (i) => i.fix_version_ids.join(",") || null, + text: (i) => names(i.fix_version_ids, (id) => js.versions.get(id)), + }, + { + field: sprintField?.name ?? "Sprint", + fieldId: sprintField?.field_id ?? "customfield_10020", + fieldtype: "custom", + id: (i) => str(i.sprint_id), + text: (i) => sprint(i.sprint_id), + }, + ]; + + const custom = js.customFields + .all() + .filter((field) => field.type !== "sprint") + .map((field): TrackedField => { + const value = (issue: JiraIssue) => issue.custom_fields[field.field_id] ?? null; + return { + field: field.name, + fieldId: field.field_id, + fieldtype: "custom", + text: (issue) => { + const current = value(issue); + if (current === null) return null; + if (field.type === "user") return user(String(current)); + return Array.isArray(current) ? current.join(" ") : String(current); + }, + compare: (issue) => JSON.stringify(value(issue)), + }; + }); + + return [...system, ...custom]; +} + +/** Builds Jira style changelog items for every tracked field that differs between two issue states. */ +export function diffIssues(js: JiraStore, before: JiraIssue, after: JiraIssue): JiraChangelogItem[] { + const items: JiraChangelogItem[] = []; + for (const tracked of trackedFields(js)) { + const compare = + tracked.compare ?? ((issue: JiraIssue) => JSON.stringify([tracked.id?.(issue), tracked.text(issue)])); + if (compare(before) === compare(after)) continue; + items.push({ + field: tracked.field, + fieldtype: tracked.fieldtype ?? "jira", + fieldId: tracked.fieldId, + from: tracked.id?.(before) ?? null, + fromString: tracked.text(before), + to: tracked.id?.(after) ?? null, + toString: tracked.text(after), + }); + } + return items; +} + +/** Actor used when seeding data outside of a request. */ +export function systemActor(store: Store, js: JiraStore, baseUrl: string, user: JiraUser): Actor { + return { store, js, baseUrl, siteUrl: baseUrl, version: "3", user }; +} diff --git a/packages/@emulators/jira/src/jql.ts b/packages/@emulators/jira/src/jql.ts new file mode 100644 index 00000000..80461a83 --- /dev/null +++ b/packages/@emulators/jira/src/jql.ts @@ -0,0 +1,926 @@ +import type { JiraIssue, JiraStatusCategory, JiraUser } from "./entities.js"; +import type { JiraStore } from "./store.js"; +import { adfToText } from "./adf.js"; +import { findField, SYSTEM_FIELDS } from "./fields.js"; +import { findIssue, findUser } from "./lookup.js"; + +export class JqlError extends Error { + constructor(message: string) { + super(message); + this.name = "JqlError"; + } +} + +// Tokens + +type TokenType = "word" | "string" | "op" | "lparen" | "rparen" | "comma" | "eof"; + +interface Token { + type: TokenType; + value: string; + pos: number; +} + +const OPERATORS = ["!=", "!~", ">=", "<=", "=", "~", ">", "<"]; +const WORD_BREAK = /[\s()=,!~<>"']/; + +function tokenize(input: string): Token[] { + const tokens: Token[] = []; + let i = 0; + while (i < input.length) { + const ch = input[i]; + if (/\s/.test(ch)) { + i++; + continue; + } + if (ch === "(") tokens.push({ type: "lparen", value: ch, pos: i++ }); + else if (ch === ")") tokens.push({ type: "rparen", value: ch, pos: i++ }); + else if (ch === ",") tokens.push({ type: "comma", value: ch, pos: i++ }); + else if (ch === '"' || ch === "'") { + const start = i++; + let value = ""; + let closed = false; + while (i < input.length) { + const c = input[i]; + if (c === "\\" && i + 1 < input.length) { + value += input[i + 1]; + i += 2; + continue; + } + if (c === ch) { + closed = true; + i++; + break; + } + value += c; + i++; + } + if (!closed) { + throw new JqlError( + `Error in the JQL Query: The quoted string '${value}' has not been completed. (line 1, character ${start + 1})`, + ); + } + tokens.push({ type: "string", value, pos: start }); + } else { + const op = OPERATORS.find((candidate) => input.startsWith(candidate, i)); + if (op) { + tokens.push({ type: "op", value: op, pos: i }); + i += op.length; + continue; + } + if (ch === "!") { + throw new JqlError( + `Error in the JQL Query: The character '!' is a reserved JQL character. (line 1, character ${i + 1})`, + ); + } + const start = i; + while (i < input.length && !WORD_BREAK.test(input[i])) i++; + tokens.push({ type: "word", value: input.slice(start, i), pos: start }); + } + } + tokens.push({ type: "eof", value: "", pos: input.length }); + return tokens; +} + +// AST + +export type Operand = + | { kind: "value"; value: string } + | { kind: "fn"; name: string; args: string[] } + | { kind: "empty" }; + +export type JqlNode = + | { type: "and" | "or"; left: JqlNode; right: JqlNode } + | { type: "not"; expr: JqlNode } + | { type: "clause"; field: string; op: string; operands: Operand[]; listFn?: Operand }; + +export interface OrderBy { + field: string; + direction: "ASC" | "DESC"; +} + +export interface JqlQuery { + where: JqlNode | null; + orderBy: OrderBy[]; +} + +const KEYWORDS = new Set(["and", "or", "not", "in", "is", "empty", "null", "order", "by", "asc", "desc"]); + +class Parser { + private index = 0; + constructor(private readonly tokens: Token[]) {} + + private peek(): Token { + return this.tokens[this.index]; + } + + private next(): Token { + return this.tokens[this.index++]; + } + + private isKeyword(word: string, token = this.peek()): boolean { + return token.type === "word" && token.value.toLowerCase() === word; + } + + private fail(expecting: string): never { + const token = this.peek(); + const got = token.type === "eof" ? "end of query" : `'${token.value}'`; + throw new JqlError( + `Error in the JQL Query: Expecting ${expecting} but got ${got}. (line 1, character ${token.pos + 1})`, + ); + } + + parse(): JqlQuery { + let where: JqlNode | null = null; + if (this.peek().type !== "eof" && !this.isKeyword("order")) where = this.parseOr(); + const orderBy: OrderBy[] = []; + if (this.isKeyword("order")) { + this.next(); + if (!this.isKeyword("by")) this.fail("'by'"); + this.next(); + do { + const token = this.peek(); + if (token.type !== "word" && token.type !== "string") this.fail("a field name"); + this.next(); + let direction: "ASC" | "DESC" = "ASC"; + if (this.isKeyword("asc") || this.isKeyword("desc")) + direction = this.next().value.toUpperCase() as "ASC" | "DESC"; + orderBy.push({ field: token.value, direction }); + } while (this.peek().type === "comma" && this.next()); + } + if (this.peek().type !== "eof") this.fail("either 'OR' or 'AND'"); + return { where, orderBy }; + } + + private parseOr(): JqlNode { + let left = this.parseAnd(); + while (this.isKeyword("or")) { + this.next(); + left = { type: "or", left, right: this.parseAnd() }; + } + return left; + } + + private parseAnd(): JqlNode { + let left = this.parseNot(); + while (this.isKeyword("and")) { + this.next(); + left = { type: "and", left, right: this.parseNot() }; + } + return left; + } + + private parseNot(): JqlNode { + if (this.isKeyword("not")) { + this.next(); + return { type: "not", expr: this.parseNot() }; + } + if (this.peek().type === "lparen") { + this.next(); + const expr = this.parseOr(); + if (this.peek().type !== "rparen") this.fail("')'"); + this.next(); + return expr; + } + return this.parseClause(); + } + + private parseClause(): JqlNode { + const fieldToken = this.peek(); + if ( + (fieldToken.type !== "word" && fieldToken.type !== "string") || + (fieldToken.type === "word" && KEYWORDS.has(fieldToken.value.toLowerCase())) + ) { + this.fail("a field name"); + } + this.next(); + const field = fieldToken.value; + const token = this.peek(); + + if (token.type === "op") { + this.next(); + return { type: "clause", field, op: token.value, operands: [this.parseOperand()] }; + } + if (this.isKeyword("is")) { + this.next(); + let op = "is"; + if (this.isKeyword("not")) { + this.next(); + op = "is not"; + } + if (!this.isKeyword("empty") && !this.isKeyword("null")) this.fail("'EMPTY' or 'NULL'"); + this.next(); + return { type: "clause", field, op, operands: [{ kind: "empty" }] }; + } + let op = "in"; + if (this.isKeyword("not")) { + this.next(); + op = "not in"; + if (!this.isKeyword("in")) this.fail("'IN'"); + } + if (this.isKeyword("in")) { + this.next(); + if (this.peek().type === "lparen") { + this.next(); + const operands: Operand[] = []; + if (this.peek().type !== "rparen") { + operands.push(this.parseOperand()); + while (this.peek().type === "comma") { + this.next(); + operands.push(this.parseOperand()); + } + } + if (this.peek().type !== "rparen") this.fail("')'"); + this.next(); + return { type: "clause", field, op, operands }; + } + const fn = this.parseOperand(); + if (fn.kind !== "fn") this.fail("a list or function"); + return { type: "clause", field, op, operands: [], listFn: fn }; + } + return this.fail("operator"); + } + + private parseOperand(): Operand { + const token = this.peek(); + if (token.type === "string") { + this.next(); + return { kind: "value", value: token.value }; + } + if (token.type !== "word" || ["and", "or", "order", "in", "is", "not"].includes(token.value.toLowerCase())) { + this.fail("either a value, list or function"); + } + this.next(); + const lower = token.value.toLowerCase(); + if (lower === "empty" || lower === "null") return { kind: "empty" }; + if (this.peek().type === "lparen") { + this.next(); + const args: string[] = []; + while (this.peek().type !== "rparen") { + const arg = this.peek(); + if (arg.type !== "word" && arg.type !== "string") this.fail("a function argument"); + args.push(this.next().value); + if (this.peek().type === "comma") this.next(); + else if (this.peek().type !== "rparen") this.fail("')'"); + } + this.next(); + return { kind: "fn", name: token.value, args }; + } + return { kind: "value", value: token.value }; + } +} + +export function parseJql(jql: string): JqlQuery { + return new Parser(tokenize(jql ?? "")).parse(); +} + +// Evaluation + +type Scalar = string | number; +type Resolved = { empty: true } | { empty: false; values: Scalar[]; dayPrecision?: boolean }; + +interface EvalContext { + js: JiraStore; + user: JiraUser; + now: Date; +} + +type FieldKind = "ref" | "text" | "date" | "number" | "priority" | "key"; + +/** Returned by `resolve` for literals such as `Unresolved` that mean "the field is empty". */ +const MATCHES_EMPTY = Symbol("matches empty"); + +interface FieldHandler { + name: string; + kind: FieldKind; + /** Current values of the field for an issue. Empty array means the field is empty. */ + get(issue: JiraIssue, ctx: EvalContext): Scalar[]; + /** Maps a literal from the query to comparable values. Throws for unknown values. */ + resolve?(value: string, ctx: EvalContext): Scalar[] | typeof MATCHES_EMPTY; + /** ORDER BY key. Defaults to the first value from `get`, lowercased. */ + sort?(issue: JiraIssue, ctx: EvalContext): Scalar | null; +} + +const eqi = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); +const lower = (value: string | undefined | null) => value?.toLowerCase() ?? null; + +function notFoundValue(value: string, field: string): JqlError { + return new JqlError(`The value '${value}' does not exist for the field '${field}'.`); +} + +function refResolver( + field: string, + items: (ctx: EvalContext) => T[], + names: (item: T) => string[], +) { + return (value: string, ctx: EvalContext): Scalar[] => { + const matches = items(ctx).filter( + (item) => String(item.id) === value || names(item).some((name) => eqi(name, value)), + ); + if (matches.length === 0) throw notFoundValue(value, field); + return matches.map((item) => item.id); + }; +} + +function userResolver(field: string) { + return (value: string, ctx: EvalContext): Scalar[] => { + const user = findUser(ctx.js, value); + if (!user) throw notFoundValue(value, field); + return [user.account_id]; + }; +} + +function resolveIssue(value: string, ctx: EvalContext): Scalar[] { + const issue = findIssue(ctx.js, value); + if (!issue) throw new JqlError(`An issue with key '${value}' does not exist for field 'key'.`); + return [issue.id]; +} + +function dateOf(value: string | null | undefined): Scalar[] { + if (!value) return []; + const time = Date.parse(value.length === 10 ? `${value}T00:00:00Z` : value); + return Number.isNaN(time) ? [] : [time]; +} + +type TextPart = "summary" | "description" | "environment" | "comment"; + +function issueText(issue: JiraIssue, ctx: EvalContext, parts: TextPart[]): string { + const chunks: string[] = []; + if (parts.includes("summary")) chunks.push(issue.summary); + if (parts.includes("description")) chunks.push(adfToText(issue.description)); + if (parts.includes("environment")) chunks.push(adfToText(issue.environment)); + if (parts.includes("comment")) { + for (const comment of ctx.js.comments.findBy("issue_id", issue.id)) chunks.push(adfToText(comment.body)); + } + return chunks.join("\n"); +} + +const CATEGORY_ALIASES: Record = { + new: ["new", "To Do", "2"], + indeterminate: ["indeterminate", "In Progress", "4"], + done: ["done", "Done", "3"], +}; + +function textField(name: string, parts: TextPart[]): FieldHandler { + return { + name, + kind: "text", + get: (issue, ctx) => { + const value = issueText(issue, ctx, parts); + return value ? [value] : []; + }, + }; +} + +function userField(name: string, key: "assignee_id" | "reporter_id" | "creator_id"): FieldHandler { + return { + name, + kind: "ref", + get: (issue) => (issue[key] ? [issue[key]!] : []), + resolve: userResolver(name), + sort: (issue, ctx) => lower(issue[key] ? ctx.js.users.findOneBy("account_id", issue[key]!)?.display_name : null), + }; +} + +function dateField(name: string, value: (issue: JiraIssue) => string | null): FieldHandler { + return { name, kind: "date", get: (issue) => dateOf(value(issue)) }; +} + +const sprintField: FieldHandler = { + name: "sprint", + kind: "ref", + get: (issue) => [...issue.closed_sprint_ids, ...(issue.sprint_id ? [issue.sprint_id] : [])], + resolve: refResolver( + "sprint", + (ctx) => ctx.js.sprints.all(), + (s) => [s.name], + ), +}; + +/** + * JQL behavior for each searchable system field, keyed by the field id in `SYSTEM_FIELDS`. Clause names + * (`type` for `issuetype`, `due` for `duedate`, ...) come from `SYSTEM_FIELDS`, plus the extra `aliases` below. + */ +const SYSTEM_JQL: Record = { + project: { + name: "project", + kind: "ref", + get: (issue) => [issue.project_id], + resolve: refResolver( + "project", + (ctx) => ctx.js.projects.all(), + (p) => [p.key, p.name], + ), + sort: (issue, ctx) => ctx.js.projects.get(issue.project_id)?.key ?? null, + }, + issuekey: { + name: "key", + kind: "key", + get: (issue) => [issue.id], + resolve: resolveIssue, + sort: (issue, ctx) => + `${ctx.js.projects.get(issue.project_id)?.key ?? ""}-${String(issue.number).padStart(10, "0")}`, + }, + summary: { ...textField("summary", ["summary"]), sort: (issue) => issue.summary.toLowerCase() }, + description: textField("description", ["description"]), + environment: textField("environment", ["environment"]), + comment: textField("comment", ["comment"]), + text: { ...textField("text", ["summary", "description", "environment", "comment"]), aliases: ["text"] }, + status: { + name: "status", + kind: "ref", + get: (issue) => [issue.status_id], + resolve: refResolver( + "status", + (ctx) => ctx.js.statuses.all(), + (s) => [s.name], + ), + sort: (issue, ctx) => lower(ctx.js.statuses.get(issue.status_id)?.name), + }, + statusCategory: { + name: "statusCategory", + kind: "ref", + get: (issue, ctx) => { + const category = ctx.js.statuses.get(issue.status_id)?.category; + return category ? [category] : []; + }, + resolve: (value) => { + const match = (Object.keys(CATEGORY_ALIASES) as JiraStatusCategory[]).find((key) => + CATEGORY_ALIASES[key].some((alias) => eqi(alias, value)), + ); + if (!match) throw notFoundValue(value, "statusCategory"); + return [match]; + }, + }, + assignee: userField("assignee", "assignee_id"), + reporter: userField("reporter", "reporter_id"), + creator: userField("creator", "creator_id"), + watches: { name: "watcher", kind: "ref", get: (issue) => [...issue.watcher_ids], resolve: userResolver("watcher") }, + priority: { + name: "priority", + kind: "priority", + get: (issue) => (issue.priority_id ? [issue.priority_id] : []), + resolve: refResolver( + "priority", + (ctx) => ctx.js.priorities.all(), + (p) => [p.name], + ), + // Priorities are only seeded, in order from Highest to Lowest, so a lower id is more urgent. + sort: (issue) => (issue.priority_id ? -issue.priority_id : null), + }, + issuetype: { + name: "issuetype", + kind: "ref", + get: (issue) => [issue.issue_type_id], + resolve: refResolver( + "issuetype", + (ctx) => ctx.js.issueTypes.all(), + (t) => [t.name], + ), + sort: (issue, ctx) => lower(ctx.js.issueTypes.get(issue.issue_type_id)?.name), + }, + labels: { + name: "labels", + kind: "ref", + get: (issue) => [...issue.labels], + resolve: (value) => [value], + aliases: ["label"], + }, + resolution: { + name: "resolution", + kind: "ref", + get: (issue) => (issue.resolution_id ? [issue.resolution_id] : []), + resolve: (value, ctx) => + eqi(value, "unresolved") + ? MATCHES_EMPTY + : refResolver( + "resolution", + (c) => c.js.resolutions.all(), + (r) => [r.name], + )(value, ctx), + }, + parent: { + name: "parent", + kind: "ref", + get: (issue) => (issue.parent_id ? [issue.parent_id] : []), + resolve: resolveIssue, + }, + created: dateField("created", (issue) => issue.created_at), + updated: dateField("updated", (issue) => issue.updated_at), + duedate: dateField("duedate", (issue) => issue.due_date), + resolutiondate: dateField("resolutiondate", (issue) => issue.resolution_date), + statuscategorychangedate: dateField("statusCategoryChangedDate", (issue) => issue.status_changed_at), + components: { + name: "component", + kind: "ref", + get: (issue) => [...issue.component_ids], + resolve: refResolver( + "component", + (ctx) => ctx.js.components.all(), + (c) => [c.name], + ), + }, + fixVersions: { + name: "fixVersion", + kind: "ref", + get: (issue) => [...issue.fix_version_ids], + resolve: refResolver( + "fixVersion", + (ctx) => ctx.js.versions.all(), + (v) => [v.name], + ), + }, + sprint: { ...sprintField, aliases: ["sprint"] }, +}; + +/** Clause name (lowercased) to handler, built once from `SYSTEM_FIELDS` clause names and `SYSTEM_JQL` aliases. */ +const SYSTEM_CLAUSES = new Map(); +for (const field of SYSTEM_FIELDS) { + const handler = SYSTEM_JQL[field.id]; + if (!handler) continue; + for (const clause of [field.id, ...field.clauseNames]) SYSTEM_CLAUSES.set(clause.toLowerCase(), handler); +} +for (const handler of Object.values(SYSTEM_JQL)) { + for (const alias of handler.aliases ?? []) SYSTEM_CLAUSES.set(alias.toLowerCase(), handler); +} + +/** ORDER BY Rank is accepted and falls back to creation order, as the emulator has no rank field. */ +const rankSort: FieldHandler = { + name: "rank", + kind: "number", + get: () => [], + sort: (issue) => issue.id, +}; + +function resolveFieldHandler(js: JiraStore, name: string): FieldHandler { + const system = SYSTEM_CLAUSES.get(name.toLowerCase()); + if (system) return system; + const def = findField(js, name); + const custom = def?.custom ? js.customFields.findOneBy("field_id", def.id) : undefined; + if (!custom) throw new JqlError(`Field '${name}' does not exist or you do not have permission to view it.`); + if (custom.type === "sprint") return sprintField; + const raw = (issue: JiraIssue): Scalar[] => { + const value = issue.custom_fields[custom.field_id]; + if (value === null || value === undefined) return []; + return Array.isArray(value) ? value.map(String) : [typeof value === "number" ? value : String(value)]; + }; + switch (custom.type) { + case "number": + return { name: custom.name, kind: "number", get: raw, resolve: (value) => [Number(value)] }; + case "date": + case "datetime": + return dateField(custom.name, (issue) => issue.custom_fields[custom.field_id] as string | null); + case "string": + return { name: custom.name, kind: "text", get: raw }; + case "user": + return { name: custom.name, kind: "ref", get: raw, resolve: userResolver(custom.name) }; + default: + return { name: custom.name, kind: "ref", get: raw, resolve: (value) => [value] }; + } +} + +function sortHandler(js: JiraStore, name: string): FieldHandler { + return name.toLowerCase() === "rank" ? rankSort : resolveFieldHandler(js, name); +} + +// Functions + +const DAY = 24 * 3600 * 1000; + +const FUNCTION_UNITS: Record<"day" | "week" | "month" | "year", string> = { + day: "d", + week: "w", + month: "M", + year: "y", +}; + +/** + * Shifts a date by a Jira period such as `-1d`, `+2w`, `-1M`, `1y`, or `-1w 2d`. Units are + * y (years), M (months), w (weeks), d (days), h (hours), and m (minutes). A bare number uses + * `defaultUnit`, which date functions set to their own unit. Returns null for invalid input. + */ +function shiftDate(base: Date, value: string, defaultUnit?: string): Date | null { + const trimmed = value.replace(/\s+/g, ""); + const match = /^([+-]?)((?:\d+[yMwdhm])+|\d+)$/.exec(trimmed); + if (!match) return null; + const sign = match[1] === "-" ? -1 : 1; + const parts = /^\d+$/.test(match[2]) + ? defaultUnit + ? [`${match[2]}${defaultUnit}`] + : [] + : match[2].match(/\d+[yMwdhm]/g); + if (!parts || parts.length === 0) return null; + const date = new Date(base); + for (const part of parts) { + const amount = sign * Number(part.slice(0, -1)); + const unit = part.slice(-1); + if (unit === "y") date.setUTCFullYear(date.getUTCFullYear() + amount); + else if (unit === "M") date.setUTCMonth(date.getUTCMonth() + amount); + else if (unit === "w") date.setTime(date.getTime() + amount * 7 * DAY); + else if (unit === "d") date.setTime(date.getTime() + amount * DAY); + else if (unit === "h") date.setTime(date.getTime() + amount * 3600 * 1000); + else date.setTime(date.getTime() + amount * 60 * 1000); + } + return date; +} + +function applyOffset(base: Date, offset: string | undefined, defaultUnit: string): Date { + if (!offset) return base; + const shifted = shiftDate(base, offset, defaultUnit); + if (!shifted) throw new JqlError(`Invalid date offset '${offset}'.`); + return shifted; +} + +function startOf(unit: "day" | "week" | "month" | "year", date: Date): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + if (unit === "week") d.setUTCDate(d.getUTCDate() - d.getUTCDay()); + if (unit === "month") d.setUTCDate(1); + if (unit === "year") d.setUTCMonth(0, 1); + return d; +} + +function endOf(unit: "day" | "week" | "month" | "year", date: Date): Date { + const start = startOf(unit, date); + const next = new Date(start); + if (unit === "day") next.setUTCDate(next.getUTCDate() + 1); + if (unit === "week") next.setUTCDate(next.getUTCDate() + 7); + if (unit === "month") next.setUTCMonth(next.getUTCMonth() + 1); + if (unit === "year") next.setUTCFullYear(next.getUTCFullYear() + 1); + return new Date(next.getTime() - 1); +} + +function callFunction(fn: { name: string; args: string[] }, ctx: EvalContext): Scalar[] { + const name = fn.name.toLowerCase(); + const js = ctx.js; + const dateFn = /^(start|end)of(day|week|month|year)$/.exec(name); + if (dateFn) { + const unit = dateFn[2] as "day" | "week" | "month" | "year"; + const base = dateFn[1] === "start" ? startOf(unit, ctx.now) : endOf(unit, ctx.now); + return [applyOffset(base, fn.args[0], FUNCTION_UNITS[unit]).getTime()]; + } + switch (name) { + case "currentuser": + return [ctx.user.account_id]; + case "now": + return [ctx.now.getTime()]; + case "opensprints": + return js.sprints + .all() + .filter((s) => s.state !== "closed") + .map((s) => s.id); + case "closedsprints": + return js.sprints + .all() + .filter((s) => s.state === "closed") + .map((s) => s.id); + case "futuresprints": + return js.sprints + .all() + .filter((s) => s.state === "future") + .map((s) => s.id); + case "standardissuetypes": + return js.issueTypes + .all() + .filter((t) => !t.subtask) + .map((t) => t.id); + case "subtaskissuetypes": + return js.issueTypes + .all() + .filter((t) => t.subtask) + .map((t) => t.id); + case "releasedversions": + case "unreleasedversions": { + const released = name === "releasedversions"; + const project = fn.args[0] + ? js.projects.all().find((p) => eqi(p.key, fn.args[0]) || String(p.id) === fn.args[0]) + : undefined; + return js.versions + .all() + .filter((v) => v.released === released && (!project || v.project_id === project.id)) + .map((v) => v.id); + } + case "linkedissues": { + const [ref, linkText] = fn.args; + if (!ref) throw new JqlError("Function 'linkedIssues' expects at least one argument."); + const issue = /^\d+$/.test(ref) ? js.issues.get(Number(ref)) : js.issues.all().find((i) => eqi(i.key, ref)); + if (!issue) throw new JqlError(`Issue '${ref}' could not be found in function 'linkedIssues'.`); + const results: number[] = []; + for (const link of js.issueLinks.findBy("inward_issue_id", issue.id)) { + const type = js.issueLinkTypes.get(link.type_id); + if (!linkText || (type && eqi(type.inward, linkText))) results.push(link.outward_issue_id); + } + for (const link of js.issueLinks.findBy("outward_issue_id", issue.id)) { + const type = js.issueLinkTypes.get(link.type_id); + if (!linkText || (type && eqi(type.outward, linkText))) results.push(link.inward_issue_id); + } + return results; + } + default: + throw new JqlError(`Unable to find JQL function '${fn.name}()'.`); + } +} + +function parseDateLiteral(value: string, ctx: EvalContext): { time: number; dayPrecision: boolean } { + const relative = shiftDate(ctx.now, value); + if (relative) return { time: relative.getTime(), dayPrecision: false }; + const match = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?$/.exec(value.trim()); + if (!match) + throw new JqlError( + `Date value '${value}' for field is invalid. Valid formats include: 'yyyy/MM/dd HH:mm', 'yyyy-MM-dd HH:mm', 'yyyy/MM/dd', 'yyyy-MM-dd', or a period format e.g. '-5d', '4w 2d'.`, + ); + const [, y, m, d, hh, mm] = match; + const time = Date.UTC(Number(y), Number(m) - 1, Number(d), Number(hh ?? 0), Number(mm ?? 0)); + return { time, dayPrecision: hh === undefined }; +} + +function resolveOperand(handler: FieldHandler, operand: Operand, ctx: EvalContext): Resolved { + if (operand.kind === "empty") return { empty: true }; + if (operand.kind === "fn") return { empty: false, values: callFunction(operand, ctx) }; + if (handler.kind === "date") { + const parsed = parseDateLiteral(operand.value, ctx); + return { empty: false, values: [parsed.time], dayPrecision: parsed.dayPrecision }; + } + if (handler.kind === "text") return { empty: false, values: [operand.value] }; + if (handler.kind === "number") { + const number = Number(operand.value); + if (!Number.isFinite(number)) + throw new JqlError(`The value '${operand.value}' is not a valid number for '${handler.name}'.`); + return { empty: false, values: [number] }; + } + const values = handler.resolve ? handler.resolve(operand.value, ctx) : [operand.value]; + return values === MATCHES_EMPTY ? { empty: true } : { empty: false, values }; +} + +function wordsOf(text: string): string[] { + return text + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean); +} + +function textMatches(haystack: string, query: string): boolean { + const trimmed = query.trim(); + const phrase = /^"(.*)"$/.exec(trimmed); + if (phrase) return haystack.toLowerCase().includes(phrase[1].toLowerCase()); + const words = wordsOf(haystack); + const terms = trimmed.toLowerCase().split(/\s+/).filter(Boolean); + return terms.every((term) => { + const prefix = term.replace(/[*?]+$/, "").replace(/[^\p{L}\p{N}]/gu, ""); + if (!prefix) return true; + return words.some((word) => word.startsWith(prefix)); + }); +} + +function compileClause(node: Extract, ctx: EvalContext): (issue: JiraIssue) => boolean { + const handler = resolveFieldHandler(ctx.js, node.field); + const op = node.op.toLowerCase(); + + if (op === "is" || op === "is not") { + return (issue) => (handler.get(issue, ctx).length === 0) === (op === "is"); + } + + if (op === "~" || op === "!~") { + if (handler.kind !== "text") { + throw new JqlError(`The operator '${node.op}' is not supported by the '${node.field}' field.`); + } + const operand = node.operands[0]; + if (operand.kind !== "value") throw new JqlError(`The operator '${node.op}' requires a text value.`); + return (issue) => { + const text = handler.get(issue, ctx)[0]; + const matches = text !== undefined && textMatches(String(text), operand.value); + return op === "~" ? matches : text !== undefined && !matches; + }; + } + + const operands = node.listFn ? [node.listFn] : node.operands; + const resolved = operands.map((operand) => resolveOperand(handler, operand, ctx)); + const wantsEmpty = resolved.some((entry) => entry.empty); + const values = resolved.flatMap((entry) => (entry.empty ? [] : entry.values)); + const dayPrecision = resolved.some((entry) => !entry.empty && entry.dayPrecision); + + const eq = (a: Scalar, b: Scalar) => + handler.kind === "date" && dayPrecision + ? Math.floor(Number(a) / DAY) === Math.floor(Number(b) / DAY) + : typeof a === "string" && typeof b === "string" + ? eqi(a, b) + : a === b; + + const matchesAny = (issue: JiraIssue) => { + const current = handler.get(issue, ctx); + if (current.length === 0) return wantsEmpty; + return current.some((value) => values.some((candidate) => eq(value, candidate))); + }; + + if (op === "=" || op === "in") return matchesAny; + if (op === "!=" || op === "not in") { + // Like Jira, negative operators never match issues where the field is empty. + return (issue) => handler.get(issue, ctx).length > 0 && !matchesAny(issue); + } + + // Ordering comparisons + if (!["date", "number", "priority", "key"].includes(handler.kind)) { + throw new JqlError(`The operator '${node.op}' is not supported by the '${node.field}' field.`); + } + if (values.length !== 1) throw new JqlError(`The operator '${node.op}' requires a single value.`); + const target = values[0]; + const cmp = (value: Scalar): number => { + if (handler.kind === "priority") { + // Lower priority ids are more urgent, so "priority > Medium" means Highest and High. + return Number(target) - Number(value); + } + if (handler.kind === "key") { + const issue = ctx.js.issues.get(Number(value)); + const other = ctx.js.issues.get(Number(target)); + if (!issue || !other || issue.project_id !== other.project_id) return Number.NaN; + return issue.number - other.number; + } + if (handler.kind === "date" && dayPrecision && (op === "<=" || op === ">")) { + // A bare date covers the whole day, so "<= 2026-10-15" includes that day. + return Number(value) - (Number(target) + DAY - 1); + } + return Number(value) - Number(target); + }; + const test: Record boolean> = { + ">": (n) => n > 0, + ">=": (n) => n >= 0, + "<": (n) => n < 0, + "<=": (n) => n <= 0, + }; + return (issue) => handler.get(issue, ctx).some((value) => test[op](cmp(value))); +} + +function compile(node: JqlNode, ctx: EvalContext): (issue: JiraIssue) => boolean { + switch (node.type) { + case "and": { + const left = compile(node.left, ctx); + const right = compile(node.right, ctx); + return (issue) => left(issue) && right(issue); + } + case "or": { + const left = compile(node.left, ctx); + const right = compile(node.right, ctx); + return (issue) => left(issue) || right(issue); + } + case "not": { + const inner = compile(node.expr, ctx); + return (issue) => !inner(issue); + } + case "clause": + return compileClause(node, ctx); + } +} + +function sortValue(handler: FieldHandler, issue: JiraIssue, ctx: EvalContext): Scalar | null { + if (handler.sort) return handler.sort(issue, ctx); + const value = handler.get(issue, ctx)[0]; + if (value === undefined) return null; + return typeof value === "string" ? value.toLowerCase() : value; +} + +function sortIssues(issues: JiraIssue[], orderBy: OrderBy[], ctx: EvalContext): JiraIssue[] { + const order = orderBy.length > 0 ? orderBy : [{ field: "created", direction: "DESC" as const }]; + const handlers = order.map((entry) => sortHandler(ctx.js, entry.field)); + const keyed = issues.map((issue) => ({ issue, keys: handlers.map((handler) => sortValue(handler, issue, ctx)) })); + keyed.sort((a, b) => { + for (let i = 0; i < order.length; i++) { + const av = a.keys[i]; + const bv = b.keys[i]; + if (av === bv) continue; + if (av === null) return 1; + if (bv === null) return -1; + const diff = av < bv ? -1 : 1; + return order[i].direction === "DESC" ? -diff : diff; + } + return order[0].direction === "DESC" ? b.issue.id - a.issue.id : a.issue.id - b.issue.id; + }); + return keyed.map((entry) => entry.issue); +} + +/** + * Compiles JQL into a predicate for testing single issues, such as webhook filters. Unknown fields and values + * (including ORDER BY fields) throw JqlError up front. ORDER BY itself is ignored. + */ +export function compileJql( + js: JiraStore, + jql: string, + user: JiraUser, + now = new Date(), +): (issue: JiraIssue) => boolean { + const query = parseJql(jql); + const ctx: EvalContext = { js, user, now }; + for (const entry of query.orderBy) sortHandler(js, entry.field); + return query.where ? compile(query.where, ctx) : () => true; +} + +/** Runs a JQL query against the store. Throws JqlError for invalid queries. */ +export function searchIssues(js: JiraStore, jql: string, user: JiraUser, now = new Date()): JiraIssue[] { + const query = parseJql(jql); + return runQuery(js, query, user, now); +} + +export function runQuery(js: JiraStore, query: JqlQuery, user: JiraUser, now = new Date()): JiraIssue[] { + const ctx: EvalContext = { js, user, now }; + const predicate = query.where ? compile(query.where, ctx) : () => true; + // Resolving the sort handlers inside sortIssues also rejects unknown ORDER BY fields when nothing matches. + return sortIssues(js.issues.all().filter(predicate), query.orderBy, ctx); +} diff --git a/packages/@emulators/jira/src/lookup.ts b/packages/@emulators/jira/src/lookup.ts new file mode 100644 index 00000000..ced2f449 --- /dev/null +++ b/packages/@emulators/jira/src/lookup.ts @@ -0,0 +1,82 @@ +import type { Collection, Entity } from "@emulators/core"; +import type { JiraStore } from "./store.js"; +import { JiraError, issueNotFound, type PageParams } from "./context.js"; + +const eqi = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); +const asId = (ref: string | number) => (/^\d+$/.test(String(ref)) ? Number(ref) : undefined); + +type Ref = string | number | undefined | null; + +/** Looks an entity up by numeric id, or else by a case-insensitive name. */ +function findByIdOrName(collection: Collection, ref: Ref, names: (item: T) => string[]) { + if (ref === undefined || ref === null || ref === "") return undefined; + const id = asId(ref); + if (id !== undefined) return collection.get(id); + return collection.all().find((item) => names(item).some((name) => eqi(name, String(ref)))); +} + +/** Project keys are stored uppercase, so key lookups use the index. */ +export function findProject(js: JiraStore, ref: Ref) { + if (ref === undefined || ref === null || ref === "") return undefined; + const id = asId(ref); + return id !== undefined ? js.projects.get(id) : js.projects.findOneBy("key", String(ref).toUpperCase()); +} + +export function requireProject(js: JiraStore, ref: string) { + const project = findProject(js, ref); + if (!project) throw new JiraError(404, [`No project could be found with key '${ref}'.`]); + return project; +} + +/** Issue keys inherit the uppercase project key, so key lookups use the index. */ +export function findIssue(js: JiraStore, ref: Ref) { + if (ref === undefined || ref === null || ref === "") return undefined; + const id = asId(ref); + return id !== undefined ? js.issues.get(id) : js.issues.findOneBy("key", String(ref).toUpperCase()); +} + +export function requireIssue(js: JiraStore, ref: string) { + const issue = findIssue(js, ref); + if (!issue) throw issueNotFound(); + return issue; +} + +export function findUser(js: JiraStore, ref: string | undefined | null) { + if (!ref) return undefined; + return ( + js.users.findOneBy("account_id", ref) ?? + js.users.all().find((user) => eqi(user.email, ref) || eqi(user.display_name, ref)) + ); +} + +export const findIssueType = (js: JiraStore, ref: Ref) => findByIdOrName(js.issueTypes, ref, (type) => [type.name]); +export const findStatus = (js: JiraStore, ref: Ref) => findByIdOrName(js.statuses, ref, (status) => [status.name]); +export const findPriority = (js: JiraStore, ref: Ref) => + findByIdOrName(js.priorities, ref, (priority) => [priority.name]); +export const findResolution = (js: JiraStore, ref: Ref) => + findByIdOrName(js.resolutions, ref, (resolution) => [resolution.name]); + +export function findComponent(js: JiraStore, projectId: number, ref: { id?: string | number; name?: string }) { + const components = js.components.findBy("project_id", projectId); + if (ref.id !== undefined) return components.find((component) => component.id === Number(ref.id)); + if (ref.name) return components.find((component) => eqi(component.name, ref.name!)); + return undefined; +} + +export function findVersion(js: JiraStore, projectId: number, ref: { id?: string | number; name?: string }) { + const versions = js.versions.findBy("project_id", projectId); + if (ref.id !== undefined) return versions.find((version) => version.id === Number(ref.id)); + if (ref.name) return versions.find((version) => eqi(version.name, ref.name!)); + return undefined; +} + +export function paginate(items: T[], { startAt, maxResults }: PageParams) { + const values = items.slice(startAt, startAt + maxResults); + return { + startAt, + maxResults, + total: items.length, + isLast: startAt + values.length >= items.length, + values, + }; +} diff --git a/packages/@emulators/jira/src/routes/agile.ts b/packages/@emulators/jira/src/routes/agile.ts new file mode 100644 index 00000000..0b7a399e --- /dev/null +++ b/packages/@emulators/jira/src/routes/agile.ts @@ -0,0 +1,440 @@ +import type { RouteContext } from "@emulators/core"; +import { JiraError, listParam, makeHandler, pageParams, READ, readJson, WRITE, type JiraRequest } from "../context.js"; +import { avatarUrls, formatStatus, projectStatuses } from "../formatters.js"; +import { formatIssue, formatSprint, parseFieldSelection } from "../issue-format.js"; +import { editIssue } from "../issue-service.js"; +import { compileJql, JqlError } from "../jql.js"; +import { findIssue, findProject, paginate, requireIssue } from "../lookup.js"; +import { emitEditEvents } from "../webhooks.js"; +import { deleteBoardRecord, deleteSprintRecord } from "../services.js"; +import type { JiraBoard, JiraIssue, JiraSprint, JiraSprintState } from "../entities.js"; + +const A = "/rest/agile/1.0"; +const PREFIXES = [A, "/rest/software/1.0"]; + +function sprintName(value: unknown): string { + const name = String(value ?? "").trim(); + if (!name) throw new JiraError(400, [], { name: "Sprint name is required." }); + if (name.length > 30) throw new JiraError(400, [], { name: "Sprint name must be 30 characters or fewer." }); + return name; +} + +/** Reads an optional sprint date (an ISO 8601 date-time) and stores it normalized. */ +function sprintDate(field: string, value: unknown): string | null { + if (value === undefined || value === null || value === "") return null; + const time = typeof value === "string" ? Date.parse(value) : Number.NaN; + if (Number.isNaN(time)) throw new JiraError(400, [], { [field]: `Invalid date '${String(value)}'.` }); + return new Date(time).toISOString(); +} + +export function agileRoutes({ app, store, baseUrl }: RouteContext): void { + const handle = makeHandler(store, baseUrl); + + const agileUrl = (r: JiraRequest, path: string) => `${r.siteUrl}${A}${path}`; + + const formatBoard = (r: JiraRequest, board: JiraBoard) => { + const project = r.js.projects.get(board.project_id); + return { + id: board.id, + self: agileUrl(r, `/board/${board.id}`), + name: board.name, + type: board.type, + location: project + ? { + projectId: project.id, + displayName: `${project.name} (${project.key})`, + projectName: project.name, + projectKey: project.key, + projectTypeKey: project.project_type_key, + avatarURI: avatarUrls(`project-${project.key}`)["48x48"], + name: `${project.name} (${project.key})`, + } + : undefined, + }; + }; + + const requireBoard = (r: JiraRequest) => { + const board = r.js.boards.get(Number(r.c.req.param("boardId"))); + if (!board) throw new JiraError(404, [`Board does not exist or you do not have permission to see it.`]); + return board; + }; + + const requireSprint = (r: JiraRequest) => { + const sprint = r.js.sprints.get(Number(r.c.req.param("sprintId"))); + if (!sprint) + throw new JiraError(404, [ + `Sprint with id ${r.c.req.param("sprintId")} does not exist or you do not have permission to see it.`, + ]); + return sprint; + }; + + const sprintFieldId = (r: JiraRequest) => + r.js.customFields.all().find((field) => field.type === "sprint")?.field_id ?? "customfield_10020"; + + /** Filters issues by an optional `jql` param, then pages and formats them the Agile way. */ + const issuePage = (r: JiraRequest, candidates: JiraIssue[]) => { + let issues = candidates; + const jql = r.c.req.query("jql"); + if (jql) { + try { + issues = issues.filter(compileJql(r.js, jql, r.user)); + } catch (err) { + if (err instanceof JqlError) throw new JiraError(400, [err.message]); + throw err; + } + } + issues = [...issues].sort((a, b) => a.id - b.id); + const { startAt, maxResults } = pageParams(r.c, 50, 100); + const options = { fields: parseFieldSelection(listParam(r.c, "fields"), true), expand: listParam(r.c, "expand") }; + return { + expand: "schema,names", + startAt, + maxResults, + total: issues.length, + isLast: startAt + maxResults >= issues.length, + issues: issues.slice(startAt, startAt + maxResults).map((issue) => formatIssue(r, issue, options)), + }; + }; + + /** Moves issues into a sprint (or the backlog when sprintId is null) through the normal edit path. */ + const moveIssues = async (r: JiraRequest, refs: unknown, sprintId: number | null) => { + if (!Array.isArray(refs) || refs.length === 0) throw new JiraError(400, ["At least one issue must be specified."]); + if (refs.length > 50) throw new JiraError(400, ["At most 50 issues can be moved at once."]); + const issues = refs.map((ref) => { + const issue = findIssue(r.js, String(ref)); + if (!issue) + throw new JiraError(400, [`Issue '${String(ref)}' does not exist or you do not have permission to see it.`]); + return issue; + }); + for (const issue of issues) { + await emitEditEvents(r, editIssue(r, issue, { fields: { [sprintFieldId(r)]: sprintId } })); + } + }; + + // Jira Cloud serves the Agile API under both prefixes. + for (const P of PREFIXES) { + // Boards + + app.get( + `${P}/board`, + handle( + (r) => { + const project = r.c.req.query("projectKeyOrId"); + const type = r.c.req.query("type"); + const name = r.c.req.query("name")?.toLowerCase(); + const projectId = project ? findProject(r.js, project)?.id : undefined; + if (project && projectId === undefined) { + throw new JiraError(400, [`No project could be found with key or id '${project}'.`]); + } + const boards = r.js.boards + .all() + .filter( + (board) => + (projectId === undefined || board.project_id === projectId) && + (!type || type.split(",").includes(board.type)) && + (!name || board.name.toLowerCase().includes(name)), + ); + const page = paginate(boards, pageParams(r.c, 50)); + return r.c.json({ ...page, values: page.values.map((board) => formatBoard(r, board)) }); + }, + { scopes: READ }, + ), + ); + + app.post( + `${P}/board`, + handle( + async (r) => { + const body = await readJson(r.c); + const name = String(body.name ?? "").trim(); + if (!name) throw new JiraError(400, [], { name: "Board name is required." }); + if (body.type !== "scrum" && body.type !== "kanban") { + throw new JiraError(400, [], { type: "Board type must be 'scrum' or 'kanban'." }); + } + const project = findProject(r.js, body.location?.projectKeyOrId); + if (!project) { + throw new JiraError(400, ["A board must have a project location with a valid projectKeyOrId."]); + } + const board = r.js.boards.insert({ name, type: body.type, project_id: project.id }); + return r.c.json(formatBoard(r, board), 201); + }, + { scopes: WRITE }, + ), + ); + + app.get( + `${P}/board/:boardId`, + handle((r) => r.c.json(formatBoard(r, requireBoard(r))), { scopes: READ }), + ); + + app.delete( + `${P}/board/:boardId`, + handle( + (r) => { + deleteBoardRecord(r.js, requireBoard(r)); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + app.get( + `${P}/board/:boardId/configuration`, + handle( + (r) => { + const board = requireBoard(r); + const project = r.js.projects.get(board.project_id); + const statuses = project ? projectStatuses(r, project) : []; + return r.c.json({ + id: board.id, + name: board.name, + type: board.type, + self: agileUrl(r, `/board/${board.id}/configuration`), + location: { type: "project", key: project?.key, id: project ? String(project.id) : undefined }, + filter: { id: String(board.id), self: `${r.siteUrl}/rest/api/3/filter/${board.id}` }, + columnConfig: { + columns: statuses.map((status) => ({ + name: status.name, + statuses: [{ id: String(status.id), self: formatStatus(r, status)!.self }], + })), + constraintType: "none", + }, + ranking: { rankCustomFieldId: 10019 }, + }); + }, + { scopes: READ }, + ), + ); + + app.get( + `${P}/board/:boardId/issue`, + handle((r) => r.c.json(issuePage(r, r.js.issues.findBy("project_id", requireBoard(r).project_id))), { + scopes: READ, + }), + ); + + app.get( + `${P}/board/:boardId/backlog`, + handle( + (r) => { + const board = requireBoard(r); + const backlog = r.js.issues + .findBy("project_id", board.project_id) + .filter((issue) => issue.sprint_id === null); + return r.c.json(issuePage(r, backlog)); + }, + { scopes: READ }, + ), + ); + + app.get( + `${P}/board/:boardId/sprint`, + handle( + (r) => { + const board = requireBoard(r); + const states = listParam(r.c, "state"); + const sprints = r.js.sprints + .findBy("board_id", board.id) + .filter((sprint) => states.length === 0 || states.includes(sprint.state)) + .sort((a, b) => a.id - b.id); + const page = paginate(sprints, pageParams(r.c, 50)); + return r.c.json({ ...page, values: page.values.map((sprint) => formatSprint(r, sprint)) }); + }, + { scopes: READ }, + ), + ); + + app.get( + `${P}/board/:boardId/epic`, + handle( + (r) => { + const board = requireBoard(r); + const epics = r.js.issues + .findBy("project_id", board.project_id) + .filter((issue) => r.js.issueTypes.get(issue.issue_type_id)?.hierarchy_level === 1) + .sort((a, b) => a.id - b.id); + const page = paginate(epics, pageParams(r.c, 50)); + return r.c.json({ + ...page, + values: page.values.map((epic) => ({ + id: epic.id, + key: epic.key, + self: agileUrl(r, `/epic/${epic.id}`), + name: epic.summary, + summary: epic.summary, + color: { key: "color_1" }, + done: r.js.statuses.get(epic.status_id)?.category === "done", + })), + }); + }, + { scopes: READ }, + ), + ); + + // Sprints + + app.post( + `${P}/sprint`, + handle( + async (r) => { + const body = await readJson(r.c); + const name = sprintName(body.name); + const board = r.js.boards.get(Number(body.originBoardId)); + if (!board) + throw new JiraError(400, [], { + originBoardId: "Board does not exist or you do not have permission to see it.", + }); + if (board.type !== "scrum") throw new JiraError(400, ["Sprints can only be created on scrum boards."]); + const sprint = r.js.sprints.insert({ + board_id: board.id, + name, + state: "future", + goal: body.goal ?? "", + start_date: sprintDate("startDate", body.startDate), + end_date: sprintDate("endDate", body.endDate), + complete_date: null, + }); + return r.c.json(formatSprint(r, sprint), 201); + }, + { scopes: WRITE }, + ), + ); + + app.get( + `${P}/sprint/:sprintId`, + handle((r) => r.c.json(formatSprint(r, requireSprint(r))), { scopes: READ }), + ); + + const updateSprint = () => + handle( + async (r) => { + const sprint = requireSprint(r); + const body = await readJson(r.c); + const next: JiraSprint = { + ...sprint, + name: body.name !== undefined ? sprintName(body.name) : sprint.name, + goal: typeof body.goal === "string" ? body.goal : sprint.goal, + start_date: body.startDate !== undefined ? sprintDate("startDate", body.startDate) : sprint.start_date, + end_date: body.endDate !== undefined ? sprintDate("endDate", body.endDate) : sprint.end_date, + }; + const target = (body.state ?? sprint.state) as JiraSprintState; + if (target !== sprint.state) { + const allowed: Record = { + future: ["active"], + active: ["closed"], + closed: [], + }; + if (!allowed[sprint.state].includes(target)) { + throw new JiraError(400, [`Cannot change the sprint state from ${sprint.state} to ${target}.`]); + } + if (target === "active" && (!next.start_date || !next.end_date)) { + throw new JiraError(400, ["The sprint must have a start date and an end date before it can be started."]); + } + next.state = target; + if (target === "closed") next.complete_date = new Date().toISOString(); + } + const { id, created_at: _c, updated_at: _u, ...data } = next; + const updated = r.js.sprints.update(id, data)!; + if (sprint.state !== "closed" && updated.state === "closed") await closeSprintIssues(r, updated); + return r.c.json(formatSprint(r, updated)); + }, + { scopes: WRITE }, + ); + app.post(`${P}/sprint/:sprintId`, updateSprint()); + app.put(`${P}/sprint/:sprintId`, updateSprint()); + + app.delete( + `${P}/sprint/:sprintId`, + handle( + (r) => { + const sprint = requireSprint(r); + if (sprint.state === "closed") throw new JiraError(400, ["Closed sprints cannot be deleted."]); + deleteSprintRecord(r.js, sprint); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + app.get( + `${P}/sprint/:sprintId/issue`, + handle( + (r) => { + const sprint = requireSprint(r); + const issues = r.js.issues + .all() + .filter((issue) => issue.sprint_id === sprint.id || issue.closed_sprint_ids.includes(sprint.id)); + return r.c.json(issuePage(r, issues)); + }, + { scopes: READ }, + ), + ); + + app.post( + `${P}/sprint/:sprintId/issue`, + handle( + async (r) => { + const sprint = requireSprint(r); + if (sprint.state === "closed") throw new JiraError(400, ["Issues cannot be moved to a closed sprint."]); + const body = await readJson(r.c); + await moveIssues(r, body.issues, sprint.id); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + const moveToBacklog = handle( + async (r) => { + const body = await readJson(r.c); + await moveIssues(r, body.issues, null); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ); + app.post(`${P}/backlog/issue`, moveToBacklog); + app.post(`${P}/backlog/:boardId/issue`, moveToBacklog); + + // Issues and epics + + app.get( + `${P}/issue/:key`, + handle( + (r) => + r.c.json( + formatIssue(r, requireIssue(r.js, r.c.req.param("key")), { + fields: parseFieldSelection(listParam(r.c, "fields"), true), + expand: listParam(r.c, "expand"), + }), + ), + { scopes: READ }, + ), + ); + + app.get( + `${P}/epic/:key/issue`, + handle( + (r) => { + const epic = requireIssue(r.js, r.c.req.param("key")); + return r.c.json(issuePage(r, r.js.issues.findBy("parent_id", epic.id))); + }, + { scopes: READ }, + ), + ); + } +} + +/** + * Completed issues stay in the closed sprint. Everything else goes back to the backlog through the normal + * edit path, so the move is recorded in the changelog and sent to webhooks. + */ +async function closeSprintIssues(r: JiraRequest, sprint: JiraSprint): Promise { + for (const issue of r.js.issues.findBy("sprint_id", sprint.id)) { + if (r.js.statuses.get(issue.status_id)?.category === "done") continue; + const result = editIssue(r, issue, {}, (draft) => { + draft.sprint_id = null; + draft.closed_sprint_ids = [...new Set([...draft.closed_sprint_ids, sprint.id])]; + }); + await emitEditEvents(r, result); + } +} diff --git a/packages/@emulators/jira/src/routes/inspector.ts b/packages/@emulators/jira/src/routes/inspector.ts new file mode 100644 index 00000000..8f6076f8 --- /dev/null +++ b/packages/@emulators/jira/src/routes/inspector.ts @@ -0,0 +1,234 @@ +import type { InspectorTab, RouteContext } from "@emulators/core"; +import { escapeHtml, renderCardPage, renderErrorPage, renderInspectorPage } from "@emulators/core"; +import { getJiraStore } from "../store.js"; +import { adfToText } from "../adf.js"; +import { findIssue } from "../lookup.js"; + +const SERVICE_LABEL = "Jira"; +const TABS: InspectorTab[] = [ + { id: "issues", label: "Issues", href: "/?tab=issues" }, + { id: "projects", label: "Projects", href: "/?tab=projects" }, + { id: "boards", label: "Boards", href: "/?tab=boards" }, + { id: "users", label: "Users", href: "/?tab=users" }, + { id: "webhooks", label: "Webhooks", href: "/?tab=webhooks" }, + { id: "auth", label: "Auth", href: "/?tab=auth" }, +]; + +function mask(secret: string): string { + return secret.length <= 8 ? "********" : `${secret.slice(0, 4)}...${secret.slice(-4)}`; +} + +export function inspectorRoutes({ app, store }: RouteContext): void { + const js = () => getJiraStore(store); + const userName = (id: string | null) => (id ? (js().users.findOneBy("account_id", id)?.display_name ?? id) : ""); + + app.get("/", (c) => { + const requested = c.req.query("tab") ?? "issues"; + const active = TABS.some((tab) => tab.id === requested) ? requested : "issues"; + const views: Record string> = { + issues: issuesView, + projects: projectsView, + boards: boardsView, + users: usersView, + webhooks: webhooksView, + auth: authView, + }; + return c.html(renderInspectorPage("Jira Inspector", TABS, active, views[active](), SERVICE_LABEL)); + }); + + app.get("/browse/:key", (c) => { + const issue = findIssue(js(), c.req.param("key")); + if (!issue) { + return c.html( + renderErrorPage("Issue not found", `No issue with key ${c.req.param("key")} exists.`, SERVICE_LABEL), + 404, + ); + } + const status = js().statuses.get(issue.status_id)?.name ?? ""; + const type = js().issueTypes.get(issue.issue_type_id)?.name ?? ""; + const priority = issue.priority_id ? (js().priorities.get(issue.priority_id)?.name ?? "") : ""; + const details = table( + ["Field", "Value"], + [ + ["Type", escapeHtml(type)], + ["Status", `${escapeHtml(status)}`], + ["Priority", escapeHtml(priority)], + ["Assignee", escapeHtml(userName(issue.assignee_id) || "Unassigned")], + ["Reporter", escapeHtml(userName(issue.reporter_id))], + ["Labels", escapeHtml(issue.labels.join(", "))], + ["Description", escapeHtml(adfToText(issue.description))], + ], + "", + ); + const comments = js() + .comments.findBy("issue_id", issue.id) + .sort((a, b) => a.id - b.id) + .map((comment) => [ + escapeHtml(userName(comment.author_id)), + escapeHtml(adfToText(comment.body)), + escapeHtml(comment.created_at), + ]); + return c.html( + renderCardPage( + `${issue.key}: ${issue.summary}`, + escapeHtml(js().projects.get(issue.project_id)?.name ?? ""), + details + section("Comments", table(["Author", "Comment", "Created"], comments, "No comments.")), + SERVICE_LABEL, + ), + ); + }); + + function issuesView(): string { + const rows = js() + .issues.all() + .sort((a, b) => b.id - a.id) + .map((issue) => [ + `${escapeHtml(issue.key)}`, + escapeHtml(js().issueTypes.get(issue.issue_type_id)?.name ?? ""), + escapeHtml(issue.summary), + escapeHtml(js().statuses.get(issue.status_id)?.name ?? ""), + escapeHtml(issue.priority_id ? (js().priorities.get(issue.priority_id)?.name ?? "") : ""), + escapeHtml(userName(issue.assignee_id)), + escapeHtml(issue.labels.join(", ")), + escapeHtml(issue.updated_at), + ]); + return section( + "Issues", + table(["Key", "Type", "Summary", "Status", "Priority", "Assignee", "Labels", "Updated"], rows, "No issues."), + ); + } + + function projectsView(): string { + const rows = js() + .projects.all() + .map((project) => [ + escapeHtml(project.key), + escapeHtml(project.name), + escapeHtml(String(project.id)), + escapeHtml(userName(project.lead_account_id)), + escapeHtml( + project.status_ids + .map((id) => js().statuses.get(id)?.name) + .filter(Boolean) + .join(" > "), + ), + escapeHtml(String(js().issues.findBy("project_id", project.id).length)), + ]); + return section("Projects", table(["Key", "Name", "ID", "Lead", "Workflow", "Issues"], rows, "No projects.")); + } + + function boardsView(): string { + const boardRows = js() + .boards.all() + .map((board) => [ + escapeHtml(String(board.id)), + escapeHtml(board.name), + escapeHtml(board.type), + escapeHtml(js().projects.get(board.project_id)?.key ?? ""), + ]); + const sprintRows = js() + .sprints.all() + .map((sprint) => [ + escapeHtml(String(sprint.id)), + escapeHtml(sprint.name), + escapeHtml(sprint.state), + escapeHtml(js().boards.get(sprint.board_id)?.name ?? ""), + escapeHtml(String(js().issues.findBy("sprint_id", sprint.id).length)), + escapeHtml(sprint.goal), + ]); + return ( + section("Boards", table(["ID", "Name", "Type", "Project"], boardRows, "No boards.")) + + section("Sprints", table(["ID", "Name", "State", "Board", "Issues", "Goal"], sprintRows, "No sprints.")) + ); + } + + function usersView(): string { + const rows = js() + .users.all() + .map((user) => [ + escapeHtml(user.display_name), + escapeHtml(user.email), + escapeHtml(user.account_id), + user.admin ? 'admin' : "", + user.active ? "active" : "inactive", + ]); + return section("Users", table(["Name", "Email", "Account ID", "Role", "Status"], rows, "No users.")); + } + + function webhooksView(): string { + const hookRows = js() + .webhooks.all() + .map((hook) => [ + escapeHtml(String(hook.id)), + escapeHtml(hook.name), + escapeHtml(hook.kind), + escapeHtml(hook.url), + escapeHtml(hook.events.join(", ")), + escapeHtml(hook.jql_filter ?? ""), + hook.enabled ? "enabled" : "disabled", + ]); + const deliveryRows = js() + .webhookDeliveries.all() + .sort((a, b) => b.id - a.id) + .slice(0, 100) + .map((delivery) => [ + escapeHtml(delivery.event), + escapeHtml(delivery.url), + escapeHtml(delivery.status === null ? "" : String(delivery.status)), + escapeHtml(delivery.error ?? ""), + escapeHtml(delivery.created_at), + ]); + return ( + section("Webhooks", table(["ID", "Name", "Kind", "URL", "Events", "JQL", "State"], hookRows, "No webhooks.")) + + section("Deliveries", table(["Event", "URL", "Status", "Error", "Sent"], deliveryRows, "No deliveries yet.")) + ); + } + + function authView(): string { + const tokenRows = js() + .apiTokens.all() + .map((token) => { + const user = js().users.findOneBy("account_id", token.account_id); + return [escapeHtml(user?.email ?? token.account_id), escapeHtml(mask(token.token))]; + }); + const appRows = js() + .oauthApps.all() + .map((oauthApp) => [ + escapeHtml(oauthApp.name), + escapeHtml(oauthApp.client_id), + escapeHtml(mask(oauthApp.client_secret)), + escapeHtml(oauthApp.redirect_uris.join(", ")), + escapeHtml(oauthApp.scopes.join(" ")), + ]); + const siteRows = js() + .sites.all() + .map((site) => [escapeHtml(site.name), escapeHtml(site.cloud_id)]); + return ( + section("API tokens (Basic auth email:token)", table(["Email", "Token"], tokenRows, "No API tokens.")) + + section( + "OAuth 2.0 apps", + table(["Name", "Client ID", "Secret", "Redirect URIs", "Scopes"], appRows, "No OAuth apps."), + ) + + section("Sites (cloud IDs)", table(["Name", "Cloud ID"], siteRows, "No sites.")) + ); + } +} + +function section(title: string, body: string): string { + return `
    +

    ${escapeHtml(title)}

    + ${body} +
    `; +} + +function table(headers: string[], rows: string[][], empty: string): string { + if (rows.length === 0) return `

    ${escapeHtml(empty)}

    `; + const headerHtml = headers.map((header) => `${escapeHtml(header)}`).join(""); + const rowHtml = rows.map((row) => `${row.map((cell) => `${cell}`).join("")}`).join("\n"); + return `
    + ${headerHtml} + +${rowHtml} + +
    `; +} diff --git a/packages/@emulators/jira/src/routes/issues.ts b/packages/@emulators/jira/src/routes/issues.ts new file mode 100644 index 00000000..0879b340 --- /dev/null +++ b/packages/@emulators/jira/src/routes/issues.ts @@ -0,0 +1,293 @@ +import type { RouteContext } from "@emulators/core"; +import { + API_V, + JiraError, + listParam, + makeHandler, + pageParams, + READ, + readJson, + WRITE, + type JiraRequest, +} from "../context.js"; +import { + formatComponent, + formatIssueType, + formatPriority, + formatVersion, + projectIssueTypes, + restUrl, +} from "../formatters.js"; +import { + formatChangelog, + formatIssue, + formatTransition, + issueSelf, + issueTransitions, + parseFieldSelection, +} from "../issue-format.js"; +import { createIssue, editIssue, transitionIssue } from "../issue-service.js"; +import { allFields, type FieldDef } from "../fields.js"; +import { findIssueType, findUser, paginate, requireIssue, requireProject } from "../lookup.js"; +import { deleteIssueRecord } from "../services.js"; +import { emitEditEvents, emitIssueEvent } from "../webhooks.js"; +import type { JiraProject } from "../entities.js"; + +export function readIssueOptions(r: JiraRequest, defaultAll = true) { + return { + fields: parseFieldSelection(listParam(r.c, "fields"), defaultAll), + expand: listParam(r.c, "expand"), + }; +} + +export function issueRoutes({ app, store, baseUrl }: RouteContext): void { + const handle = makeHandler(store, baseUrl); + + // Static paths first so they win over /issue/:key. + app.get( + `${API_V}/issue/createmeta/:project/issuetypes`, + handle( + (r) => { + const project = requireProject(r.js, r.c.req.param("project")); + const types = projectIssueTypes(r, project).map((type) => formatIssueType(r, type)); + const page = paginate(types, pageParams(r.c, 50, 200)); + return r.c.json({ + issueTypes: page.values, + maxResults: page.maxResults, + startAt: page.startAt, + total: page.total, + }); + }, + { scopes: WRITE }, + ), + ); + + app.get( + `${API_V}/issue/createmeta/:project/issuetypes/:typeId`, + handle( + (r) => { + const project = requireProject(r.js, r.c.req.param("project")); + const type = findIssueType(r.js, r.c.req.param("typeId")); + if (!type || !project.issue_type_ids.includes(type.id)) { + throw new JiraError(404, [ + "Issue type with given ID does not exist or you do not have permission to see it.", + ]); + } + const fields = createFieldMeta(r, project, type.subtask); + const page = paginate(fields, pageParams(r.c, 50, 200)); + return r.c.json({ fields: page.values, maxResults: page.maxResults, startAt: page.startAt, total: page.total }); + }, + { scopes: WRITE }, + ), + ); + + app.post( + `${API_V}/issue/bulk`, + handle( + async (r) => { + const body = await readJson(r.c); + const updates: unknown[] = Array.isArray(body.issueUpdates) ? body.issueUpdates : []; + const issues: unknown[] = []; + const errors: unknown[] = []; + for (const [index, update] of updates.entries()) { + try { + const { issue, comments } = createIssue(r, update as { fields?: Record }); + issues.push({ id: String(issue.id), key: issue.key, self: issueSelf(r, issue) }); + await emitIssueEvent(r, "jira:issue_created", issue, { comments }); + } catch (err) { + if (!(err instanceof JiraError)) throw err; + errors.push({ + status: err.status, + elementErrors: { errorMessages: err.errorMessages, errors: err.errors }, + failedElementNumber: index, + }); + } + } + return r.c.json({ issues, errors }, issues.length > 0 || updates.length === 0 ? 201 : 400); + }, + { scopes: WRITE }, + ), + ); + + app.post( + `${API_V}/issue`, + handle( + async (r) => { + const { issue, comments } = createIssue(r, await readJson(r.c)); + await emitIssueEvent(r, "jira:issue_created", issue, { comments }); + return r.c.json({ id: String(issue.id), key: issue.key, self: issueSelf(r, issue) }, 201); + }, + { scopes: WRITE }, + ), + ); + + app.get( + `${API_V}/issue/:key`, + handle((r) => r.c.json(formatIssue(r, requireIssue(r.js, r.c.req.param("key")), readIssueOptions(r))), { + scopes: READ, + }), + ); + + app.put( + `${API_V}/issue/:key`, + handle( + async (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const result = editIssue(r, issue, await readJson(r.c)); + await emitEditEvents(r, result); + if (r.c.req.query("returnIssue") === "true") { + return r.c.json(formatIssue(r, result.issue, readIssueOptions(r))); + } + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + app.delete( + `${API_V}/issue/:key`, + handle( + async (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const subtasks = r.js.issues + .findBy("parent_id", issue.id) + .filter((child) => r.js.issueTypes.get(child.issue_type_id)?.subtask); + if (subtasks.length > 0 && r.c.req.query("deleteSubtasks") !== "true") { + throw new JiraError(400, [ + `The issue '${issue.key}' has subtasks. You must specify the 'deleteSubtasks' parameter to delete this issue and all its subtasks.`, + ]); + } + await emitIssueEvent(r, "jira:issue_deleted", issue); + deleteIssueRecord(r.js, issue); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + app.put( + `${API_V}/issue/:key/assignee`, + handle( + async (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const body = await readJson(r.c); + const ref = body.accountId ?? body.name ?? null; + if (ref !== null && ref !== "-1" && !findUser(r.js, String(ref))) { + throw new JiraError(400, [], { assignee: `User '${ref}' cannot be assigned issues.` }); + } + const result = editIssue(r, issue, { fields: { assignee: ref === null ? null : { accountId: String(ref) } } }); + await emitEditEvents(r, result); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + app.get( + `${API_V}/issue/:key/transitions`, + handle( + (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const only = r.c.req.query("transitionId"); + const transitions = issueTransitions(r, issue) + .filter((transition) => !only || transition.id === only) + .map((transition) => formatTransition(r, transition)); + return r.c.json({ expand: "transitions", transitions }); + }, + { scopes: READ }, + ), + ); + + app.post( + `${API_V}/issue/:key/transitions`, + handle( + async (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const result = transitionIssue(r, issue, await readJson(r.c)); + await emitEditEvents(r, result); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + app.get( + `${API_V}/issue/:key/changelog`, + handle( + (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const entries = r.js.changelogs.findBy("issue_id", issue.id).sort((a, b) => a.id - b.id); + const { startAt, maxResults } = pageParams(r.c, 100, 100); + const page = paginate(entries, { startAt, maxResults }); + return r.c.json({ + self: restUrl(r, `/issue/${issue.key}/changelog?maxResults=${maxResults}&startAt=${startAt}`), + ...page, + values: page.values.map((entry) => formatChangelog(r, entry)), + }); + }, + { scopes: READ }, + ), + ); + + app.get( + `${API_V}/issue/:key/editmeta`, + handle( + (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const project = r.js.projects.get(issue.project_id)!; + const subtask = r.js.issueTypes.get(issue.issue_type_id)?.subtask ?? false; + const fields = createFieldMeta(r, project, subtask).filter((field) => field.fieldId !== "project"); + return r.c.json({ fields: Object.fromEntries(fields.map((field) => [field.fieldId, field])) }); + }, + { scopes: READ }, + ), + ); +} + +const OPERATIONS: Record = { + labels: ["add", "set", "remove"], + components: ["add", "set", "remove"], + fixVersions: ["add", "set", "remove"], + comment: ["add"], +}; + +function createFieldMeta(r: JiraRequest, project: JiraProject, subtask: boolean) { + const editable = new Set([ + "summary", + "issuetype", + "project", + "description", + "environment", + "priority", + "assignee", + "reporter", + "labels", + "duedate", + "parent", + "components", + "fixVersions", + ]); + const required = new Set(["summary", "issuetype", "project", ...(subtask ? ["parent"] : [])]); + const allowed: Record unknown[]> = { + priority: () => r.js.priorities.all().map((priority) => formatPriority(r, priority)), + issuetype: () => projectIssueTypes(r, project).map((type) => formatIssueType(r, type)), + project: () => [ + { id: String(project.id), key: project.key, name: project.name, self: restUrl(r, `/project/${project.id}`) }, + ], + components: () => + r.js.components.findBy("project_id", project.id).map((component) => formatComponent(r, component)), + fixVersions: () => r.js.versions.findBy("project_id", project.id).map((version) => formatVersion(r, version)), + }; + return allFields(r.js) + .filter((field: FieldDef) => field.custom || editable.has(field.id)) + .map((field) => ({ + required: required.has(field.id), + schema: field.schema, + name: field.name, + key: field.id, + fieldId: field.id, + hasDefaultValue: field.id === "priority" || field.id === "reporter", + operations: OPERATIONS[field.id] ?? ["set"], + ...(allowed[field.id] ? { allowedValues: allowed[field.id]() } : {}), + })); +} diff --git a/packages/@emulators/jira/src/routes/oauth.ts b/packages/@emulators/jira/src/routes/oauth.ts new file mode 100644 index 00000000..27b1c94e --- /dev/null +++ b/packages/@emulators/jira/src/routes/oauth.ts @@ -0,0 +1,258 @@ +import type { AppEnv, Context, RouteContext, Store } from "@emulators/core"; +import { + bodyStr, + constantTimeSecretEqual, + escapeHtml, + matchesRedirectUri, + renderCardPage, + renderErrorPage, + renderUserButton, +} from "@emulators/core"; +import { getJiraStore } from "../store.js"; +import { secretToken } from "../ids.js"; +import { authenticate, JiraError, jiraErrorResponse } from "../context.js"; +import { avatarUrls } from "../formatters.js"; + +const SERVICE_LABEL = "Jira"; +const CODE_TTL_MS = 10 * 60 * 1000; +const ACCESS_TOKEN_TTL_SECONDS = 3600; +const AUDIENCE = "api.atlassian.com"; + +interface PendingCode { + clientId: string; + redirectUri: string; + scopes: string[]; + accountId: string; + createdAt: number; +} + +function pendingCodes(store: Store): Map { + let map = store.getData>("jira.oauth.pending_codes"); + if (!map) { + map = new Map(); + store.setData("jira.oauth.pending_codes", map); + } + return map; +} + +export function splitScopes(value: string | string[] | undefined): string[] { + if (Array.isArray(value)) return value.map((scope) => scope.trim()).filter(Boolean); + return (value ?? "") + .split(/[\s,]+/) + .map((scope) => scope.trim()) + .filter(Boolean); +} + +function oauthError(c: Context, status: number, error: string, description: string): Response { + return c.json({ error, error_description: description }, status); +} + +export function oauthRoutes({ app, store, baseUrl }: RouteContext): void { + const js = () => getJiraStore(store); + + const validateRequest = (clientId: string, redirectUri: string, scopes: string[]) => { + const oauthApp = js().oauthApps.findOneBy("client_id", clientId); + if (!oauthApp) + return { error: ["Application not found", `The client_id '${clientId}' is not registered.`] as const }; + if (!redirectUri || !matchesRedirectUri(redirectUri, oauthApp.redirect_uris)) { + return { error: ["Redirect URI mismatch", "The redirect_uri is not registered for this app."] as const }; + } + const invalid = scopes.filter((scope) => !oauthApp.scopes.includes(scope)); + if (invalid.length > 0) { + return { error: ["Invalid scope", `The app is not registered for scopes: ${invalid.join(", ")}.`] as const }; + } + return { oauthApp }; + }; + + app.get("/authorize", (c) => { + const clientId = c.req.query("client_id") ?? ""; + const redirectUri = c.req.query("redirect_uri") ?? ""; + const state = c.req.query("state") ?? ""; + const scopes = splitScopes(c.req.query("scope")); + if ((c.req.query("audience") ?? AUDIENCE) !== AUDIENCE) { + return c.html(renderErrorPage("Invalid audience", `The audience must be ${AUDIENCE}.`, SERVICE_LABEL), 400); + } + if ((c.req.query("response_type") ?? "code") !== "code") { + return c.html( + renderErrorPage("Unsupported response_type", "Only response_type=code is supported.", SERVICE_LABEL), + 400, + ); + } + const checked = validateRequest(clientId, redirectUri, scopes); + if (checked.error) return c.html(renderErrorPage(checked.error[0], checked.error[1], SERVICE_LABEL), 400); + + const buttons = js() + .users.all() + .filter((user) => user.active && user.account_type === "atlassian") + .map((user) => + renderUserButton({ + letter: (user.display_name[0] ?? "U").toUpperCase(), + login: user.email, + name: user.display_name, + email: user.email, + formAction: "/authorize/callback", + hiddenFields: { + account_id: user.account_id, + client_id: clientId, + redirect_uri: redirectUri, + scope: scopes.join(" "), + state, + }, + }), + ) + .join("\n"); + + return c.html( + renderCardPage( + "Authorize Atlassian app", + `${escapeHtml(checked.oauthApp!.name)} is requesting access to your Jira site with scopes ${escapeHtml(scopes.join(", ") || "none")}.`, + buttons || '

    No users in the Jira emulator store.

    ', + SERVICE_LABEL, + ), + ); + }); + + app.post("/authorize/callback", async (c) => { + const body = await c.req.parseBody(); + const clientId = bodyStr(body.client_id); + const redirectUri = bodyStr(body.redirect_uri); + const scopes = splitScopes(bodyStr(body.scope)); + const state = bodyStr(body.state); + const checked = validateRequest(clientId, redirectUri, scopes); + if (checked.error) return c.html(renderErrorPage(checked.error[0], checked.error[1], SERVICE_LABEL), 400); + const user = js().users.findOneBy("account_id", bodyStr(body.account_id)); + if (!user || !user.active) { + return c.html(renderErrorPage("Unknown user", "Pick a user from the consent screen.", SERVICE_LABEL), 400); + } + const code = secretToken("jira_code"); + pendingCodes(store).set(code, { clientId, redirectUri, scopes, accountId: user.account_id, createdAt: Date.now() }); + const url = new URL(redirectUri); + url.searchParams.set("code", code); + if (state) url.searchParams.set("state", state); + return c.redirect(url.toString(), 302); + }); + + const issueTokens = (clientId: string, accountId: string, scopes: string[]) => { + const access = secretToken("jira_at"); + js().oauthTokens.insert({ + token: access, + type: "access", + account_id: accountId, + client_id: clientId, + scopes, + expires_at: new Date(Date.now() + ACCESS_TOKEN_TTL_SECONDS * 1000).toISOString(), + revoked: false, + }); + let refresh: string | undefined; + if (scopes.includes("offline_access")) { + refresh = secretToken("jira_rt"); + js().oauthTokens.insert({ + token: refresh, + type: "refresh", + account_id: accountId, + client_id: clientId, + scopes, + expires_at: null, + revoked: false, + }); + } + return { + access_token: access, + expires_in: ACCESS_TOKEN_TTL_SECONDS, + token_type: "Bearer", + ...(refresh ? { refresh_token: refresh } : {}), + scope: scopes.join(" "), + }; + }; + + app.post("/oauth/token", async (c) => { + const contentType = c.req.header("Content-Type") ?? ""; + let body: Record; + if (contentType.includes("application/json")) { + body = (await c.req.json().catch(() => ({}))) as Record; + } else { + body = await c.req.parseBody(); + } + const clientId = bodyStr(body.client_id); + const clientSecret = bodyStr(body.client_secret); + const oauthApp = js().oauthApps.findOneBy("client_id", clientId); + if (!oauthApp || !constantTimeSecretEqual(clientSecret, oauthApp.client_secret)) { + return oauthError(c, 401, "access_denied", "Unauthorized"); + } + + const grantType = bodyStr(body.grant_type); + if (grantType === "authorization_code") { + const code = bodyStr(body.code); + const pending = pendingCodes(store).get(code); + pendingCodes(store).delete(code); + if (!pending || pending.clientId !== clientId || Date.now() - pending.createdAt > CODE_TTL_MS) { + return oauthError(c, 403, "invalid_grant", "Invalid authorization code"); + } + if (bodyStr(body.redirect_uri) !== pending.redirectUri) { + return oauthError(c, 403, "invalid_grant", "redirect_uri does not match the authorization request"); + } + return c.json(issueTokens(clientId, pending.accountId, pending.scopes)); + } + + if (grantType === "refresh_token") { + const record = js().oauthTokens.findOneBy("token", bodyStr(body.refresh_token)); + if (!record || record.type !== "refresh" || record.revoked || record.client_id !== clientId) { + return oauthError(c, 403, "invalid_grant", "Unknown or invalid refresh token."); + } + // Atlassian rotates refresh tokens: the old one stops working. + js().oauthTokens.update(record.id, { revoked: true }); + return c.json(issueTokens(clientId, record.account_id, record.scopes)); + } + + return oauthError(c, 400, "unsupported_grant_type", `Unsupported grant_type '${grantType}'.`); + }); + + const requireOAuthUser = (c: Context) => { + const auth = authenticate(c, js()); + if (!auth) throw new JiraError(401, ["Unauthorized"]); + return auth; + }; + + app.get("/oauth/token/accessible-resources", (c) => { + try { + const auth = requireOAuthUser(c); + const scopes = auth.scopes ?? js().oauthApps.all()[0]?.scopes ?? []; + return c.json( + js() + .sites.all() + .map((site) => ({ + id: site.cloud_id, + url: baseUrl, + name: site.name, + scopes: scopes.filter((scope) => scope !== "offline_access"), + avatarUrl: "https://site-admin-avatar-cdn.prod.public.atl-paas.net/avatars/240/rocket.png", + })), + ); + } catch (err) { + if (err instanceof JiraError) return jiraErrorResponse(c, err); + throw err; + } + }); + + app.get("/me", (c) => { + try { + const { user } = requireOAuthUser(c); + return c.json({ + account_type: user.account_type, + account_id: user.account_id, + email: user.email, + email_verified: true, + name: user.display_name, + picture: avatarUrls(user.account_id)["48x48"], + account_status: user.active ? "active" : "inactive", + nickname: user.display_name, + zoneinfo: user.time_zone, + locale: user.locale.replace("_", "-"), + extended_profile: {}, + }); + } catch (err) { + if (err instanceof JiraError) return jiraErrorResponse(c, err); + throw err; + } + }); +} diff --git a/packages/@emulators/jira/src/routes/platform.ts b/packages/@emulators/jira/src/routes/platform.ts new file mode 100644 index 00000000..0d2ccc83 --- /dev/null +++ b/packages/@emulators/jira/src/routes/platform.ts @@ -0,0 +1,354 @@ +import type { RouteContext } from "@emulators/core"; +import { API_V, JiraError, listParam, makeHandler, pageParams } from "../context.js"; +import { + allStatusCategories, + formatIssueType, + formatPriorityFull, + formatResolution, + formatStatus, + formatUser, + projectStatuses, + restUrl, +} from "../formatters.js"; +import { allFields } from "../fields.js"; +import { findIssueType, findPriority, findProject, findResolution, findStatus, paginate } from "../lookup.js"; +import type { JiraUser } from "../entities.js"; + +const READ_USER = ["read:jira-user", "read:jira-work"]; +const READ_WORK = ["read:jira-work"]; + +const PERMISSION_KEYS = [ + "BROWSE_PROJECTS", + "CREATE_ISSUES", + "EDIT_ISSUES", + "DELETE_ISSUES", + "ASSIGN_ISSUES", + "ASSIGNABLE_USER", + "TRANSITION_ISSUES", + "RESOLVE_ISSUES", + "CLOSE_ISSUES", + "ADD_COMMENTS", + "EDIT_ALL_COMMENTS", + "DELETE_ALL_COMMENTS", + "WORK_ON_ISSUES", + "LINK_ISSUES", + "MANAGE_WATCHERS", + "SCHEDULE_ISSUES", + "MANAGE_SPRINTS_PERMISSION", + "ADMINISTER_PROJECTS", + "ADMINISTER", + "SYSTEM_ADMIN", +]; +const ADMIN_ONLY = new Set(["ADMINISTER", "SYSTEM_ADMIN"]); + +export function platformRoutes({ app, store, baseUrl }: RouteContext): void { + const handle = makeHandler(store, baseUrl); + + app.get( + `${API_V}/serverInfo`, + handle( + (r) => + r.c.json({ + baseUrl: r.siteUrl, + displayUrl: r.siteUrl, + version: "1001.0.0-SNAPSHOT", + versionNumbers: [1001, 0, 0], + deploymentType: "Cloud", + buildNumber: 100000, + buildDate: "2026-01-01T00:00:00.000+0000", + serverTime: new Date().toISOString().replace("Z", "+0000"), + scmInfo: "emulate", + serverTitle: "Jira", + defaultLocale: { locale: "en_US" }, + serverTimeZone: "Etc/UTC", + }), + { auth: false }, + ), + ); + + app.get( + `${API_V}/myself`, + handle((r) => r.c.json({ ...formatUser(r, r.user), groups: { size: 0, items: [] } }), { scopes: READ_USER }), + ); + + const matchesQuery = (user: JiraUser, query: string | undefined) => { + if (!query) return true; + const q = query.toLowerCase(); + return ( + user.display_name.toLowerCase().includes(q) || + user.email.toLowerCase().includes(q) || + user.account_id.toLowerCase() === q + ); + }; + + app.get( + `${API_V}/user`, + handle( + (r) => { + const accountId = r.c.req.query("accountId") ?? ""; + const user = r.js.users.findOneBy("account_id", accountId); + if (!user) + throw new JiraError(404, [ + `Specified user does not exist or you do not have required permissions: ${accountId}`, + ]); + return r.c.json(formatUser(r, user)); + }, + { scopes: READ_USER }, + ), + ); + + app.get( + `${API_V}/user/bulk`, + handle( + (r) => { + const ids = listParam(r.c, "accountId"); + const users = ids + .map((id) => r.js.users.findOneBy("account_id", id)) + .filter((user): user is JiraUser => Boolean(user)); + const { startAt, maxResults } = pageParams(r.c, 10, 200); + const page = paginate(users, { startAt, maxResults }); + return r.c.json({ ...page, values: page.values.map((user) => formatUser(r, user)) }); + }, + { scopes: READ_USER }, + ), + ); + + for (const path of [`${API_V}/user/search`, `${API_V}/users/search`, `${API_V}/users`, `${API_V}/user/picker`]) { + app.get( + path, + handle( + (r) => { + const query = r.c.req.query("query") ?? r.c.req.query("username"); + const accountId = r.c.req.query("accountId"); + const { startAt, maxResults } = pageParams(r.c, 50, 1000); + const users = r.js.users + .all() + .filter((user) => (accountId ? user.account_id === accountId : matchesQuery(user, query))) + .slice(startAt, startAt + maxResults) + .map((user) => formatUser(r, user)); + if (path.endsWith("/picker")) { + return r.c.json({ + users: users.map((user) => ({ ...user, html: user!.displayName })), + total: users.length, + header: `Showing ${users.length} of ${users.length} matching users`, + }); + } + return r.c.json(users); + }, + { scopes: READ_USER }, + ), + ); + } + + for (const path of [`${API_V}/user/assignable/search`, `${API_V}/user/assignable/multiProjectSearch`]) { + app.get( + path, + handle( + (r) => { + const projectRefs = [...listParam(r.c, "project"), ...listParam(r.c, "projectKeys")]; + for (const ref of projectRefs) { + if (!findProject(r.js, ref)) throw new JiraError(404, [`No project could be found with key '${ref}'.`]); + } + const query = r.c.req.query("query") ?? r.c.req.query("username"); + const accountId = r.c.req.query("accountId"); + const users = r.js.users + .all() + .filter((user) => user.active && user.account_type === "atlassian") + .filter((user) => (accountId ? user.account_id === accountId : matchesQuery(user, query))) + .map((user) => formatUser(r, user)); + return r.c.json(users); + }, + { scopes: READ_USER }, + ), + ); + } + + app.get( + `${API_V}/mypermissions`, + handle( + (r) => { + const requested = listParam(r.c, "permissions"); + const keys = requested.length > 0 ? requested : PERMISSION_KEYS; + const permissions: Record = {}; + keys.forEach((key, index) => { + permissions[key] = { + id: String(index + 1), + key, + name: key + .toLowerCase() + .split("_") + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(" "), + type: ADMIN_ONLY.has(key) ? "GLOBAL" : "PROJECT", + description: "", + havePermission: ADMIN_ONLY.has(key) || key === "ADMINISTER_PROJECTS" ? r.user.admin : true, + }; + }); + return r.c.json({ permissions }); + }, + { scopes: READ_WORK }, + ), + ); + + app.get( + `${API_V}/issuetype`, + handle((r) => r.c.json(r.js.issueTypes.all().map((type) => formatIssueType(r, type))), { scopes: READ_WORK }), + ); + + app.get( + `${API_V}/issuetype/project`, + handle( + (r) => { + const project = findProject(r.js, r.c.req.query("projectId") ?? ""); + if (!project) throw new JiraError(404, ["The project was not found or you don't have permission to view it."]); + return r.c.json( + project.issue_type_ids.map((id) => formatIssueType(r, r.js.issueTypes.get(id))).filter(Boolean), + ); + }, + { scopes: READ_WORK }, + ), + ); + + app.get( + `${API_V}/issuetype/:id`, + handle( + (r) => { + const type = findIssueType(r.js, r.c.req.param("id")); + if (!type) throw new JiraError(404, ["The issue type selected is invalid."]); + return r.c.json(formatIssueType(r, type)); + }, + { scopes: READ_WORK }, + ), + ); + + app.get( + `${API_V}/status`, + handle((r) => r.c.json(r.js.statuses.all().map((status) => formatStatus(r, status))), { scopes: READ_WORK }), + ); + + app.get( + `${API_V}/status/:idOrName`, + handle( + (r) => { + const status = findStatus(r.js, decodeURIComponent(r.c.req.param("idOrName"))); + if (!status) throw new JiraError(404, [`The status with id '${r.c.req.param("idOrName")}' does not exist`]); + return r.c.json(formatStatus(r, status)); + }, + { scopes: READ_WORK }, + ), + ); + + app.get( + `${API_V}/statuscategory`, + handle((r) => r.c.json(allStatusCategories(r)), { scopes: READ_WORK }), + ); + + app.get( + `${API_V}/priority`, + handle((r) => r.c.json(r.js.priorities.all().map((priority) => formatPriorityFull(r, priority))), { + scopes: READ_WORK, + }), + ); + + app.get( + `${API_V}/priority/search`, + handle( + (r) => { + const all = r.js.priorities.all().map((priority) => formatPriorityFull(r, priority)); + const page = paginate(all, pageParams(r.c, 50)); + return r.c.json({ self: restUrl(r, "/priority/search"), ...page }); + }, + { scopes: READ_WORK }, + ), + ); + + app.get( + `${API_V}/priority/:id`, + handle( + (r) => { + const priority = findPriority(r.js, r.c.req.param("id")); + if (!priority) throw new JiraError(404, [`Priority with id ${r.c.req.param("id")} not found.`]); + return r.c.json(formatPriorityFull(r, priority)); + }, + { scopes: READ_WORK }, + ), + ); + + app.get( + `${API_V}/resolution`, + handle((r) => r.c.json(r.js.resolutions.all().map((resolution) => formatResolution(r, resolution))), { + scopes: READ_WORK, + }), + ); + + app.get( + `${API_V}/resolution/:id`, + handle( + (r) => { + const resolution = findResolution(r.js, r.c.req.param("id")); + if (!resolution) throw new JiraError(404, [`The resolution with id '${r.c.req.param("id")}' does not exist.`]); + return r.c.json(formatResolution(r, resolution)); + }, + { scopes: READ_WORK }, + ), + ); + + app.get( + `${API_V}/field`, + handle((r) => r.c.json(allFields(r.js).map((field) => ({ ...field, key: field.id }))), { scopes: READ_WORK }), + ); + + app.get( + `${API_V}/label`, + handle( + (r) => { + const labels = [...new Set(r.js.issues.all().flatMap((issue) => issue.labels))].sort(); + const page = paginate(labels, pageParams(r.c, 1000)); + return r.c.json(page); + }, + { scopes: READ_WORK }, + ), + ); + + app.get( + `${API_V}/issueLinkType`, + handle( + (r) => + r.c.json({ + issueLinkTypes: r.js.issueLinkTypes.all().map((type) => ({ + id: String(type.id), + name: type.name, + inward: type.inward, + outward: type.outward, + self: restUrl(r, `/issueLinkType/${type.id}`), + })), + }), + { scopes: READ_WORK }, + ), + ); + + app.get( + `${API_V}/project/:key/statuses`, + handle( + (r) => { + const project = findProject(r.js, r.c.req.param("key")); + if (!project) throw new JiraError(404, [`No project could be found with key '${r.c.req.param("key")}'.`]); + const statuses = projectStatuses(r, project).map((status) => formatStatus(r, status)); + return r.c.json( + project.issue_type_ids + .map((id) => r.js.issueTypes.get(id)) + .filter(Boolean) + .map((type) => ({ + self: restUrl(r, `/issuetype/${type!.id}`), + id: String(type!.id), + name: type!.name, + subtask: type!.subtask, + statuses, + })), + ); + }, + { scopes: READ_WORK }, + ), + ); +} diff --git a/packages/@emulators/jira/src/routes/projects.ts b/packages/@emulators/jira/src/routes/projects.ts new file mode 100644 index 00000000..68b6a4da --- /dev/null +++ b/packages/@emulators/jira/src/routes/projects.ts @@ -0,0 +1,363 @@ +import type { RouteContext } from "@emulators/core"; +import { + API_V, + fieldError, + JiraError, + listParam, + makeHandler, + MANAGE, + pageParams, + READ, + readJson, + type JiraRequest, +} from "../context.js"; +import { formatComponent, formatProject, formatVersion, restUrl } from "../formatters.js"; +import { findProject, findUser, paginate, requireProject } from "../lookup.js"; +import { insertFrom } from "../store.js"; +import { createProject, deleteComponentRecord, deleteProjectRecord, deleteVersionRecord } from "../services.js"; + +const PROJECT_KEY = /^[A-Z][A-Z0-9_]{1,9}$/; + +/** Jira lets site admins and the project lead administer a project's components and versions. */ +function requireProjectAdmin(r: JiraRequest, projectId: number) { + const project = r.js.projects.get(projectId); + if (r.user.admin || (project && project.lead_account_id === r.user.account_id)) return; + throw new JiraError(403, ["You do not have permission to administer this project."]); +} + +/** Rejects a component or version name already used by another item in the same project. */ +function requireUniqueName( + items: Array<{ id: number; name: string }>, + name: string, + error: () => JiraError, + exceptId?: number, +) { + if (items.some((item) => item.id !== exceptId && item.name.toLowerCase() === name.toLowerCase())) throw error(); +} + +/** Reads an optional `yyyy-MM-dd` date from a request body. */ +function dateField(field: string, value: unknown): string | null { + if (value === undefined || value === null || value === "") return null; + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(value))) { + throw fieldError(field, `Invalid date format. Please enter the date in the format "yyyy-MM-dd".`); + } + return value; +} + +const componentExists = (name: string) => + fieldError("name", `A component with the name ${name} already exists in this project.`); +const versionExists = () => fieldError("name", "A version with this name already exists in this project."); + +function requireAdmin(r: JiraRequest) { + if (!r.user.admin) { + throw new JiraError(403, ["You must have global administrator rights in order to modify projects."]); + } +} + +export function projectRoutes({ app, store, baseUrl }: RouteContext): void { + const handle = makeHandler(store, baseUrl); + + app.get( + `${API_V}/project`, + handle((r) => r.c.json(r.js.projects.all().map((project) => formatProject(r, project))), { scopes: READ }), + ); + + app.get( + `${API_V}/project/search`, + handle( + (r) => { + const query = r.c.req.query("query")?.toLowerCase(); + const keys = listParam(r.c, "keys").map((key) => key.toLowerCase()); + const ids = listParam(r.c, "id"); + const typeKey = r.c.req.query("typeKey"); + const projects = r.js.projects.all().filter((project) => { + if (query && !project.key.toLowerCase().includes(query) && !project.name.toLowerCase().includes(query)) { + return false; + } + if (keys.length > 0 && !keys.includes(project.key.toLowerCase())) return false; + if (ids.length > 0 && !ids.includes(String(project.id))) return false; + if (typeKey && project.project_type_key !== typeKey) return false; + return true; + }); + const { startAt, maxResults } = pageParams(r.c, 50, 100); + const page = paginate(projects, { startAt, maxResults }); + return r.c.json({ + self: restUrl(r, `/project/search?startAt=${startAt}&maxResults=${maxResults}`), + ...(page.isLast + ? {} + : { nextPage: restUrl(r, `/project/search?startAt=${startAt + maxResults}&maxResults=${maxResults}`) }), + ...page, + values: page.values.map((project) => formatProject(r, project)), + }); + }, + { scopes: READ }, + ), + ); + + app.post( + `${API_V}/project`, + handle( + async (r) => { + requireAdmin(r); + const body = await readJson(r.c); + const key = String(body.key ?? ""); + const name = String(body.name ?? "").trim(); + if (!PROJECT_KEY.test(key)) { + throw fieldError( + "projectKey", + "Project keys must start with an uppercase letter, followed by one or more uppercase alphanumeric characters.", + ); + } + if (findProject(r.js, key)) + throw fieldError( + "projectKey", + `Project '${key}' uses this project key. A project with that project key already exists.`, + ); + if (!name) throw fieldError("projectName", "You must specify a valid project name."); + if (r.js.projects.all().some((project) => project.name.toLowerCase() === name.toLowerCase())) { + throw fieldError("projectName", "A project with that name already exists."); + } + const typeKey = body.projectTypeKey ?? "software"; + if (!["software", "business", "service_desk"].includes(typeKey)) { + throw fieldError("projectTypeKey", "Invalid project type key."); + } + let lead = r.user.account_id; + if (body.leadAccountId) { + const user = findUser(r.js, String(body.leadAccountId)); + if (!user) throw fieldError("projectLead", "The project lead you specified does not exist."); + lead = user.account_id; + } + const project = createProject(r.js, { + key, + name, + description: body.description ?? "", + lead, + project_type_key: typeKey, + }); + return r.c.json({ self: restUrl(r, `/project/${project.id}`), id: project.id, key: project.key }, 201); + }, + { scopes: MANAGE }, + ), + ); + + app.get( + `${API_V}/project/:key`, + handle((r) => r.c.json(formatProject(r, requireProject(r.js, r.c.req.param("key")))), { scopes: READ }), + ); + + app.put( + `${API_V}/project/:key`, + handle( + async (r) => { + requireAdmin(r); + const project = requireProject(r.js, r.c.req.param("key")); + const body = await readJson(r.c); + const patch: Partial = {}; + if (typeof body.name === "string") patch.name = body.name; + if (typeof body.description === "string") patch.description = body.description; + if (typeof body.key === "string" && body.key !== project.key) { + if (!PROJECT_KEY.test(body.key)) throw fieldError("projectKey", "Invalid project key."); + if (findProject(r.js, body.key)) + throw fieldError("projectKey", "A project with that project key already exists."); + patch.key = body.key; + } + if (body.leadAccountId) { + const user = findUser(r.js, String(body.leadAccountId)); + if (!user) throw fieldError("projectLead", "The project lead you specified does not exist."); + patch.lead_account_id = user.account_id; + } + const updated = r.js.projects.update(project.id, patch)!; + if (patch.key) { + for (const issue of r.js.issues.findBy("project_id", project.id)) { + r.js.issues.update(issue.id, { key: `${patch.key}-${issue.number}` }); + } + } + return r.c.json(formatProject(r, updated)); + }, + { scopes: MANAGE }, + ), + ); + + app.delete( + `${API_V}/project/:key`, + handle( + (r) => { + requireAdmin(r); + deleteProjectRecord(r.js, requireProject(r.js, r.c.req.param("key"))); + return r.c.body(null, 204); + }, + { scopes: MANAGE }, + ), + ); + + app.get( + `${API_V}/project/:key/components`, + handle( + (r) => { + const project = requireProject(r.js, r.c.req.param("key")); + return r.c.json( + r.js.components.findBy("project_id", project.id).map((component) => formatComponent(r, component)), + ); + }, + { scopes: READ }, + ), + ); + + app.get( + `${API_V}/project/:key/versions`, + handle( + (r) => { + const project = requireProject(r.js, r.c.req.param("key")); + return r.c.json(r.js.versions.findBy("project_id", project.id).map((version) => formatVersion(r, version))); + }, + { scopes: READ }, + ), + ); + + app.post( + `${API_V}/component`, + handle( + async (r) => { + const body = await readJson(r.c); + const project = findProject(r.js, body.projectId ?? body.project); + if (!project) throw fieldError("project", "The project with key or id specified does not exist."); + requireProjectAdmin(r, project.id); + const name = String(body.name ?? "").trim(); + if (!name) throw fieldError("name", "The component name must not be empty."); + requireUniqueName(r.js.components.findBy("project_id", project.id), name, () => componentExists(name)); + const lead = body.leadAccountId ? findUser(r.js, String(body.leadAccountId)) : undefined; + const component = insertFrom(r.js.components, 10000, { + project_id: project.id, + name, + description: body.description ?? "", + lead_account_id: lead?.account_id ?? null, + }); + return r.c.json(formatComponent(r, component), 201); + }, + { scopes: MANAGE }, + ), + ); + + const requireComponent = (r: JiraRequest) => { + const component = r.js.components.get(Number(r.c.req.param("id"))); + if (!component) throw new JiraError(404, [`The component with id ${r.c.req.param("id")} does not exist.`]); + return component; + }; + + app.get( + `${API_V}/component/:id`, + handle((r) => r.c.json(formatComponent(r, requireComponent(r))), { scopes: READ }), + ); + + app.put( + `${API_V}/component/:id`, + handle( + async (r) => { + const component = requireComponent(r); + requireProjectAdmin(r, component.project_id); + const body = await readJson(r.c); + const name = typeof body.name === "string" ? body.name.trim() : undefined; + if (name !== undefined) { + if (!name) throw fieldError("name", "The component name must not be empty."); + const siblings = r.js.components.findBy("project_id", component.project_id); + requireUniqueName(siblings, name, () => componentExists(name), component.id); + } + const updated = r.js.components.update(component.id, { + ...(name !== undefined ? { name } : {}), + ...(typeof body.description === "string" ? { description: body.description } : {}), + })!; + return r.c.json(formatComponent(r, updated)); + }, + { scopes: MANAGE }, + ), + ); + + app.delete( + `${API_V}/component/:id`, + handle( + (r) => { + const component = requireComponent(r); + requireProjectAdmin(r, component.project_id); + deleteComponentRecord(r.js, component); + return r.c.body(null, 204); + }, + { scopes: MANAGE }, + ), + ); + + app.post( + `${API_V}/version`, + handle( + async (r) => { + const body = await readJson(r.c); + const project = findProject(r.js, body.projectId ?? body.project); + if (!project) throw fieldError("project", "The project with key or id specified does not exist."); + requireProjectAdmin(r, project.id); + const name = String(body.name ?? "").trim(); + if (!name) throw fieldError("name", "You must specify a valid version name"); + requireUniqueName(r.js.versions.findBy("project_id", project.id), name, versionExists); + const version = insertFrom(r.js.versions, 10000, { + project_id: project.id, + name, + description: body.description ?? "", + released: body.released === true, + archived: body.archived === true, + start_date: dateField("startDate", body.startDate), + release_date: dateField("releaseDate", body.releaseDate), + }); + return r.c.json(formatVersion(r, version), 201); + }, + { scopes: MANAGE }, + ), + ); + + const requireVersion = (r: JiraRequest) => { + const version = r.js.versions.get(Number(r.c.req.param("id"))); + if (!version) throw new JiraError(404, [`Could not find version for id '${r.c.req.param("id")}'`]); + return version; + }; + + app.get( + `${API_V}/version/:id`, + handle((r) => r.c.json(formatVersion(r, requireVersion(r))), { scopes: READ }), + ); + + app.put( + `${API_V}/version/:id`, + handle( + async (r) => { + const version = requireVersion(r); + requireProjectAdmin(r, version.project_id); + const body = await readJson(r.c); + const name = typeof body.name === "string" ? body.name.trim() : undefined; + if (name !== undefined) { + if (!name) throw fieldError("name", "You must specify a valid version name"); + requireUniqueName(r.js.versions.findBy("project_id", version.project_id), name, versionExists, version.id); + } + const updated = r.js.versions.update(version.id, { + ...(name !== undefined ? { name } : {}), + ...(typeof body.description === "string" ? { description: body.description } : {}), + ...(typeof body.released === "boolean" ? { released: body.released } : {}), + ...(typeof body.archived === "boolean" ? { archived: body.archived } : {}), + ...(body.startDate !== undefined ? { start_date: dateField("startDate", body.startDate) } : {}), + ...(body.releaseDate !== undefined ? { release_date: dateField("releaseDate", body.releaseDate) } : {}), + })!; + return r.c.json(formatVersion(r, updated)); + }, + { scopes: MANAGE }, + ), + ); + + app.delete( + `${API_V}/version/:id`, + handle( + (r) => { + const version = requireVersion(r); + requireProjectAdmin(r, version.project_id); + deleteVersionRecord(r.js, version); + return r.c.body(null, 204); + }, + { scopes: MANAGE }, + ), + ); +} diff --git a/packages/@emulators/jira/src/routes/search.ts b/packages/@emulators/jira/src/routes/search.ts new file mode 100644 index 00000000..a72a5906 --- /dev/null +++ b/packages/@emulators/jira/src/routes/search.ts @@ -0,0 +1,184 @@ +import type { RouteContext } from "@emulators/core"; +import { API_V, intParam, JiraError, listParam, makeHandler, READ, readJson, type JiraRequest } from "../context.js"; +import { formatIssue, parseFieldSelection } from "../issue-format.js"; +import { JqlError, parseJql, runQuery } from "../jql.js"; +import type { JiraIssue } from "../entities.js"; + +interface SearchParams { + jql: string; + fields: string[]; + expand: string[]; + maxResults?: number; + startAt?: number; + nextPageToken?: string; +} + +async function searchParams(r: JiraRequest): Promise { + if (r.c.req.method === "POST") { + const body = await readJson(r.c); + const list = (value: unknown) => + Array.isArray(value) + ? value.map(String) + : typeof value === "string" + ? value + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + : []; + return { + jql: typeof body.jql === "string" ? body.jql : "", + fields: list(body.fields), + expand: list(body.expand), + maxResults: typeof body.maxResults === "number" ? body.maxResults : undefined, + startAt: typeof body.startAt === "number" ? body.startAt : undefined, + nextPageToken: typeof body.nextPageToken === "string" ? body.nextPageToken : undefined, + }; + } + const q = (name: string) => r.c.req.query(name); + return { + jql: q("jql") ?? "", + fields: listParam(r.c, "fields"), + expand: listParam(r.c, "expand"), + maxResults: q("maxResults") !== undefined ? intParam(q("maxResults"), 50) : undefined, + startAt: q("startAt") !== undefined ? intParam(q("startAt"), 0) : undefined, + nextPageToken: q("nextPageToken"), + }; +} + +function runJql(r: JiraRequest, jql: string, requireRestriction: boolean): JiraIssue[] { + try { + const query = parseJql(jql); + if (requireRestriction && !query.where) { + throw new JiraError(400, [ + "Unbounded JQL queries are not allowed here. Please add a search restriction to your query.", + ]); + } + return runQuery(r.js, query, r.user); + } catch (err) { + if (err instanceof JqlError) throw new JiraError(400, [err.message]); + throw err; + } +} + +function encodeToken(offset: number): string { + return Buffer.from(JSON.stringify({ offset }), "utf-8").toString("base64url"); +} + +function decodeToken(token: string): number { + try { + const parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf-8")); + if (typeof parsed.offset === "number" && parsed.offset >= 0) return parsed.offset; + } catch { + // fall through + } + throw new JiraError(400, ["The provided next page token is invalid or expired."]); +} + +function namesAndSchema(r: JiraRequest, expand: string[], issues: Array>) { + const extra: Record = {}; + if (issues.length === 0) return extra; + const sample = issues[0]; + if (expand.includes("names") && sample.names) extra.names = sample.names; + if (expand.includes("schema") && sample.schema) extra.schema = sample.schema; + return extra; +} + +export function searchRoutes({ app, store, baseUrl }: RouteContext): void { + const handle = makeHandler(store, baseUrl); + + const enhanced = handle( + async (r) => { + const params = await searchParams(r); + const issues = runJql(r, params.jql, true); + const idsOnly = params.fields.length === 0 || params.fields.every((field) => field === "id" || field === "key"); + const maxResults = Math.min(Math.max(params.maxResults ?? 50, 1), idsOnly ? 5000 : 100); + const offset = params.nextPageToken ? decodeToken(params.nextPageToken) : 0; + const page = issues.slice(offset, offset + maxResults); + const selection = parseFieldSelection( + params.fields.filter((field) => field !== "id"), + false, + ); + const formatted = page.map((issue) => formatIssue(r, issue, { fields: selection, expand: params.expand })); + const isLast = offset + page.length >= issues.length; + return r.c.json({ + issues: formatted, + ...(isLast ? {} : { nextPageToken: encodeToken(offset + page.length) }), + isLast, + ...namesAndSchema(r, params.expand, formatted), + }); + }, + { scopes: READ }, + ); + app.get(`${API_V}/search/jql`, enhanced); + app.post(`${API_V}/search/jql`, enhanced); + + app.post( + `${API_V}/search/approximate-count`, + handle( + async (r) => { + const body = await readJson(r.c); + const issues = runJql(r, typeof body.jql === "string" ? body.jql : "", true); + return r.c.json({ count: issues.length }); + }, + { scopes: READ }, + ), + ); + + const legacy = handle( + async (r) => { + const params = await searchParams(r); + const issues = runJql(r, params.jql, false); + const startAt = params.startAt ?? 0; + const maxResults = Math.min(Math.max(params.maxResults ?? 50, 0), 100); + const selection = parseFieldSelection(params.fields, true); + const formatted = issues + .slice(startAt, startAt + maxResults) + .map((issue) => formatIssue(r, issue, { fields: selection, expand: params.expand })); + return r.c.json({ + expand: "schema,names", + startAt, + maxResults, + total: issues.length, + issues: formatted, + ...namesAndSchema(r, params.expand, formatted), + }); + }, + { scopes: READ }, + ); + app.get(`${API_V}/search`, legacy); + app.post(`${API_V}/search`, legacy); + + app.get( + `${API_V}/issue/picker`, + handle( + (r) => { + const query = (r.c.req.query("query") ?? "").toLowerCase(); + const currentJql = r.c.req.query("currentJQL"); + const pool = currentJql ? runJql(r, currentJql, false) : r.js.issues.all().sort((a, b) => b.id - a.id); + const matches = pool + .filter( + (issue) => !query || issue.key.toLowerCase().includes(query) || issue.summary.toLowerCase().includes(query), + ) + .slice(0, 20); + return r.c.json({ + sections: [ + { + label: "History Search", + sub: `Showing ${matches.length} of ${matches.length} matching issues`, + id: "hs", + issues: matches.map((issue) => ({ + id: issue.id, + key: issue.key, + keyHtml: issue.key, + img: `${r.siteUrl}/images/icons/issuetypes/task.svg`, + summary: issue.summary, + summaryText: issue.summary, + })), + }, + ], + }); + }, + { scopes: READ }, + ), + ); +} diff --git a/packages/@emulators/jira/src/routes/subresources.ts b/packages/@emulators/jira/src/routes/subresources.ts new file mode 100644 index 00000000..db56b5a1 --- /dev/null +++ b/packages/@emulators/jira/src/routes/subresources.ts @@ -0,0 +1,386 @@ +import type { RouteContext } from "@emulators/core"; +import { + API_V, + fieldError, + JiraError, + makeHandler, + pageParams, + READ, + readJson, + WRITE, + type JiraRequest, +} from "../context.js"; +import { formatUser, restUrl } from "../formatters.js"; +import { formatComment, formatIssueRef, formatWorklog, parseDuration } from "../issue-format.js"; +import { addComment, createLink, findLinkType, readBody } from "../issue-service.js"; +import { findIssue, findUser, requireIssue } from "../lookup.js"; +import { insertFrom } from "../store.js"; +import { touchIssue } from "../services.js"; +import { emitCommentEvent, emitIssueEvent } from "../webhooks.js"; +import type { JiraComment, JiraIssue, JiraWorklog } from "../entities.js"; + +function timeSpentSeconds(body: { timeSpent?: unknown; timeSpentSeconds?: unknown }): number | undefined { + if (typeof body.timeSpentSeconds === "number") { + if (body.timeSpentSeconds <= 0) throw fieldError("timeLogged", "You must indicate the time spent working."); + return body.timeSpentSeconds; + } + if (typeof body.timeSpent === "string") { + const seconds = parseDuration(body.timeSpent); + if (!seconds) throw fieldError("timeLogged", "Invalid time duration entered."); + return seconds; + } + return undefined; +} + +export function subresourceRoutes({ app, store, baseUrl }: RouteContext): void { + const handle = makeHandler(store, baseUrl); + + const requireComment = (r: JiraRequest, issue: JiraIssue): JiraComment => { + const comment = r.js.comments.get(Number(r.c.req.param("id"))); + if (!comment || comment.issue_id !== issue.id) { + throw new JiraError(404, [`Can not find a comment for the id: ${r.c.req.param("id")}.`]); + } + return comment; + }; + + const requireCommentAuthor = (r: JiraRequest, comment: JiraComment) => { + if (comment.author_id !== r.user.account_id && !r.user.admin) { + throw new JiraError(403, ["You do not have the permission to edit this comment."]); + } + }; + + // Comments + + app.post( + `${API_V}/comment/list`, + handle( + async (r) => { + const body = await readJson(r.c); + const ids: number[] = Array.isArray(body.ids) ? body.ids.map(Number) : []; + const comments = ids + .map((id) => r.js.comments.get(id)) + .filter((comment): comment is JiraComment => Boolean(comment)); + return r.c.json({ + startAt: 0, + maxResults: comments.length, + total: comments.length, + isLast: true, + values: comments.map((comment) => formatComment(r, comment)), + }); + }, + { scopes: READ }, + ), + ); + + app.get( + `${API_V}/issue/:key/comment`, + handle( + (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const orderBy = r.c.req.query("orderBy") ?? "created"; + const descending = orderBy.startsWith("-"); + const comments = r.js.comments + .findBy("issue_id", issue.id) + .sort((a, b) => (descending ? b.id - a.id : a.id - b.id)); + const { startAt, maxResults } = pageParams(r.c, 5000, 5000); + return r.c.json({ + self: restUrl(r, `/issue/${issue.id}/comment`), + startAt, + maxResults, + total: comments.length, + comments: comments.slice(startAt, startAt + maxResults).map((comment) => formatComment(r, comment)), + }); + }, + { scopes: READ }, + ), + ); + + app.post( + `${API_V}/issue/:key/comment`, + handle( + async (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const body = await readJson(r.c); + const doc = readCommentBody(r, body.body); + const comment = addComment(r, issue, doc); + await emitCommentEvent(r, "comment_created", issue, comment); + await emitIssueEvent(r, "jira:issue_updated", r.js.issues.get(issue.id)!, { comments: [comment] }); + return r.c.json(formatComment(r, comment), 201); + }, + { scopes: WRITE }, + ), + ); + + app.get( + `${API_V}/issue/:key/comment/:id`, + handle( + (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + return r.c.json(formatComment(r, requireComment(r, issue))); + }, + { scopes: READ }, + ), + ); + + app.put( + `${API_V}/issue/:key/comment/:id`, + handle( + async (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const comment = requireComment(r, issue); + requireCommentAuthor(r, comment); + const body = await readJson(r.c); + const updated = r.js.comments.update(comment.id, { + body: readCommentBody(r, body.body), + update_author_id: r.user.account_id, + })!; + await emitCommentEvent(r, "comment_updated", issue, updated); + return r.c.json(formatComment(r, updated)); + }, + { scopes: WRITE }, + ), + ); + + app.delete( + `${API_V}/issue/:key/comment/:id`, + handle( + async (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const comment = requireComment(r, issue); + requireCommentAuthor(r, comment); + r.js.comments.delete(comment.id); + touchIssue(r.js, issue.id); + await emitCommentEvent(r, "comment_deleted", issue, comment); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + // Watchers + + app.get( + `${API_V}/issue/:key/watchers`, + handle( + (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + return r.c.json({ + self: restUrl(r, `/issue/${issue.key}/watchers`), + isWatching: issue.watcher_ids.includes(r.user.account_id), + watchCount: issue.watcher_ids.length, + watchers: issue.watcher_ids + .map((id) => formatUser(r, r.js.users.findOneBy("account_id", id))) + .filter(Boolean), + }); + }, + { scopes: READ }, + ), + ); + + app.post( + `${API_V}/issue/:key/watchers`, + handle( + async (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const body = await readJson(r.c); + const ref = typeof body === "string" ? body : r.user.account_id; + const user = findUser(r.js, ref); + if (!user) throw new JiraError(404, [`The user "${ref}" does not exist.`]); + if (!issue.watcher_ids.includes(user.account_id)) { + r.js.issues.update(issue.id, { watcher_ids: [...issue.watcher_ids, user.account_id] }); + } + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + app.delete( + `${API_V}/issue/:key/watchers`, + handle( + (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const ref = r.c.req.query("accountId") ?? r.c.req.query("username"); + if (!ref) throw new JiraError(400, ["You must specify an accountId."]); + const user = findUser(r.js, ref); + if (!user) throw new JiraError(404, [`The user "${ref}" does not exist.`]); + r.js.issues.update(issue.id, { watcher_ids: issue.watcher_ids.filter((id) => id !== user.account_id) }); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + // Worklogs + + const requireWorklog = (r: JiraRequest, issue: JiraIssue): JiraWorklog => { + const worklog = r.js.worklogs.get(Number(r.c.req.param("id"))); + if (!worklog || worklog.issue_id !== issue.id) { + throw new JiraError(404, [`Cannot find worklog with id: '${r.c.req.param("id")}'.`]); + } + return worklog; + }; + + app.get( + `${API_V}/issue/:key/worklog`, + handle( + (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const worklogs = r.js.worklogs.findBy("issue_id", issue.id).sort((a, b) => a.id - b.id); + const { startAt, maxResults } = pageParams(r.c, 5000, 5000); + return r.c.json({ + startAt, + maxResults, + total: worklogs.length, + worklogs: worklogs.slice(startAt, startAt + maxResults).map((worklog) => formatWorklog(r, worklog)), + }); + }, + { scopes: READ }, + ), + ); + + app.post( + `${API_V}/issue/:key/worklog`, + handle( + async (r) => { + const issue = requireIssue(r.js, r.c.req.param("key")); + const body = await readJson(r.c); + const seconds = timeSpentSeconds(body); + if (!seconds) throw fieldError("timeLogged", "You must indicate the time spent working."); + const worklog = insertFrom(r.js.worklogs, 10000, { + issue_id: issue.id, + author_id: r.user.account_id, + update_author_id: r.user.account_id, + comment: body.comment === undefined ? null : readBody(r, "comment", body.comment), + started: parseStarted(body.started), + time_spent_seconds: seconds, + }); + touchIssue(r.js, issue.id); + return r.c.json(formatWorklog(r, worklog), 201); + }, + { scopes: WRITE }, + ), + ); + + app.get( + `${API_V}/issue/:key/worklog/:id`, + handle((r) => r.c.json(formatWorklog(r, requireWorklog(r, requireIssue(r.js, r.c.req.param("key"))))), { + scopes: READ, + }), + ); + + app.put( + `${API_V}/issue/:key/worklog/:id`, + handle( + async (r) => { + const worklog = requireWorklog(r, requireIssue(r.js, r.c.req.param("key"))); + const body = await readJson(r.c); + const seconds = timeSpentSeconds(body); + const updated = r.js.worklogs.update(worklog.id, { + ...(seconds ? { time_spent_seconds: seconds } : {}), + ...(body.comment !== undefined ? { comment: readBody(r, "comment", body.comment) } : {}), + ...(body.started !== undefined ? { started: parseStarted(body.started) } : {}), + update_author_id: r.user.account_id, + })!; + touchIssue(r.js, worklog.issue_id); + return r.c.json(formatWorklog(r, updated)); + }, + { scopes: WRITE }, + ), + ); + + app.delete( + `${API_V}/issue/:key/worklog/:id`, + handle( + (r) => { + const worklog = requireWorklog(r, requireIssue(r.js, r.c.req.param("key"))); + r.js.worklogs.delete(worklog.id); + touchIssue(r.js, worklog.issue_id); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); + + // Issue links + + app.post( + `${API_V}/issueLink`, + handle( + async (r) => { + const body = await readJson(r.c); + const type = findLinkType(r.js, body.type); + if (!type) throw new JiraError(404, ["No issue link type with name or id specified found."]); + const inward = findIssue(r.js, body.inwardIssue?.key ?? body.inwardIssue?.id); + const outward = findIssue(r.js, body.outwardIssue?.key ?? body.outwardIssue?.id); + if (!inward || !outward) + throw new JiraError(404, ["Issue does not exist or you do not have permission to see it."]); + createLink(r.js, type.id, inward.id, outward.id); + if (body.comment?.body !== undefined) { + // Jira adds the link comment to the "from" issue, which is the outward side. + const comment = addComment(r, outward, readCommentBody(r, body.comment.body)); + await emitCommentEvent(r, "comment_created", outward, comment); + } + return r.c.body(null, 201); + }, + { scopes: WRITE }, + ), + ); + + const requireLink = (r: JiraRequest) => { + const link = r.js.issueLinks.get(Number(r.c.req.param("id"))); + if (!link) throw new JiraError(404, [`No issue link with id '${r.c.req.param("id")}' exists.`]); + return link; + }; + + app.get( + `${API_V}/issueLink/:id`, + handle( + (r) => { + const link = requireLink(r); + const type = r.js.issueLinkTypes.get(link.type_id)!; + return r.c.json({ + id: String(link.id), + self: restUrl(r, `/issueLink/${link.id}`), + type: { + id: String(type.id), + name: type.name, + inward: type.inward, + outward: type.outward, + self: restUrl(r, `/issueLinkType/${type.id}`), + }, + inwardIssue: formatIssueRef(r, r.js.issues.get(link.inward_issue_id)!), + outwardIssue: formatIssueRef(r, r.js.issues.get(link.outward_issue_id)!), + }); + }, + { scopes: READ }, + ), + ); + + app.delete( + `${API_V}/issueLink/:id`, + handle( + (r) => { + const link = requireLink(r); + r.js.issueLinks.delete(link.id); + return r.c.body(null, 204); + }, + { scopes: WRITE }, + ), + ); +} + +function readCommentBody(r: JiraRequest, value: unknown) { + if (r.version === "3" && typeof value === "string") throw new JiraError(400, ["INVALID_INPUT"]); + const doc = readBody(r, "comment", value); + if (!doc) throw fieldError("comment", "Comment body can not be empty!"); + return doc; +} + +function parseStarted(value: unknown): string { + if (typeof value !== "string") return new Date().toISOString(); + const normalized = value.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) throw fieldError("started", "Invalid started date."); + return date.toISOString(); +} diff --git a/packages/@emulators/jira/src/routes/webhooks.ts b/packages/@emulators/jira/src/routes/webhooks.ts new file mode 100644 index 00000000..4be367e7 --- /dev/null +++ b/packages/@emulators/jira/src/routes/webhooks.ts @@ -0,0 +1,236 @@ +import type { RouteContext } from "@emulators/core"; +import { API_V, JiraError, makeHandler, pageParams, readJson, type JiraRequest } from "../context.js"; +import { jiraTime } from "../ids.js"; +import { compileJql, JqlError } from "../jql.js"; +import { paginate } from "../lookup.js"; +import { insertFrom } from "../store.js"; +import { WEBHOOK_EVENTS } from "../webhooks.js"; +import type { JiraWebhook } from "../entities.js"; + +const W = "/rest/webhooks/1.0/webhook"; +const DYNAMIC_TTL_MS = 30 * 24 * 3600 * 1000; +const FILTER_KEY = "issue-related-events-section"; + +/** Returns an error message for an invalid JQL filter, or null. Compiling it catches unknown values too. */ +function jqlProblem(r: JiraRequest, jql: string | null): string | null { + if (!jql?.trim()) return null; + try { + compileJql(r.js, jql, r.user); + return null; + } catch (err) { + if (err instanceof JqlError) return err.message; + throw err; + } +} + +function eventProblem(events: unknown): string | null { + if (!Array.isArray(events) || events.length === 0) return "At least one event must be specified."; + const unknown = events.filter((event) => !WEBHOOK_EVENTS.includes(String(event))); + return unknown.length > 0 ? `Unsupported webhook events: ${unknown.join(", ")}.` : null; +} + +export function webhookRoutes({ app, store, baseUrl }: RouteContext): void { + const handle = makeHandler(store, baseUrl); + + const requireAdmin = (r: JiraRequest) => { + if (!r.user.admin) throw new JiraError(403, ["Only Jira administrators can manage webhooks."]); + }; + + const formatAdmin = (r: JiraRequest, webhook: JiraWebhook) => { + const author = r.js.users.all().find((user) => user.admin) ?? r.user; + return { + name: webhook.name, + url: webhook.url, + excludeBody: webhook.exclude_body, + filters: { [FILTER_KEY]: webhook.jql_filter ?? "" }, + events: webhook.events, + enabled: webhook.enabled, + self: `${r.siteUrl}${W}/${webhook.id}`, + lastUpdatedUser: author.account_id, + lastUpdatedDisplayName: author.display_name, + lastUpdated: new Date(webhook.updated_at).getTime(), + }; + }; + + const readAdminBody = async (r: JiraRequest, existing?: JiraWebhook) => { + const body = await readJson(r.c); + const url = body.url ?? existing?.url; + if (typeof url !== "string" || !/^https?:\/\//.test(url)) + throw new JiraError(400, [], { url: "A valid webhook URL is required." }); + const events = body.events ?? existing?.events; + const eventError = eventProblem(events); + if (eventError) throw new JiraError(400, [], { events: eventError }); + const jql = body.filters?.[FILTER_KEY] ?? existing?.jql_filter ?? null; + const jqlError = jqlProblem(r, jql); + if (jqlError) throw new JiraError(400, [], { filters: jqlError }); + return { + name: String(body.name ?? existing?.name ?? "Webhook"), + url, + events: events as string[], + jql_filter: jql || null, + enabled: typeof body.enabled === "boolean" ? body.enabled : (existing?.enabled ?? true), + exclude_body: typeof body.excludeBody === "boolean" ? body.excludeBody : (existing?.exclude_body ?? false), + secret: typeof body.secret === "string" && body.secret ? body.secret : (existing?.secret ?? null), + }; + }; + + const requireAdminWebhook = (r: JiraRequest) => { + const webhook = r.js.webhooks.get(Number(r.c.req.param("id"))); + if (!webhook || webhook.kind !== "admin") throw new JiraError(404, ["Webhook not found."]); + return webhook; + }; + + app.get( + W, + handle((r) => { + requireAdmin(r); + return r.c.json( + r.js.webhooks + .all() + .filter((w) => w.kind === "admin") + .map((w) => formatAdmin(r, w)), + ); + }), + ); + + app.post( + W, + handle(async (r) => { + requireAdmin(r); + const data = await readAdminBody(r); + const webhook = insertFrom(r.js.webhooks, 1, { ...data, kind: "admin", client_id: null, expiration_date: null }); + return r.c.json(formatAdmin(r, webhook), 201); + }), + ); + + app.get( + `${W}/:id`, + handle((r) => { + requireAdmin(r); + return r.c.json(formatAdmin(r, requireAdminWebhook(r))); + }), + ); + + app.put( + `${W}/:id`, + handle(async (r) => { + requireAdmin(r); + const webhook = requireAdminWebhook(r); + const data = await readAdminBody(r, webhook); + return r.c.json(formatAdmin(r, r.js.webhooks.update(webhook.id, data)!)); + }), + ); + + app.delete( + `${W}/:id`, + handle((r) => { + requireAdmin(r); + r.js.webhooks.delete(requireAdminWebhook(r).id); + return r.c.body(null, 204); + }), + ); + + // Dynamic webhooks for OAuth 2.0 and Connect apps + + const requireApp = (r: JiraRequest): string => { + if (!r.clientId) throw new JiraError(403, ["Only Connect and OAuth 2.0 apps can use this operation."]); + return r.clientId; + }; + + const appWebhooks = (r: JiraRequest, clientId: string) => + r.js.webhooks.all().filter((webhook) => webhook.kind === "dynamic" && webhook.client_id === clientId); + + const expiry = () => new Date(Date.now() + DYNAMIC_TTL_MS).toISOString(); + + app.post( + `${API_V}/webhook`, + handle( + async (r) => { + const clientId = requireApp(r); + const body = await readJson(r.c); + if (typeof body.url !== "string" || !/^https?:\/\//.test(body.url)) { + throw new JiraError(400, ["A valid webhook URL is required."]); + } + const specs: Array<{ events?: unknown; jqlFilter?: string; fieldIdsFilter?: string[] }> = Array.isArray( + body.webhooks, + ) + ? body.webhooks + : []; + const webhookRegistrationResult = specs.map((spec) => { + const errors = [ + eventProblem(spec.events), + spec.jqlFilter ? jqlProblem(r, spec.jqlFilter) : "The JQL filter is required.", + ].filter((error): error is string => Boolean(error)); + if (errors.length > 0) return { errors }; + const webhook = insertFrom(r.js.webhooks, 1, { + name: `${clientId} dynamic webhook`, + url: body.url, + events: spec.events as string[], + jql_filter: spec.jqlFilter ?? null, + enabled: true, + exclude_body: false, + secret: null, + kind: "dynamic", + client_id: clientId, + expiration_date: expiry(), + }); + return { createdWebhookId: webhook.id }; + }); + return r.c.json({ webhookRegistrationResult }); + }, + { scopes: ["manage:jira-webhook"] }, + ), + ); + + app.get( + `${API_V}/webhook`, + handle( + (r) => { + const webhooks = appWebhooks(r, requireApp(r)); + const page = paginate(webhooks, pageParams(r.c, 100)); + return r.c.json({ + ...page, + values: page.values.map((webhook) => ({ + id: webhook.id, + jqlFilter: webhook.jql_filter, + events: webhook.events, + expirationDate: jiraTime(webhook.expiration_date), + })), + }); + }, + { scopes: ["manage:jira-webhook"] }, + ), + ); + + app.delete( + `${API_V}/webhook`, + handle( + async (r) => { + const clientId = requireApp(r); + const body = await readJson(r.c); + const ids = new Set((Array.isArray(body.webhookIds) ? body.webhookIds : []).map(Number)); + if (ids.size === 0) throw new JiraError(400, ["webhookIds must not be empty."]); + for (const webhook of appWebhooks(r, clientId)) if (ids.has(webhook.id)) r.js.webhooks.delete(webhook.id); + return r.c.body(null, 202); + }, + { scopes: ["manage:jira-webhook"] }, + ), + ); + + app.put( + `${API_V}/webhook/refresh`, + handle( + async (r) => { + const clientId = requireApp(r); + const body = await readJson(r.c); + const ids = new Set((Array.isArray(body.webhookIds) ? body.webhookIds : []).map(Number)); + const expirationDate = expiry(); + for (const webhook of appWebhooks(r, clientId)) { + if (ids.has(webhook.id)) r.js.webhooks.update(webhook.id, { expiration_date: expirationDate }); + } + return r.c.json({ expirationDate: jiraTime(expirationDate) }); + }, + { scopes: ["manage:jira-webhook"] }, + ), + ); +} diff --git a/packages/@emulators/jira/src/seed.ts b/packages/@emulators/jira/src/seed.ts new file mode 100644 index 00000000..a329fedc --- /dev/null +++ b/packages/@emulators/jira/src/seed.ts @@ -0,0 +1,485 @@ +import type { Store } from "@emulators/core"; +import { getJiraStore, insertFrom } from "./store.js"; +import { accountId } from "./ids.js"; +import type { JiraBoardType, JiraCustomFieldType, JiraStatusCategory, JiraUser } from "./entities.js"; +import { addComment, createIssue, createLink, systemActor } from "./issue-service.js"; +import { findComponent, findIssue, findIssueType, findProject, findStatus, findUser, findVersion } from "./lookup.js"; +import { WEBHOOK_EVENTS } from "./webhooks.js"; +import { createProject, type CreateProjectInput } from "./services.js"; +import { textToAdf } from "./adf.js"; + +export const DEFAULT_CLOUD_ID = "11111111-2222-4333-8444-555555555555"; +export const DEFAULT_ADMIN_EMAIL = "admin@jira.local"; +export const DEFAULT_DEV_EMAIL = "dev@jira.local"; +export const DEFAULT_API_TOKEN = "jira_test_token"; +export const DEFAULT_DEV_API_TOKEN = "jira_dev_token"; +export const DEFAULT_OAUTH_CLIENT_ID = "jira_example_client_id"; +export const DEFAULT_OAUTH_CLIENT_SECRET = "example_client_secret"; +export const DEFAULT_OAUTH_SCOPES = [ + "read:jira-work", + "write:jira-work", + "read:jira-user", + "manage:jira-project", + "manage:jira-configuration", + "manage:jira-webhook", + "read:me", + "offline_access", +]; + +export function seedDefaults(store: Store, baseUrl: string): void { + const js = getJiraStore(store); + + if (!js.sites.all()[0]) js.sites.insert({ cloud_id: DEFAULT_CLOUD_ID, name: "emulate" }); + + const admin = ensureUser(store, { email: DEFAULT_ADMIN_EMAIL, display_name: "Jira Admin", admin: true }); + const dev = ensureUser(store, { email: DEFAULT_DEV_EMAIL, display_name: "Developer" }); + ensureApiToken(store, admin.account_id, DEFAULT_API_TOKEN); + ensureApiToken(store, dev.account_id, DEFAULT_DEV_API_TOKEN); + + const statusDefaults: Array<[string, JiraStatusCategory]> = [ + ["To Do", "new"], + ["In Progress", "indeterminate"], + ["Done", "done"], + ]; + for (const [name, category] of statusDefaults) ensureStatus(store, name, category); + + const typeDefaults: Array<[string, string, boolean, number]> = [ + ["Epic", "A big user story that needs to be broken down.", false, 1], + ["Story", "Functionality or a feature expressed as a user goal.", false, 0], + ["Task", "A small, distinct piece of work.", false, 0], + ["Bug", "A problem or error.", false, 0], + ["Subtask", "A small piece of work that's part of a larger task.", true, -1], + ]; + for (const [name, description, subtask, level] of typeDefaults) { + if (!js.issueTypes.findOneBy("name", name)) { + insertFrom(js.issueTypes, 10000, { name, description, subtask, hierarchy_level: level }); + } + } + + const priorityDefaults: Array<[string, string, string]> = [ + ["Highest", "This problem will block progress.", "#d04437"], + ["High", "Serious problem that could block progress.", "#f15C75"], + ["Medium", "Has the potential to affect progress.", "#f79232"], + ["Low", "Minor problem or easily worked around.", "#707070"], + ["Lowest", "Trivial problem with little or no impact on progress.", "#999999"], + ]; + for (const [name, description, color] of priorityDefaults) { + if (!js.priorities.findOneBy("name", name)) insertFrom(js.priorities, 1, { name, description, color }); + } + + const resolutionDefaults: Array<[string, string]> = [ + ["Done", "Work has been completed on this issue."], + ["Won't Do", "This issue won't be actioned."], + ["Duplicate", "The problem is a duplicate of an existing issue."], + ["Cannot Reproduce", "All attempts at reproducing this issue failed."], + ]; + for (const [name, description] of resolutionDefaults) { + if (!js.resolutions.findOneBy("name", name)) insertFrom(js.resolutions, 10000, { name, description }); + } + + const linkDefaults: Array<[string, string, string]> = [ + ["Blocks", "is blocked by", "blocks"], + ["Cloners", "is cloned by", "clones"], + ["Duplicate", "is duplicated by", "duplicates"], + ["Relates", "relates to", "relates to"], + ]; + for (const [name, inward, outward] of linkDefaults) { + if (!js.issueLinkTypes.findOneBy("name", name)) insertFrom(js.issueLinkTypes, 10000, { name, inward, outward }); + } + + const fieldDefaults: Array<[string, string, "number" | "date" | "sprint"]> = [ + ["customfield_10015", "Start date", "date"], + ["customfield_10016", "Story point estimate", "number"], + ["customfield_10020", "Sprint", "sprint"], + ]; + for (const [fieldId, name, type] of fieldDefaults) { + if (!js.customFields.findOneBy("field_id", fieldId)) { + js.customFields.insert({ field_id: fieldId, name, type, options: [] }); + } + } + + const project = ensureProject(store, { + key: "EMU", + name: "Emulate", + lead: admin.account_id, + description: "Default emulated project", + }); + + const board = js.boards.findBy("project_id", project.id)[0]; + let sprint = board ? js.sprints.findBy("board_id", board.id)[0] : undefined; + if (board && !sprint) { + const start = new Date(); + const end = new Date(start.getTime() + 14 * 24 * 3600 * 1000); + sprint = js.sprints.insert({ + board_id: board.id, + name: "EMU Sprint 1", + state: "active", + goal: "Ship the emulator", + start_date: start.toISOString(), + end_date: end.toISOString(), + complete_date: null, + }); + } + + if (!js.oauthApps.findOneBy("client_id", DEFAULT_OAUTH_CLIENT_ID)) { + js.oauthApps.insert({ + client_id: DEFAULT_OAUTH_CLIENT_ID, + client_secret: DEFAULT_OAUTH_CLIENT_SECRET, + name: "My Jira App", + redirect_uris: ["http://localhost:3000/api/auth/callback/atlassian"], + scopes: [...DEFAULT_OAUTH_SCOPES], + }); + } + + if (js.issues.all().length === 0) { + const actor = systemActor(store, js, baseUrl, admin); + const { issue } = createIssue(actor, { + fields: { + project: { key: project.key }, + issuetype: { name: "Task" }, + summary: "Ship Jira emulator", + description: textToAdf("Use local Jira state in tests without calling the real Jira API."), + assignee: { accountId: dev.account_id }, + labels: ["emulate"], + ...(sprint ? { customfield_10020: sprint.id } : {}), + }, + }); + addComment(actor, issue, textToAdf("This issue was seeded by the Jira emulator.")); + } +} + +export function ensureUser( + store: Store, + input: { + email: string; + display_name?: string; + account_id?: string; + admin?: boolean; + active?: boolean; + account_type?: "atlassian" | "app" | "customer"; + time_zone?: string; + }, +) { + const js = getJiraStore(store); + const existing = js.users.all().find((user) => user.email.toLowerCase() === input.email.toLowerCase()); + if (existing) return existing; + return js.users.insert({ + account_id: input.account_id ?? accountId(), + email: input.email, + display_name: input.display_name ?? input.email.split("@")[0], + active: input.active ?? true, + account_type: input.account_type ?? "atlassian", + time_zone: input.time_zone ?? "Etc/UTC", + locale: "en_US", + admin: input.admin ?? false, + }); +} + +export function ensureApiToken(store: Store, account: string, token: string, label = "emulate") { + const js = getJiraStore(store); + const existing = js.apiTokens.findOneBy("token", token); + if (existing) return js.apiTokens.update(existing.id, { account_id: account })!; + return js.apiTokens.insert({ token, account_id: account, label }); +} + +export function ensureStatus(store: Store, name: string, category: JiraStatusCategory, description = "") { + const js = getJiraStore(store); + const existing = js.statuses.all().find((status) => status.name.toLowerCase() === name.toLowerCase()); + if (existing) return existing; + return insertFrom(js.statuses, 10000, { name, category, description }); +} + +export function ensureProject(store: Store, input: CreateProjectInput) { + const js = getJiraStore(store); + return js.projects.findOneBy("key", input.key.toUpperCase()) ?? createProject(js, input); +} + +export interface JiraSeedConfig { + port?: number; + baseUrl?: string; + site?: { name?: string; cloud_id?: string }; + users?: Array<{ + email: string; + display_name?: string; + account_id?: string; + admin?: boolean; + active?: boolean; + time_zone?: string; + /** API token accepted for Basic auth (`email:token`) and as a bearer token. */ + api_token?: string; + }>; + statuses?: Array<{ name: string; category?: JiraStatusCategory; description?: string }>; + issue_types?: Array<{ name: string; description?: string; subtask?: boolean; hierarchy_level?: number }>; + custom_fields?: Array<{ id?: string; name: string; type: JiraCustomFieldType; options?: string[] }>; + projects?: Array<{ + key: string; + name: string; + description?: string; + lead?: string; + type?: "software" | "business" | "service_desk"; + statuses?: string[]; + issue_types?: string[]; + components?: string[]; + versions?: Array; + board?: boolean | { name?: string; type?: JiraBoardType }; + }>; + sprints?: Array<{ + /** Project key or board name. */ + project?: string; + board?: string; + name: string; + state?: "future" | "active" | "closed"; + goal?: string; + start_date?: string; + end_date?: string; + }>; + issues?: Array<{ + project: string; + summary: string; + type?: string; + description?: string; + status?: string; + priority?: string; + assignee?: string; + reporter?: string; + labels?: string[]; + /** Key or summary of the parent issue. */ + parent?: string; + due_date?: string; + components?: string[]; + fix_versions?: string[]; + sprint?: string; + custom_fields?: Record; + comments?: Array<{ body: string; author?: string }>; + }>; + links?: Array<{ type: string; inward: string; outward: string }>; + oauth_apps?: Array<{ + client_id: string; + client_secret: string; + name: string; + redirect_uris: string[]; + scopes?: string[] | string; + }>; + webhooks?: Array<{ + name?: string; + url: string; + events?: string[]; + jql?: string; + secret?: string; + enabled?: boolean; + exclude_body?: boolean; + }>; + strict_scopes?: boolean; +} + +function inferCategory(name: string): JiraStatusCategory { + const lower = name.toLowerCase(); + if (/(done|closed|resolved|complete|released|cancel)/.test(lower)) return "done"; + if (/(to do|todo|backlog|open|new|selected)/.test(lower)) return "new"; + return "indeterminate"; +} + +export function seedFromConfig(store: Store, baseUrl: string, config: JiraSeedConfig): void { + const js = getJiraStore(store); + + if (config.site) { + const site = js.sites.all()[0]; + const patch = { + name: config.site.name ?? site?.name ?? "emulate", + cloud_id: config.site.cloud_id ?? site?.cloud_id ?? DEFAULT_CLOUD_ID, + }; + if (site) js.sites.update(site.id, patch); + else js.sites.insert(patch); + } + + for (const userCfg of config.users ?? []) { + const user = ensureUser(store, userCfg); + const patch: Partial = {}; + if (userCfg.display_name) patch.display_name = userCfg.display_name; + if (userCfg.admin !== undefined) patch.admin = userCfg.admin; + if (userCfg.active !== undefined) patch.active = userCfg.active; + if (Object.keys(patch).length > 0) js.users.update(user.id, patch); + if (userCfg.api_token) ensureApiToken(store, user.account_id, userCfg.api_token); + } + const userRef = (ref: string | undefined) => (ref ? findUser(js, ref)?.account_id : undefined); + + for (const statusCfg of config.statuses ?? []) { + ensureStatus(store, statusCfg.name, statusCfg.category ?? inferCategory(statusCfg.name), statusCfg.description); + } + + for (const typeCfg of config.issue_types ?? []) { + if (findIssueType(js, typeCfg.name)) continue; + insertFrom(js.issueTypes, 10000, { + name: typeCfg.name, + description: typeCfg.description ?? "", + subtask: typeCfg.subtask ?? false, + hierarchy_level: typeCfg.hierarchy_level ?? (typeCfg.subtask ? -1 : 0), + }); + } + + for (const fieldCfg of config.custom_fields ?? []) { + if (js.customFields.all().some((field) => field.name === fieldCfg.name || field.field_id === fieldCfg.id)) continue; + const used = js.customFields.all().map((field) => Number(field.field_id.replace("customfield_", ""))); + const fieldId = fieldCfg.id ?? `customfield_${Math.max(10099, ...used) + 1}`; + js.customFields.insert({ + field_id: fieldId, + name: fieldCfg.name, + type: fieldCfg.type, + options: fieldCfg.options ?? [], + }); + } + + for (const projectCfg of config.projects ?? []) { + for (const name of projectCfg.statuses ?? []) ensureStatus(store, name, inferCategory(name)); + const project = ensureProject(store, { + key: projectCfg.key, + name: projectCfg.name, + description: projectCfg.description, + lead: userRef(projectCfg.lead) ?? js.users.all().find((user) => user.admin)?.account_id ?? null, + project_type_key: projectCfg.type, + statuses: projectCfg.statuses, + issue_types: projectCfg.issue_types, + board: projectCfg.board, + }); + for (const name of projectCfg.components ?? []) { + if (findComponent(js, project.id, { name })) continue; + insertFrom(js.components, 10000, { project_id: project.id, name, description: "", lead_account_id: null }); + } + for (const versionCfg of projectCfg.versions ?? []) { + const version = typeof versionCfg === "string" ? { name: versionCfg } : versionCfg; + if (findVersion(js, project.id, { name: version.name })) continue; + insertFrom(js.versions, 10000, { + project_id: project.id, + name: version.name, + description: "description" in version ? (version.description ?? "") : "", + released: "released" in version ? (version.released ?? false) : false, + archived: false, + start_date: null, + release_date: "release_date" in version ? (version.release_date ?? null) : null, + }); + } + } + + for (const sprintCfg of config.sprints ?? []) { + const project = sprintCfg.project ? findProject(js, sprintCfg.project) : undefined; + const board = sprintCfg.board + ? js.boards.all().find((b) => b.name === sprintCfg.board) + : project + ? js.boards.findBy("project_id", project.id).find((b) => b.type === "scrum") + : undefined; + if (!board || js.sprints.findBy("board_id", board.id).some((sprint) => sprint.name === sprintCfg.name)) continue; + const state = sprintCfg.state ?? "future"; + const start = sprintCfg.start_date ?? (state === "future" ? null : new Date().toISOString()); + const end = + sprintCfg.end_date ?? (start ? new Date(new Date(start).getTime() + 14 * 24 * 3600 * 1000).toISOString() : null); + js.sprints.insert({ + board_id: board.id, + name: sprintCfg.name, + state, + goal: sprintCfg.goal ?? "", + start_date: start, + end_date: end, + complete_date: state === "closed" ? (end ?? new Date().toISOString()) : null, + }); + } + + const findIssueRef = (ref: string | undefined, projectId?: number) => { + if (!ref) return undefined; + return ( + findIssue(js, ref) ?? + js.issues + .all() + .find((issue) => issue.summary === ref && (projectId === undefined || issue.project_id === projectId)) + ); + }; + + const sprintField = js.customFields.all().find((field) => field.type === "sprint")?.field_id; + for (const issueCfg of config.issues ?? []) { + const project = findProject(js, issueCfg.project); + if (!project) continue; + if (js.issues.findBy("project_id", project.id).some((issue) => issue.summary === issueCfg.summary)) continue; + const reporter = + findUser(js, issueCfg.reporter ?? "") ?? js.users.all().find((user) => user.admin) ?? js.users.all()[0]; + const actor = systemActor(store, js, baseUrl, reporter); + const parent = findIssueRef(issueCfg.parent, project.id); + const sprint = issueCfg.sprint + ? js.sprints.all().find((s) => s.name === issueCfg.sprint || String(s.id) === issueCfg.sprint) + : undefined; + const fields: Record = { + project: { key: project.key }, + issuetype: { name: issueCfg.type ?? "Task" }, + summary: issueCfg.summary, + ...(issueCfg.description !== undefined ? { description: textToAdf(issueCfg.description) } : {}), + ...(issueCfg.priority ? { priority: { name: issueCfg.priority } } : {}), + ...(issueCfg.assignee ? { assignee: { accountId: userRef(issueCfg.assignee) ?? issueCfg.assignee } } : {}), + ...(issueCfg.labels ? { labels: issueCfg.labels } : {}), + ...(parent ? { parent: { key: parent.key } } : {}), + ...(issueCfg.due_date ? { duedate: issueCfg.due_date } : {}), + ...(issueCfg.components ? { components: issueCfg.components.map((name) => ({ name })) } : {}), + ...(issueCfg.fix_versions ? { fixVersions: issueCfg.fix_versions.map((name) => ({ name })) } : {}), + // Closed sprints cannot take new issues, so they are recorded as sprint history below. + ...(sprint && sprintField && sprint.state !== "closed" ? { [sprintField]: sprint.id } : {}), + ...(issueCfg.custom_fields ?? {}), + }; + const { issue } = createIssue(actor, { fields }); + if (sprint?.state === "closed") js.issues.update(issue.id, { closed_sprint_ids: [sprint.id] }); + if (issueCfg.status) { + const status = findStatus(js, issueCfg.status); + if (status && project.status_ids.includes(status.id)) { + const done = status.category === "done"; + js.issues.update(issue.id, { + status_id: status.id, + resolution_id: done ? (js.resolutions.findOneBy("name", "Done")?.id ?? null) : null, + resolution_date: done ? new Date().toISOString() : null, + }); + } + } + for (const commentCfg of issueCfg.comments ?? []) { + const author = findUser(js, commentCfg.author ?? "") ?? reporter; + addComment(systemActor(store, js, baseUrl, author), js.issues.get(issue.id)!, textToAdf(commentCfg.body)); + } + } + + for (const linkCfg of config.links ?? []) { + const type = js.issueLinkTypes.all().find((t) => t.name.toLowerCase() === linkCfg.type.toLowerCase()); + const inward = findIssueRef(linkCfg.inward); + const outward = findIssueRef(linkCfg.outward); + if (type && inward && outward) createLink(js, type.id, inward.id, outward.id); + } + + for (const appCfg of config.oauth_apps ?? []) { + const scopes = Array.isArray(appCfg.scopes) + ? appCfg.scopes + : appCfg.scopes + ? appCfg.scopes.split(/[\s,]+/).filter(Boolean) + : [...DEFAULT_OAUTH_SCOPES]; + const data = { + client_secret: appCfg.client_secret, + name: appCfg.name, + redirect_uris: appCfg.redirect_uris, + scopes, + }; + const existing = js.oauthApps.findOneBy("client_id", appCfg.client_id); + if (existing) js.oauthApps.update(existing.id, data); + else js.oauthApps.insert({ client_id: appCfg.client_id, ...data }); + } + + for (const hookCfg of config.webhooks ?? []) { + if (js.webhooks.all().some((hook) => hook.url === hookCfg.url && hook.name === (hookCfg.name ?? "Webhook"))) + continue; + insertFrom(js.webhooks, 1, { + name: hookCfg.name ?? "Webhook", + url: hookCfg.url, + events: hookCfg.events ?? [...WEBHOOK_EVENTS], + jql_filter: hookCfg.jql ?? null, + enabled: hookCfg.enabled ?? true, + exclude_body: hookCfg.exclude_body ?? false, + secret: hookCfg.secret ?? null, + kind: "admin", + client_id: null, + expiration_date: null, + }); + } + + if (config.strict_scopes !== undefined) store.setData("jira.strict_scopes", config.strict_scopes); +} diff --git a/packages/@emulators/jira/src/services.ts b/packages/@emulators/jira/src/services.ts new file mode 100644 index 00000000..c6b0f02a --- /dev/null +++ b/packages/@emulators/jira/src/services.ts @@ -0,0 +1,122 @@ +import type { + JiraBoard, + JiraBoardType, + JiraComponent, + JiraIssue, + JiraProject, + JiraSprint, + JiraVersion, +} from "./entities.js"; +import { insertFrom, type JiraStore } from "./store.js"; + +export interface CreateProjectInput { + key: string; + name: string; + description?: string; + lead?: string | null; + project_type_key?: JiraProject["project_type_key"]; + /** Status names in workflow order. Defaults to To Do, In Progress, Done. */ + statuses?: string[]; + /** Issue type names. Defaults to every issue type. */ + issue_types?: string[]; + /** Software projects get a scrum board unless this is false. */ + board?: boolean | { name?: string; type?: JiraBoardType }; +} + +/** Creates a project with its workflow statuses, issue types, and (for software projects) a board. */ +export function createProject(js: JiraStore, input: CreateProjectInput): JiraProject { + const byName = (items: T[], names: string[]) => + names + .map((name) => items.find((item) => item.name.toLowerCase() === name.toLowerCase())?.id) + .filter((id): id is number => id !== undefined); + const project = insertFrom(js.projects, 10000, { + key: input.key.toUpperCase(), + name: input.name, + description: input.description ?? "", + lead_account_id: input.lead ?? null, + project_type_key: input.project_type_key ?? "software", + issue_sequence: 0, + status_ids: byName(js.statuses.all(), input.statuses ?? ["To Do", "In Progress", "Done"]), + issue_type_ids: byName(js.issueTypes.all(), input.issue_types ?? js.issueTypes.all().map((type) => type.name)), + }); + if (input.board !== false && project.project_type_key === "software") { + const board = typeof input.board === "object" ? input.board : {}; + js.boards.insert({ + name: board.name ?? `${project.key} board`, + type: board.type ?? "scrum", + project_id: project.id, + }); + } + return project; +} + +/** Bumps an issue's `updated` timestamp after a change to something that hangs off it. */ +export function touchIssue(js: JiraStore, issueId: number): void { + js.issues.update(issueId, {}); +} + +/** Removes an issue and everything that hangs off it. Subtasks are removed too. */ +export function deleteIssueRecord(js: JiraStore, issue: JiraIssue): void { + for (const child of js.issues.findBy("parent_id", issue.id)) { + if (js.issueTypes.get(child.issue_type_id)?.subtask) deleteIssueRecord(js, child); + else js.issues.update(child.id, { parent_id: null }); + } + for (const comment of js.comments.findBy("issue_id", issue.id)) js.comments.delete(comment.id); + for (const entry of js.changelogs.findBy("issue_id", issue.id)) js.changelogs.delete(entry.id); + for (const worklog of js.worklogs.findBy("issue_id", issue.id)) js.worklogs.delete(worklog.id); + for (const link of [ + ...js.issueLinks.findBy("inward_issue_id", issue.id), + ...js.issueLinks.findBy("outward_issue_id", issue.id), + ]) { + js.issueLinks.delete(link.id); + } + js.issues.delete(issue.id); +} + +export function deleteProjectRecord(js: JiraStore, project: JiraProject): void { + for (const issue of js.issues.findBy("project_id", project.id)) { + if (js.issues.get(issue.id)) deleteIssueRecord(js, issue); + } + for (const component of js.components.findBy("project_id", project.id)) js.components.delete(component.id); + for (const version of js.versions.findBy("project_id", project.id)) js.versions.delete(version.id); + for (const board of js.boards.findBy("project_id", project.id)) deleteBoardRecord(js, board); + js.projects.delete(project.id); +} + +/** Removes a component and drops it from every issue that uses it. */ +export function deleteComponentRecord(js: JiraStore, component: JiraComponent): void { + for (const issue of js.issues.findBy("project_id", component.project_id)) { + if (issue.component_ids.includes(component.id)) { + js.issues.update(issue.id, { component_ids: issue.component_ids.filter((id) => id !== component.id) }); + } + } + js.components.delete(component.id); +} + +/** Removes a version and drops it from every issue's fix versions. */ +export function deleteVersionRecord(js: JiraStore, version: JiraVersion): void { + for (const issue of js.issues.findBy("project_id", version.project_id)) { + if (issue.fix_version_ids.includes(version.id)) { + js.issues.update(issue.id, { fix_version_ids: issue.fix_version_ids.filter((id) => id !== version.id) }); + } + } + js.versions.delete(version.id); +} + +/** Removes a sprint and detaches it from every issue, current or past. */ +export function deleteSprintRecord(js: JiraStore, sprint: JiraSprint): void { + for (const issue of js.issues.all()) { + if (issue.sprint_id === sprint.id || issue.closed_sprint_ids.includes(sprint.id)) { + js.issues.update(issue.id, { + sprint_id: issue.sprint_id === sprint.id ? null : issue.sprint_id, + closed_sprint_ids: issue.closed_sprint_ids.filter((id) => id !== sprint.id), + }); + } + } + js.sprints.delete(sprint.id); +} + +export function deleteBoardRecord(js: JiraStore, board: JiraBoard): void { + for (const sprint of js.sprints.findBy("board_id", board.id)) deleteSprintRecord(js, sprint); + js.boards.delete(board.id); +} diff --git a/packages/@emulators/jira/src/store.ts b/packages/@emulators/jira/src/store.ts new file mode 100644 index 00000000..9bffc03e --- /dev/null +++ b/packages/@emulators/jira/src/store.ts @@ -0,0 +1,91 @@ +import { Store, type Collection, type Entity, type InsertInput } from "@emulators/core"; +import type { + JiraApiToken, + JiraBoard, + JiraChangelog, + JiraComment, + JiraComponent, + JiraCustomField, + JiraIssue, + JiraIssueLink, + JiraIssueLinkType, + JiraIssueType, + JiraOAuthApp, + JiraOAuthToken, + JiraPriority, + JiraProject, + JiraResolution, + JiraSite, + JiraSprint, + JiraStatus, + JiraUser, + JiraVersion, + JiraWebhook, + JiraWebhookDelivery, + JiraWorklog, +} from "./entities.js"; + +export interface JiraStore { + sites: Collection; + users: Collection; + apiTokens: Collection; + projects: Collection; + issueTypes: Collection; + statuses: Collection; + priorities: Collection; + resolutions: Collection; + customFields: Collection; + issues: Collection; + comments: Collection; + changelogs: Collection; + worklogs: Collection; + issueLinkTypes: Collection; + issueLinks: Collection; + components: Collection; + versions: Collection; + boards: Collection; + sprints: Collection; + webhooks: Collection; + webhookDeliveries: Collection; + oauthApps: Collection; + oauthTokens: Collection; +} + +export function getJiraStore(store: Store): JiraStore { + return { + sites: store.collection("jira.sites", ["cloud_id"]), + users: store.collection("jira.users", ["account_id", "email"]), + apiTokens: store.collection("jira.api_tokens", ["token", "account_id"]), + projects: store.collection("jira.projects", ["key"]), + issueTypes: store.collection("jira.issue_types", ["name"]), + statuses: store.collection("jira.statuses", ["name"]), + priorities: store.collection("jira.priorities", ["name"]), + resolutions: store.collection("jira.resolutions", ["name"]), + customFields: store.collection("jira.custom_fields", ["field_id"]), + issues: store.collection("jira.issues", ["key", "project_id", "parent_id", "sprint_id"]), + comments: store.collection("jira.comments", ["issue_id"]), + changelogs: store.collection("jira.changelogs", ["issue_id"]), + worklogs: store.collection("jira.worklogs", ["issue_id"]), + issueLinkTypes: store.collection("jira.issue_link_types", ["name"]), + issueLinks: store.collection("jira.issue_links", ["inward_issue_id", "outward_issue_id"]), + components: store.collection("jira.components", ["project_id"]), + versions: store.collection("jira.versions", ["project_id"]), + boards: store.collection("jira.boards", ["project_id"]), + sprints: store.collection("jira.sprints", ["board_id"]), + webhooks: store.collection("jira.webhooks", []), + webhookDeliveries: store.collection("jira.webhook_deliveries", ["webhook_id"]), + oauthApps: store.collection("jira.oauth_apps", ["client_id"]), + oauthTokens: store.collection("jira.oauth_tokens", ["token"]), + }; +} + +/** + * Jira uses numeric string IDs that start at 10000 for most resources. + * Inserting with an explicit ID moves the collection's auto ID forward, so + * every later insert continues from there. + */ +export function insertFrom(collection: Collection, base: number, data: InsertInput): T { + if (data.id != null) return collection.insert(data); + const next = collection.snapshot().autoId; + return collection.insert({ ...data, id: Math.max(next, base) }); +} diff --git a/packages/@emulators/jira/src/webhooks.ts b/packages/@emulators/jira/src/webhooks.ts new file mode 100644 index 00000000..d5ad007c --- /dev/null +++ b/packages/@emulators/jira/src/webhooks.ts @@ -0,0 +1,165 @@ +import { createHmac, randomUUID } from "node:crypto"; +import type { JiraChangelog, JiraComment, JiraIssue, JiraWebhook } from "./entities.js"; +import type { Actor, EditIssueResult } from "./issue-service.js"; +import { formatUser, type Fmt } from "./formatters.js"; +import { formatComment, formatIssue, formatIssueRef } from "./issue-format.js"; +import { compileJql, JqlError } from "./jql.js"; +import { insertFrom } from "./store.js"; + +export type JiraIssueEvent = "jira:issue_created" | "jira:issue_updated" | "jira:issue_deleted"; +export type JiraCommentEvent = "comment_created" | "comment_updated" | "comment_deleted"; + +export const WEBHOOK_EVENTS: readonly string[] = [ + "jira:issue_created", + "jira:issue_updated", + "jira:issue_deleted", + "comment_created", + "comment_updated", + "comment_deleted", +]; + +export interface IssueEventOptions { + changelog?: JiraChangelog | null; + comments?: JiraComment[]; +} + +/** Webhook payloads use the REST API v2 representation at the site URL. */ +function payloadFmt(actor: Actor): Fmt { + return { js: actor.js, baseUrl: actor.baseUrl, siteUrl: actor.baseUrl, version: "2", user: actor.user }; +} + +function issueEventType(event: JiraIssueEvent, opts: IssueEventOptions): string { + if (event === "jira:issue_created") return "issue_created"; + if (event === "jira:issue_deleted") return "issue_deleted"; + const items = opts.changelog?.items ?? []; + if (items.length === 0 && opts.comments?.length) return "issue_commented"; + if (items.some((item) => item.field === "status")) return "issue_generic"; + if (items.length > 0 && items.every((item) => item.field === "assignee")) return "issue_assigned"; + return "issue_updated"; +} + +function matchesFilter(actor: Actor, webhook: JiraWebhook, issue: JiraIssue): boolean { + if (!webhook.jql_filter?.trim()) return true; + try { + return compileJql(actor.js, webhook.jql_filter, actor.user)(issue); + } catch (err) { + if (err instanceof JqlError) return false; + throw err; + } +} + +function matchingWebhooks(actor: Actor, event: string, issue: JiraIssue): JiraWebhook[] { + const now = Date.now(); + return actor.js.webhooks.all().filter((webhook) => { + if (!webhook.enabled || !webhook.events.includes(event)) return false; + if (webhook.expiration_date && new Date(webhook.expiration_date).getTime() <= now) return false; + return matchesFilter(actor, webhook, issue); + }); +} + +export async function emitIssueEvent( + actor: Actor, + event: JiraIssueEvent, + issue: JiraIssue, + opts: IssueEventOptions = {}, +): Promise { + const webhooks = matchingWebhooks(actor, event, issue); + if (webhooks.length === 0) return; + const fmt = payloadFmt(actor); + const payload: Record = { + timestamp: Date.now(), + webhookEvent: event, + issue_event_type_name: issueEventType(event, opts), + user: formatUser(fmt, actor.user), + issue: formatIssue(fmt, issue), + }; + if (opts.changelog) payload.changelog = { id: String(opts.changelog.id), items: opts.changelog.items }; + if (opts.comments?.length) payload.comment = formatComment(fmt, opts.comments[opts.comments.length - 1]); + await deliverAll(actor, webhooks, event, payload); +} + +export async function emitCommentEvent( + actor: Actor, + event: JiraCommentEvent, + issue: JiraIssue, + comment: JiraComment, +): Promise { + const webhooks = matchingWebhooks(actor, event, issue); + if (webhooks.length === 0) return; + const fmt = payloadFmt(actor); + const ref = formatIssueRef(fmt, issue); + const project = actor.js.projects.get(issue.project_id); + await deliverAll(actor, webhooks, event, { + timestamp: Date.now(), + webhookEvent: event, + comment: formatComment(fmt, comment), + issue: { + ...ref, + fields: { + ...ref.fields, + project: project ? { id: String(project.id), key: project.key, name: project.name } : null, + }, + }, + }); +} + +/** Sends the issue and comment events for the result of an edit or transition. */ +export async function emitEditEvents(actor: Actor, result: EditIssueResult): Promise { + for (const comment of result.comments) { + await emitCommentEvent(actor, "comment_created", result.issue, comment); + } + // Like Jira, one edit sends one jira:issue_updated, carrying both the changelog and the new comment. + if (result.changelog || result.comments.length > 0) { + await emitIssueEvent(actor, "jira:issue_updated", result.issue, { + changelog: result.changelog, + comments: result.comments, + }); + } +} + +/** Admin webhooks each get their own request. Dynamic webhooks are grouped per app URL with matchedWebhookIds. */ +async function deliverAll(actor: Actor, webhooks: JiraWebhook[], event: string, payload: Record) { + const admin = webhooks.filter((webhook) => webhook.kind === "admin"); + for (const webhook of admin) await deliver(actor, [webhook], event, payload); + + const groups = new Map(); + for (const webhook of webhooks.filter((w) => w.kind === "dynamic")) { + const key = `${webhook.client_id}\n${webhook.url}`; + groups.set(key, [...(groups.get(key) ?? []), webhook]); + } + for (const group of groups.values()) { + await deliver(actor, group, event, { ...payload, matchedWebhookIds: group.map((webhook) => webhook.id) }); + } +} + +async function deliver(actor: Actor, webhooks: JiraWebhook[], event: string, payload: Record) { + const [webhook] = webhooks; + const body = webhook.exclude_body ? "" : JSON.stringify(payload); + const headers: Record = { + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "Atlassian Webhook HTTP Client", + "X-Atlassian-Webhook-Identifier": randomUUID(), + }; + if (webhook.secret) { + headers["X-Hub-Signature"] = `sha256=${createHmac("sha256", webhook.secret).update(body).digest("hex")}`; + } + + let status: number | null = null; + let error: string | null = null; + try { + const res = await fetch(webhook.url, { method: "POST", headers, body, signal: AbortSignal.timeout(10000) }); + status = res.status; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } + + insertFrom(actor.js.webhookDeliveries, 1, { + webhook_id: webhook.id, + event, + url: webhook.url, + status, + error, + payload: webhook.exclude_body ? null : payload, + headers, + }); +} diff --git a/packages/@emulators/jira/tsconfig.json b/packages/@emulators/jira/tsconfig.json new file mode 100644 index 00000000..c8c92cbd --- /dev/null +++ b/packages/@emulators/jira/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/@emulators/jira/tsup.config.ts b/packages/@emulators/jira/tsup.config.ts new file mode 100644 index 00000000..59a7354c --- /dev/null +++ b/packages/@emulators/jira/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; +import { cpSync, mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const copyFonts = async () => { + const src = resolve(__dirname, "../core/src/fonts"); + const dest = resolve(__dirname, "dist/fonts"); + mkdirSync(dest, { recursive: true }); + cpSync(src, dest, { recursive: true }); +}; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + noExternal: [/^@emulators\/core/], + onSuccess: copyFonts, +}); diff --git a/packages/@emulators/jira/vitest.config.ts b/packages/@emulators/jira/vitest.config.ts new file mode 100644 index 00000000..c1433e6e --- /dev/null +++ b/packages/@emulators/jira/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/emulate/package.json b/packages/emulate/package.json index 75e2a43f..63cfccdb 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -33,6 +33,7 @@ "google", "oauth", "testing", + "jira", "ci", "local-development" ], @@ -74,6 +75,7 @@ "@emulators/clerk": "workspace:*", "@emulators/linear": "workspace:*", "@emulators/twilio": "workspace:*", + "@emulators/jira": "workspace:*", "tsup": "^8", "typescript": "^5.7" } diff --git a/packages/emulate/src/__tests__/jira-service.test.ts b/packages/emulate/src/__tests__/jira-service.test.ts new file mode 100644 index 00000000..67ddc771 --- /dev/null +++ b/packages/emulate/src/__tests__/jira-service.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { createEmulator } from "../api.js"; +import { SERVICE_NAMES, SERVICE_REGISTRY } from "../registry.js"; + +const basic = (email: string, token: string) => `Basic ${Buffer.from(`${email}:${token}`).toString("base64")}`; + +describe("jira service", () => { + it("is registered with the CLI", () => { + expect(SERVICE_NAMES).toContain("jira"); + expect(SERVICE_REGISTRY.jira.label).toContain("Jira"); + }); + + it("starts through createEmulator, applies seed config, and resets", async () => { + const jira = await createEmulator({ + service: "jira", + port: 14300, + seed: { + jira: { + users: [{ email: "qa@example.com", display_name: "QA", api_token: "qa_token" }], + projects: [{ key: "QA", name: "Quality" }], + issues: [{ project: "QA", summary: "Seeded through createEmulator" }], + }, + }, + }); + + try { + const me = await fetch(`${jira.url}/rest/api/3/myself`, { + headers: { Authorization: basic("qa@example.com", "qa_token") }, + }); + expect(me.status).toBe(200); + expect(((await me.json()) as { displayName: string }).displayName).toBe("QA"); + + const search = await fetch(`${jira.url}/rest/api/3/search/jql`, { + method: "POST", + headers: { Authorization: basic("admin@jira.local", "jira_test_token"), "Content-Type": "application/json" }, + body: JSON.stringify({ jql: "project = QA", fields: ["summary"] }), + }); + const found = (await search.json()) as { issues: Array<{ key: string; fields: { summary: string } }> }; + expect(found.issues.map((issue) => issue.fields.summary)).toEqual(["Seeded through createEmulator"]); + + const missing = await fetch(`${jira.url}/rest/api/3/issue/QA-99`, { + headers: { Authorization: basic("admin@jira.local", "jira_test_token") }, + }); + expect(missing.status).toBe(404); + expect(((await missing.json()) as { errorMessages: string[] }).errorMessages).toHaveLength(1); + + await fetch(`${jira.url}/rest/api/3/issue`, { + method: "POST", + headers: { Authorization: basic("admin@jira.local", "jira_test_token"), "Content-Type": "application/json" }, + body: JSON.stringify({ fields: { project: { key: "QA" }, issuetype: { name: "Task" }, summary: "Temporary" } }), + }); + jira.reset(); + const afterReset = await fetch(`${jira.url}/rest/api/3/issue/QA-2`, { + headers: { Authorization: basic("admin@jira.local", "jira_test_token") }, + }); + expect(afterReset.status).toBe(404); + const seeded = await fetch(`${jira.url}/rest/api/3/issue/QA-1`, { + headers: { Authorization: basic("admin@jira.local", "jira_test_token") }, + }); + expect(seeded.status).toBe(200); + } finally { + await jira.close(); + } + }); +}); diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index a8c5bac6..0e3670a7 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -120,6 +120,12 @@ Configuration: Twilio API coverage: Accounts, API keys, phone numbers, Messaging, Verify, Voice, Conversations, webhooks, simulators, and inspector. +Jira API coverage: + Jira Cloud REST API v3 and v2, JQL search (/search/jql with nextPageToken), issues, transitions, comments, + worklogs, links, projects, users, Agile boards and sprints, webhooks, and OAuth 2.0 (3LO). + Authenticate with Basic auth email:api_token (default admin@jira.local:jira_test_token). + OAuth clients call the API through /ex/jira/{cloudId}; inspect state at GET /. + Slack interactivity: Configure slack.oauth_apps[].interactivity_url and slack.signing_secret for signed button and modal callbacks. POST /_slack/simulate/block-actions clicks a stored button; POST /_slack/simulate/view-submission submits a modal. diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 4891200c..228d22ed 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -40,6 +40,7 @@ const SERVICE_NAME_LIST = [ "clerk", "linear", "twilio", + "jira", ] as const; export type ServiceName = (typeof SERVICE_NAME_LIST)[number]; export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST; @@ -636,6 +637,62 @@ export const SERVICE_REGISTRY: Record = { }, }, }, + jira: { + label: "Jira Cloud REST API emulator", + endpoints: + "REST API v2/v3, JQL search, issues, transitions, comments, worklogs, links, projects, users, Agile boards and sprints, webhooks, OAuth 2.0 (3LO), inspector", + async load() { + const mod = await import("@emulators/jira"); + return { plugin: mod.jiraPlugin, seedFromConfig: mod.seedFromConfig }; + }, + defaultFallback(cfg) { + const firstEmail = (cfg?.users as Array<{ email?: string }> | undefined)?.[0]?.email ?? "admin@jira.local"; + return { login: firstEmail, id: 1, scopes: [] }; + }, + initConfig: { + jira: { + site: { name: "acme" }, + users: [ + { email: "admin@example.com", display_name: "Admin User", admin: true, api_token: "jira_test_token" }, + { email: "dev@example.com", display_name: "Developer", api_token: "jira_dev_token" }, + ], + projects: [ + { + key: "ENG", + name: "Engineering", + lead: "admin@example.com", + statuses: ["To Do", "In Progress", "In Review", "Done"], + components: ["API", "Web"], + versions: ["1.0.0"], + }, + ], + sprints: [{ project: "ENG", name: "ENG Sprint 1", state: "active" }], + issues: [ + { + project: "ENG", + summary: "Fix local checkout test", + type: "Bug", + description: "Reproduce and fix the checkout failure.", + status: "To Do", + priority: "High", + assignee: "dev@example.com", + labels: ["checkout"], + sprint: "ENG Sprint 1", + }, + ], + oauth_apps: [ + { + client_id: "jira_example_client_id", + client_secret: "example_client_secret", + name: "My Jira App", + redirect_uris: ["http://localhost:3000/api/auth/callback/atlassian"], + scopes: ["read:jira-work", "write:jira-work", "read:jira-user", "manage:jira-webhook", "offline_access"], + }, + ], + strict_scopes: false, + }, + }, + }, }; export const DEFAULT_TOKENS = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8abc8c2..1bbbd1d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -682,6 +682,25 @@ importers: specifier: ^4.1.0 version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(vite@8.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + packages/@emulators/jira: + dependencies: + '@emulators/core': + specifier: workspace:* + version: link:../core + devDependencies: + jira.js: + specifier: ^6.2.0 + version: 6.2.0 + tsup: + specifier: ^8 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.23)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.7 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(vite@8.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + packages/@emulators/linear: dependencies: '@emulators/core': @@ -877,6 +896,9 @@ importers: '@emulators/google': specifier: workspace:* version: link:../@emulators/google + '@emulators/jira': + specifier: workspace:* + version: link:../@emulators/jira '@emulators/linear': specifier: workspace:* version: link:../@emulators/linear @@ -8408,6 +8430,10 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} + jira.js@6.2.0: + resolution: {integrity: sha512-tF6FUfByN0ezxR1SoA5iZ49FI0U/GdHLR+r3FDq4aqHO2MlVKE2VitAsnsa1ThFhdT82hTSCYXw8jT9zbeOIRw==} + engines: {node: '>=22'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -19872,6 +19898,10 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 + jira.js@6.2.0: + dependencies: + zod: 4.6.5 + jiti@2.6.1: {} jiti@2.7.0: {} diff --git a/skills/emulate/SKILL.md b/skills/emulate/SKILL.md index bbb75ffc..fb32e13e 100644 --- a/skills/emulate/SKILL.md +++ b/skills/emulate/SKILL.md @@ -1,6 +1,6 @@ --- name: emulate -description: Local drop-in API emulator for Vercel, GitHub, Google, Slack, Apple, Microsoft, AWS, Clerk, Linear, Twilio, and other developer APIs. Use when the user needs to start emulated services, configure seed data, write tests against local APIs, set up CI without network access, or work with the emulate CLI or programmatic API. Triggers include "start the emulator", "emulate services", "mock API locally", "create emulator config", "test against local API", "npx emulate", or any task requiring local service emulation. +description: Local drop-in API emulator for Vercel, GitHub, Google, Slack, Apple, Microsoft, AWS, Clerk, Linear, Twilio, Jira, and other developer APIs. Use when the user needs to start emulated services, configure seed data, write tests against local APIs, set up CI without network access, or work with the emulate CLI or programmatic API. Triggers include "start the emulator", "emulate services", "mock API locally", "create emulator config", "test against local API", "npx emulate", or any task requiring local service emulation. allowed-tools: Bash(npx emulate:*) --- @@ -32,6 +32,7 @@ All services listen on IPv4 loopback (`127.0.0.1`) by default: | Clerk | 4011 | | Linear | 4012 | | Twilio | 4013 | +| Jira | 4014 | ## CLI @@ -108,7 +109,7 @@ For GitHub App tests, inspect secret-free minted installation-token metadata at | Option | Default | Description | |--------|---------|-------------| -| `service` | *(required)* | `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, `'okta'`, `'aws'`, `'resend'`, `'stripe'`, `'mongoatlas'`, `'clerk'`, `'linear'`, or `'twilio'` | +| `service` | *(required)* | `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, `'okta'`, `'aws'`, `'resend'`, `'stripe'`, `'mongoatlas'`, `'clerk'`, `'linear'`, `'twilio'`, or `'jira'` | | `port` | `4000` | Port for the HTTP server | | `hostname` | `127.0.0.1` | Listening address for built-in and custom emulators | | `seed` | none | Inline seed data (same shape as YAML config) | @@ -426,6 +427,22 @@ twilio: conversations: services: - friendly_name: Local Conversations + +jira: + users: + - email: admin@example.com + display_name: Admin User + admin: true + api_token: jira_test_token + projects: + - key: ENG + name: Engineering + statuses: [To Do, In Progress, In Review, Done] + issues: + - project: ENG + summary: Fix local checkout test + type: Bug + status: To Do ``` `slack.signing_secret` signs every outbound event subscription callback. Signed callbacks include `X-Slack-Request-Timestamp` and `X-Slack-Signature`, calculated as `v0=:")>` over the exact serialized callback body. Configure the receiver with the same secret and verify the unparsed request body. Callbacks are unsigned when the secret is absent or empty. @@ -493,6 +510,7 @@ APPLE_EMULATOR_URL=http://localhost:4004 MICROSOFT_EMULATOR_URL=http://localhost:4005 AWS_EMULATOR_URL=http://localhost:4007 LINEAR_EMULATOR_URL=http://localhost:4012 +JIRA_EMULATOR_URL=http://localhost:4014 ``` Then use these in your app to construct API and OAuth URLs. See each service's skill for SDK-specific override instructions. @@ -544,6 +562,7 @@ packages/ slack/ # Slack Web API, OAuth, incoming webhooks plugin linear/ # Linear GraphQL API, OAuth, webhooks plugin twilio/ # Twilio Messaging, Verify, Voice, webhooks plugin + jira/ # Jira Cloud REST v2/v3, JQL, Agile, webhooks, OAuth 2.0 plugin apple/ # Sign in with Apple / OIDC plugin microsoft/ # Microsoft Entra ID OAuth 2.0 / OIDC plugin aws/ # AWS S3, SQS, IAM, STS plugin diff --git a/skills/jira/SKILL.md b/skills/jira/SKILL.md new file mode 100644 index 00000000..31b7a5e1 --- /dev/null +++ b/skills/jira/SKILL.md @@ -0,0 +1,196 @@ +--- +name: jira +description: Emulated Jira Cloud REST API for local development and testing. Use when the user needs to test Jira integrations locally, emulate Jira issues, JQL search, transitions, comments, worklogs, projects, boards, sprints, webhooks, Atlassian OAuth 2.0 (3LO), or work with the Jira API without hitting a real Atlassian site. Triggers include "Jira API", "emulate Jira", "mock Jira", "local Jira", "JQL", "Jira webhook", "Atlassian OAuth", "jira.js", "Jira sprint", or any task requiring a local Jira API. +allowed-tools: Bash(npx emulate:*) +--- + +# Jira API Emulator + +Stateful Jira Cloud emulation covering the platform REST API (v3 and v2), the Jira Software Agile API, JQL search, webhooks, and Atlassian OAuth 2.0 (3LO). Writes record changelog history and dispatch Jira-shaped webhooks. + +## Start + +```bash +# Jira only +npx emulate --service jira +``` + +Default URL: `http://localhost:4014` when all services are started, or `http://localhost:4000` when Jira is the only service. + +## URL Mapping + +| Real Jira URL | Emulator URL | +|---------------|--------------| +| `https://your-domain.atlassian.net/rest/api/3/...` | `$JIRA_EMULATOR_URL/rest/api/3/...` | +| `https://your-domain.atlassian.net/rest/api/2/...` | `$JIRA_EMULATOR_URL/rest/api/2/...` | +| `https://your-domain.atlassian.net/rest/agile/1.0/...` | `$JIRA_EMULATOR_URL/rest/agile/1.0/...` | +| `https://api.atlassian.com/ex/jira/{cloudId}/rest/...` | `$JIRA_EMULATOR_URL/ex/jira/{cloudId}/rest/...` | +| `https://auth.atlassian.com/authorize` | `$JIRA_EMULATOR_URL/authorize` | +| `https://auth.atlassian.com/oauth/token` | `$JIRA_EMULATOR_URL/oauth/token` | +| `https://api.atlassian.com/oauth/token/accessible-resources` | `$JIRA_EMULATOR_URL/oauth/token/accessible-resources` | +| `https://api.atlassian.com/me` | `$JIRA_EMULATOR_URL/me` | + +## Auth + +Use HTTP Basic auth with a seeded user's email and API token, exactly like Jira Cloud API tokens: + +```bash +curl -u admin@jira.local:jira_test_token "$JIRA_EMULATOR_URL/rest/api/3/myself" +``` + +Default credentials: `admin@jira.local` / `jira_test_token` (admin) and `dev@jira.local` / `jira_dev_token`. API tokens also work as `Authorization: Bearer `. Wrong credentials return `401` with `{"errorMessages":["Client must be authenticated to access this resource."],"errors":{}}`. Creating, editing, or deleting projects and webhooks requires an admin; components and versions also accept the project lead. + +OAuth scope checks are relaxed by default. Set `jira.strict_scopes: true` to require granular scopes such as `read:jira-work` and `write:jira-work` for OAuth access tokens. + +## Default Seed + +- Project `EMU` (id `10000`) with workflow `To Do`, `In Progress`, `Done` and issue types `Epic`, `Story`, `Task`, `Bug`, `Subtask` +- Scrum board `1` with active sprint `1` (`EMU Sprint 1`) +- Issue `EMU-1` assigned to `dev@jira.local`, with one comment +- Custom fields `customfield_10015` (Start date), `customfield_10016` (Story point estimate), `customfield_10020` (Sprint) +- OAuth app `jira_example_client_id` / `example_client_secret` +- Cloud ID `11111111-2222-4333-8444-555555555555` + +## Seed Config + +```yaml +jira: + site: + name: acme + users: + - email: admin@example.com + display_name: Admin User + admin: true + api_token: jira_test_token + - email: dev@example.com + display_name: Developer + api_token: jira_dev_token + statuses: + - name: In Review + category: indeterminate + custom_fields: + - name: Team + type: option + options: [Red, Blue] + projects: + - key: ENG + name: Engineering + lead: admin@example.com + statuses: [To Do, In Progress, In Review, Done] + components: [API, Web] + versions: ["1.0.0"] + sprints: + - project: ENG + name: ENG Sprint 1 + state: active + issues: + - project: ENG + summary: Fix local checkout test + type: Bug + description: Reproduce and fix the checkout failure. + status: To Do + priority: High + assignee: dev@example.com + labels: [checkout] + sprint: ENG Sprint 1 + comments: + - body: Reproduced locally. + author: admin@example.com + links: + - type: Blocks + inward: Fix local checkout test + outward: ENG-2 + webhooks: + - name: Local issue events + url: http://localhost:3000/api/webhooks/jira + events: [jira:issue_created, jira:issue_updated, comment_created] + jql: project = ENG + secret: webhook_secret + oauth_apps: + - client_id: jira_example_client_id + client_secret: example_client_secret + name: My Jira App + redirect_uris: + - http://localhost:3000/api/auth/callback/atlassian + scopes: [read:jira-work, write:jira-work, read:jira-user, manage:jira-webhook, offline_access] + strict_scopes: false +``` + +Issue `parent` and link `inward`/`outward` accept an issue key or a seeded summary. As in Jira's API, the outward issue is the source of a link: `{ type: Blocks, inward: A, outward: B }` means B blocks A. Statuses listed on a project that do not exist yet are created, with the category inferred from the name. + +## REST Surface + +Issues (`/rest/api/3` and `/rest/api/2`): + +- `POST /issue`, `POST /issue/bulk`, `GET|PUT|DELETE /issue/{idOrKey}` +- `PUT /issue/{idOrKey}/assignee`, `GET|POST /issue/{idOrKey}/transitions`, `GET /issue/{idOrKey}/changelog` +- `GET /issue/createmeta/{project}/issuetypes[/{issueTypeId}]`, `GET /issue/{idOrKey}/editmeta` +- `GET|POST /issue/{idOrKey}/comment`, `GET|PUT|DELETE /issue/{idOrKey}/comment/{id}`, `POST /comment/list` +- `GET|POST|DELETE /issue/{idOrKey}/watchers` +- `GET|POST /issue/{idOrKey}/worklog`, `GET|PUT|DELETE /issue/{idOrKey}/worklog/{id}` +- `POST /issueLink`, `GET|DELETE /issueLink/{id}`, `GET /issueLinkType` + +Search: `GET|POST /search/jql` (enhanced, `nextPageToken`, rejects unbounded queries), `POST /search/approximate-count`, legacy `GET|POST /search`, `GET /issue/picker`. + +Projects and configuration: `/project`, `/project/search`, `/project/{key}`, `/project/{key}/statuses|components|versions`, `/component`, `/version`, `/issuetype`, `/status`, `/statuscategory`, `/priority`, `/resolution`, `/field`, `/label`, `/mypermissions`, `/serverInfo`. + +Users: `/myself`, `/user`, `/user/search`, `/users/search`, `/user/assignable/search`, `/user/bulk`. + +Agile (`/rest/agile/1.0`, also `/rest/software/1.0`): boards, board issues, backlog, sprints, epics, sprint lifecycle (`future` to `active` to `closed`), moving issues to sprints and the backlog. + +## Fidelity Notes + +- REST API v3 bodies (descriptions, comments, worklog comments) must be Atlassian Document Format. Plain strings return Jira's `400` error. REST API v2 accepts and returns plain strings. +- `GET /issue` supports `fields` (`*all`, `summary,status`, `-comment`) and `expand` (`changelog`, `renderedFields`, `names`, `schema`, `transitions`). +- `/search/jql` returns only issue IDs and keys unless `fields` are requested. +- Workflows allow any status to move to any other status, like team-managed projects. Transition IDs are `11`, `21`, `31`, and so on by workflow position. Moving to a Done category sets the resolution; moving out clears it. +- Setting `status` through edit fails, as in Jira. Use transitions. +- Closing a sprint keeps completed issues in it and returns unfinished issues to the backlog. +- In `POST /issueLink`, `outwardIssue` is the source ("from") issue and receives the optional comment. In the issue view, the source lists the other issue as `outwardIssue` and the target lists it as `inwardIssue`. +- Errors use `{ "errorMessages": [...], "errors": { "field": "message" } }`. + +## JQL + +Supports `AND`, `OR`, `NOT`, parentheses, `=`, `!=`, `~`, `!~`, `>`, `>=`, `<`, `<=`, `IN`, `NOT IN`, `IS [NOT] EMPTY`, and `ORDER BY`. Fields include `project`, `key`, `summary`, `description`, `comment`, `text`, `status`, `statusCategory`, `assignee`, `reporter`, `creator`, `watcher`, `priority`, `issuetype`, `labels`, `resolution`, `parent`, `created`, `updated`, `duedate`, `resolutiondate`, `sprint`, `component`, `fixVersion`, and custom fields by `cf[id]` or name. Functions: `currentUser()`, `now()`, `startOfDay()`, `endOfDay()`, `startOfWeek()`, `startOfMonth()`, `startOfYear()` (with offsets like `startOfDay(-1d)`), `openSprints()`, `closedSprints()`, `futureSprints()`, `standardIssueTypes()`, `subTaskIssueTypes()`, `releasedVersions()`, `unreleasedVersions()`, and `linkedIssues(key[, "link text"])`. Relative dates such as `-7d` and `-2w` work. + +## Webhooks + +- Admin webhooks: `GET|POST /rest/webhooks/1.0/webhook`, `GET|PUT|DELETE /rest/webhooks/1.0/webhook/{id}` with a JQL filter in `filters["issue-related-events-section"]`, `excludeBody`, and `secret`. Secrets sign deliveries with `X-Hub-Signature: sha256=`. +- Dynamic webhooks for OAuth apps: `GET|POST|DELETE /rest/api/3/webhook`, `PUT /rest/api/3/webhook/refresh`. Deliveries include `matchedWebhookIds`. +- Events: `jira:issue_created`, `jira:issue_updated` (with `changelog` and `issue_event_type_name` such as `issue_generic`, `issue_assigned`, `issue_commented`), sent once per edit even when the edit also adds a comment, `jira:issue_deleted`, `comment_created`, `comment_updated`, `comment_deleted`. + +## OAuth 2.0 (3LO) + +- `GET /authorize?audience=api.atlassian.com&client_id=...&scope=...&redirect_uri=...&state=...&response_type=code` shows a user picker +- `POST /oauth/token` supports `authorization_code` and rotating `refresh_token` grants (refresh tokens need `offline_access`), with JSON or form bodies +- `GET /oauth/token/accessible-resources` returns the site cloud ID; call the API at `/ex/jira/{cloudId}/rest/api/3/...` +- `GET /me` returns the Atlassian profile + +## jira.js + +```typescript +import { createCloudClient } from "jira.js"; + +const client = createCloudClient({ + host: process.env.JIRA_EMULATOR_URL!, + auth: { type: "basic", email: "admin@jira.local", apiToken: "jira_test_token" }, +}); + +const issue = await client.issues.createIssue({ + fields: { + project: { key: "EMU" }, + issuetype: { name: "Task" }, + summary: "Created locally", + }, +}); +``` + +jira.js always sends OAuth 2.0 requests (clients configured with a `cloudId`) to `https://api.atlassian.com/ex/jira/{cloudId}`, so point those at the emulator with a proxy or a `fetch` wrapper, or use Basic auth with `host` as above. Any HTTP client can call `/ex/jira/{cloudId}/rest/api/3/...` on the emulator directly. + +## Inspector + +Open `GET /` for issues, projects, boards and sprints, users, webhooks and deliveries, and auth (API tokens, OAuth apps, cloud IDs). `GET /browse/{issueKey}` shows an issue page. + +## Current Limits + +Attachments, filters, dashboards, workflow and screen scheme administration, permission schemes, groups, Jira Service Management, Confluence, rate limiting, and exact search relevance are not implemented.