@shipwrights/source-jira 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shipwrights contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # @shipwrights/source-jira
2
+
3
+ Jira backlog source adapter for [`@shipwrights/core`](https://github.com/shipwrights/core). Pulls issues via JQL, materialises them as epic files, writes status transitions and PR links back to Jira.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -D @shipwrights/core @shipwrights/source-jira
9
+ # or
10
+ pnpm add -D @shipwrights/core @shipwrights/source-jira
11
+ ```
12
+
13
+ ## Configure
14
+
15
+ Generate a Jira API token at https://id.atlassian.com/manage-profile/security/api-tokens.
16
+
17
+ Set env vars in your shell or `.env`:
18
+
19
+ ```bash
20
+ export JIRA_EMAIL=you@example.com
21
+ export JIRA_API_TOKEN=<your-token>
22
+ ```
23
+
24
+ Reference them from `.shipwright.yml`:
25
+
26
+ ```yaml
27
+ backlog:
28
+ source:
29
+ kind: jira
30
+ config:
31
+ host: myorg.atlassian.net
32
+ email_env: JIRA_EMAIL
33
+ token_env: JIRA_API_TOKEN
34
+ jql: 'project = SHOP AND status in ("Ready for Dev", "Refined") ORDER BY priority ASC'
35
+ state_dir: docs/backlog/epics
36
+ ```
37
+
38
+ ## What this adapter implements
39
+
40
+ The `BacklogSource` contract from `@shipwrights/core`:
41
+
42
+ | Method | Behaviour |
43
+ |---|---|
44
+ | `healthcheck()` | Calls `GET /myself` to validate auth |
45
+ | `listAvailable()` | Runs the configured JQL (`POST /rest/api/3/search/jql`) with cursor pagination |
46
+ | `pickNext()` | `listAvailable()` sorted by priority + key |
47
+ | `materialize(item, dir)` | Renders the Jira issue's description (ADF) to markdown + writes an epic file with frontmatter |
48
+ | `markStatus(id, status)` | Transitions the Jira issue (3 statuses) or posts a comment (middle states) |
49
+ | `attachPR(id, prUrl)` | Posts a comment with the PR url |
50
+
51
+ ## Status mapping
52
+
53
+ Most Shipwright lifecycle states are internal artefacts that don't have a useful Jira counterpart. The adapter only transitions the Jira issue for three states:
54
+
55
+ | Shipwright status | Jira action (default) |
56
+ |---|---|
57
+ | `refined` | Transition to **Ready for Dev** |
58
+ | `ready-for-human-review` | Transition to **In Review** |
59
+ | `shipped` | Transition to **Done** |
60
+ | anything else (`sliced`, `built`, `integrated`, `tested`, `reviewed`) | Post a comment, no transition |
61
+
62
+ Override the mapping in `.shipwright.yml`:
63
+
64
+ ```yaml
65
+ config:
66
+ status_mapping:
67
+ refined: "In Progress"
68
+ shipped: "Closed"
69
+ ```
70
+
71
+ ## Field mapping
72
+
73
+ Standard fields (`summary`, `priority`, `status`, `labels`) are mapped by name. Story points and parent epic are custom fields — convention-detected by default, overridable:
74
+
75
+ ```yaml
76
+ config:
77
+ field_mapping:
78
+ size: customfield_10016 # Story Points
79
+ parents: customfield_10014 # Epic Link
80
+ ```
81
+
82
+ ## Status
83
+
84
+ v0.1.0 — Phase 1. Implements `healthcheck` + `listAvailable`. Materialise, transitions, and PR-link writes land in later phases.
85
+
86
+ ## License
87
+
88
+ MIT
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@shipwrights/source-jira",
3
+ "version": "0.1.0",
4
+ "description": "Jira backlog source adapter for @shipwrights/core. Pulls issues via JQL, materialises them as epic files, writes status transitions and PR links back to Jira.",
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test \"tests/**/*.test.mjs\"",
17
+ "lint": "echo 'no lint configured yet'",
18
+ "typecheck": "echo 'no typecheck configured yet'"
19
+ },
20
+ "engines": {
21
+ "node": ">=20.0.0"
22
+ },
23
+ "keywords": [
24
+ "shipwrights",
25
+ "shipwrights-source",
26
+ "jira",
27
+ "backlog",
28
+ "orchestration",
29
+ "claude-code"
30
+ ],
31
+ "license": "MIT",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/shipwrights/source-jira.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/shipwrights/source-jira/issues"
41
+ },
42
+ "homepage": "https://github.com/shipwrights/source-jira#readme",
43
+ "peerDependencies": {
44
+ "@shipwrights/core": ">=0.1.3"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@shipwrights/core": {
48
+ "optional": false
49
+ }
50
+ },
51
+ "devDependencies": {}
52
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,136 @@
1
+ // Thin Jira Cloud REST API v3 client.
2
+ //
3
+ // Uses native fetch + Basic auth (email + API token). No external HTTP dep.
4
+ // Exposes the few endpoints Phase 1 needs:
5
+ //
6
+ // client.myself() -> GET /rest/api/3/myself
7
+ // client.searchJql({ jql, fields }) -> POST /rest/api/3/search/jql (paginates)
8
+ //
9
+ // Later phases extend this with:
10
+ // client.transitions(issueKey), client.transition(issueKey, transitionId)
11
+ // client.comment(issueKey, adf)
12
+ // client.issue(issueKey)
13
+
14
+ const API_BASE = "/rest/api/3";
15
+
16
+ class JiraClientError extends Error {
17
+ constructor(message, status, body) {
18
+ super(message);
19
+ this.name = "JiraClientError";
20
+ this.status = status;
21
+ this.body = body;
22
+ }
23
+ }
24
+
25
+ export function createClient({ host, email, token, fetch: fetchImpl = fetch } = {}) {
26
+ if (!host) throw new Error("Jira client: host is required (e.g. 'myorg.atlassian.net')");
27
+ if (!email) throw new Error("Jira client: email is required");
28
+ if (!token) throw new Error("Jira client: token is required (API token from id.atlassian.com)");
29
+
30
+ const baseUrl = `https://${host}${API_BASE}`;
31
+ const authHeader = `Basic ${Buffer.from(`${email}:${token}`).toString("base64")}`;
32
+
33
+ async function request(method, path, { body, query } = {}) {
34
+ const url = new URL(`${baseUrl}${path}`);
35
+ if (query) {
36
+ for (const [k, v] of Object.entries(query)) {
37
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
38
+ }
39
+ }
40
+ const response = await fetchImpl(url.toString(), {
41
+ method,
42
+ headers: {
43
+ Accept: "application/json",
44
+ Authorization: authHeader,
45
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
46
+ },
47
+ body: body !== undefined ? JSON.stringify(body) : undefined,
48
+ });
49
+
50
+ if (!response.ok) {
51
+ let errorBody = null;
52
+ try {
53
+ errorBody = await response.json();
54
+ } catch {
55
+ errorBody = await response.text().catch(() => null);
56
+ }
57
+ const message = errorBodySummary(errorBody) ?? response.statusText;
58
+ throw new JiraClientError(
59
+ `Jira ${method} ${path} failed: ${response.status} ${message}`,
60
+ response.status,
61
+ errorBody,
62
+ );
63
+ }
64
+
65
+ if (response.status === 204) return null;
66
+ return response.json();
67
+ }
68
+
69
+ return {
70
+ /** Confirm credentials are valid. Returns the authenticated user. */
71
+ myself() {
72
+ return request("GET", "/myself");
73
+ },
74
+
75
+ /**
76
+ * Paginate through JQL search results. Returns an async iterator yielding
77
+ * issues. Uses POST /search/jql with nextPageToken cursor pagination.
78
+ *
79
+ * @param {{ jql: string, fields?: string[], maxResults?: number, fieldsByKeys?: boolean }} opts
80
+ */
81
+ async *searchJql({ jql, fields, maxResults = 50, fieldsByKeys = false }) {
82
+ if (!jql) throw new Error("searchJql requires a `jql` string");
83
+ let nextPageToken;
84
+ while (true) {
85
+ const payload = {
86
+ jql,
87
+ maxResults,
88
+ fieldsByKeys,
89
+ ...(fields ? { fields } : {}),
90
+ ...(nextPageToken ? { nextPageToken } : {}),
91
+ };
92
+ const page = await request("POST", "/search/jql", { body: payload });
93
+ for (const issue of page.issues ?? []) {
94
+ yield issue;
95
+ }
96
+ if (page.isLast || !page.nextPageToken) break;
97
+ nextPageToken = page.nextPageToken;
98
+ }
99
+ },
100
+
101
+ /**
102
+ * Convenience wrapper: collect all paginated results into an array.
103
+ */
104
+ async searchJqlAll(opts) {
105
+ const out = [];
106
+ for await (const issue of this.searchJql(opts)) out.push(issue);
107
+ return out;
108
+ },
109
+
110
+ /**
111
+ * Low-level escape hatch. Useful for tests and for endpoints not yet
112
+ * added to this client.
113
+ */
114
+ request,
115
+ };
116
+ }
117
+
118
+ function errorBodySummary(body) {
119
+ if (!body) return null;
120
+ if (typeof body === "string") return body.slice(0, 200);
121
+ // Jira error shapes:
122
+ // { errorMessages: ["..."], errors: { field: "..." } }
123
+ // { message: "...", warningMessages: [...] }
124
+ if (Array.isArray(body.errorMessages) && body.errorMessages.length > 0) {
125
+ return body.errorMessages.join("; ");
126
+ }
127
+ if (body.errors && typeof body.errors === "object") {
128
+ return Object.entries(body.errors)
129
+ .map(([field, msg]) => `${field}: ${msg}`)
130
+ .join("; ");
131
+ }
132
+ if (body.message) return body.message;
133
+ return JSON.stringify(body).slice(0, 200);
134
+ }
135
+
136
+ export { JiraClientError };
package/src/index.mjs ADDED
@@ -0,0 +1,183 @@
1
+ // @shipwrights/source-jira — entry point.
2
+ //
3
+ // Implements the BacklogSource interface from @shipwrights/core:
4
+ //
5
+ // { healthcheck, listAvailable, pickNext, materialize, markStatus, attachPR }
6
+ //
7
+ // Phase 1 ships healthcheck + listAvailable + pickNext. The remaining three
8
+ // land in Phases 2–4.
9
+
10
+ import { createClient } from "./client.mjs";
11
+ import { validateJql, fieldsForSearch } from "./jql.mjs";
12
+
13
+ const PRIORITY_ORDER = { Highest: 0, High: 1, Medium: 2, Low: 3, Lowest: 4 };
14
+
15
+ /**
16
+ * Resolve a config-declared env var to its value. Throws if missing.
17
+ */
18
+ function readEnv(varName, role) {
19
+ const value = process.env[varName];
20
+ if (!value) {
21
+ throw new Error(
22
+ `@shipwrights/source-jira: env var ${varName} is required for ${role} but is not set`,
23
+ );
24
+ }
25
+ return value;
26
+ }
27
+
28
+ /**
29
+ * Map a Jira issue to a Shipwright BacklogItem. Phase 1 uses standard
30
+ * fields only — field_mapping for size / parents arrives in Phase 5.
31
+ */
32
+ function toBacklogItem(issue, { idPrefix, fieldMapping }) {
33
+ const fields = issue.fields ?? {};
34
+ const id = issue.key; // SHOP-123
35
+ const sizeFieldKey = fieldMapping?.size;
36
+ const parentsFieldKey = fieldMapping?.parents;
37
+
38
+ const size = sizeFieldKey && fields[sizeFieldKey]
39
+ ? bucketSize(Number(fields[sizeFieldKey]))
40
+ : undefined;
41
+
42
+ const parents = parentsFieldKey && fields[parentsFieldKey]
43
+ ? [fields[parentsFieldKey]].flat().filter(Boolean)
44
+ : [];
45
+
46
+ return {
47
+ id,
48
+ title: fields.summary ?? `(no summary) ${id}`,
49
+ description: undefined, // ADF rendering lands in Phase 2 (materialize)
50
+ status: fields.status?.name ?? "unknown",
51
+ priority: fields.priority?.name,
52
+ size,
53
+ domain: undefined,
54
+ parents,
55
+ metadata: {
56
+ issueKey: issue.key,
57
+ issueId: issue.id,
58
+ jiraUrl: issue.self,
59
+ assignee: fields.assignee?.displayName,
60
+ reporter: fields.reporter?.displayName,
61
+ created: fields.created,
62
+ updated: fields.updated,
63
+ labels: fields.labels ?? [],
64
+ components: (fields.components ?? []).map((c) => c.name),
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Map a numeric story-points value to Shipwright's coarse size bucket.
71
+ * Defaults follow common Fibonacci-style estimation.
72
+ */
73
+ function bucketSize(points) {
74
+ if (!Number.isFinite(points)) return undefined;
75
+ if (points <= 2) return "small";
76
+ if (points <= 8) return "medium";
77
+ return "large";
78
+ }
79
+
80
+ function comparePriority(a, b) {
81
+ const ap = PRIORITY_ORDER[a.priority] ?? 99;
82
+ const bp = PRIORITY_ORDER[b.priority] ?? 99;
83
+ if (ap !== bp) return ap - bp;
84
+ return (a.id ?? "").localeCompare(b.id ?? "");
85
+ }
86
+
87
+ /**
88
+ * Factory called by @shipwrights/core's source-loader.
89
+ */
90
+ export function createSource(rawConfig = {}) {
91
+ const {
92
+ host,
93
+ email_env = "JIRA_EMAIL",
94
+ token_env = "JIRA_API_TOKEN",
95
+ jql,
96
+ field_mapping = {},
97
+ id_prefix,
98
+ _client, // injected in tests
99
+ } = rawConfig;
100
+
101
+ if (!host) {
102
+ throw new Error("@shipwrights/source-jira: `host` is required (e.g. 'myorg.atlassian.net')");
103
+ }
104
+ if (!jql) {
105
+ throw new Error("@shipwrights/source-jira: `jql` is required");
106
+ }
107
+
108
+ // Validate early — surface bad JQL before any API call.
109
+ validateJql(jql);
110
+
111
+ const client = _client ?? createClient({
112
+ host,
113
+ email: readEnv(email_env, "Jira email"),
114
+ token: readEnv(token_env, "Jira API token"),
115
+ });
116
+
117
+ const fields = fieldsForSearch({ fieldMapping: field_mapping });
118
+
119
+ return {
120
+ /**
121
+ * Phase 1: confirm credentials by hitting /myself. Throws on failure.
122
+ */
123
+ async healthcheck() {
124
+ await client.myself();
125
+ },
126
+
127
+ /**
128
+ * Phase 1: paginate the configured JQL, map each issue to a BacklogItem.
129
+ * Optional filter narrows by status (Jira status name, not Shipwright
130
+ * status) — pass `statuses` to override the JQL's own status clause.
131
+ */
132
+ async listAvailable(filter = {}) {
133
+ let effectiveJql = jql;
134
+ if (Array.isArray(filter.statuses) && filter.statuses.length > 0) {
135
+ const escaped = filter.statuses.map((s) => `"${s.replace(/"/g, '\\"')}"`).join(", ");
136
+ effectiveJql = `(${jql}) AND status in (${escaped})`;
137
+ }
138
+ const issues = await client.searchJqlAll({
139
+ jql: effectiveJql,
140
+ fields,
141
+ maxResults: 50,
142
+ });
143
+ return issues.map((issue) =>
144
+ toBacklogItem(issue, { idPrefix: id_prefix, fieldMapping: field_mapping }),
145
+ );
146
+ },
147
+
148
+ /**
149
+ * Phase 1: pick the highest-priority item, then lowest key alphabetically
150
+ * for stable ordering. Doesn't yet honour `parents-shipped` blocking —
151
+ * that arrives once `materialize` can resolve Epic Link parents (Phase 2/5).
152
+ */
153
+ async pickNext(criteria = {}) {
154
+ const items = await this.listAvailable(criteria);
155
+ if (items.length === 0) return null;
156
+ items.sort(comparePriority);
157
+ return items[0];
158
+ },
159
+
160
+ /**
161
+ * Phases 2–4: not yet implemented. Each throws a clear error rather than
162
+ * silently no-oping, so consumers know they're on a Phase 1 release.
163
+ */
164
+ async materialize(_item, _targetDir) {
165
+ throw new Error(
166
+ "@shipwrights/source-jira: materialize() lands in Phase 2 (next release). Use listAvailable() / pickNext() for now and materialise epic files by hand.",
167
+ );
168
+ },
169
+ async markStatus(_itemId, _status) {
170
+ throw new Error(
171
+ "@shipwrights/source-jira: markStatus() lands in Phase 3 (next release).",
172
+ );
173
+ },
174
+ async attachPR(_itemId, _prUrl) {
175
+ throw new Error(
176
+ "@shipwrights/source-jira: attachPR() lands in Phase 4 (next release).",
177
+ );
178
+ },
179
+ };
180
+ }
181
+
182
+ // @shipwrights/core's source-loader accepts default export as the factory too.
183
+ export default createSource;
package/src/jql.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // JQL helpers. Two responsibilities for now:
2
+ //
3
+ // 1. Light validation — surface obvious mistakes (empty string, mismatched
4
+ // quotes) before sending to Jira. Jira's 400 messages are useful but a
5
+ // cheap pre-check makes /shipwright:doctor output more actionable.
6
+ //
7
+ // 2. Field-list resolution — given the consumer's `field_mapping` config,
8
+ // produce the `fields` array we ask Jira to return so we don't pull the
9
+ // full issue payload (each issue is ~10kb of irrelevant data otherwise).
10
+
11
+ const ALWAYS_REQUESTED_FIELDS = [
12
+ "summary",
13
+ "status",
14
+ "priority",
15
+ "issuetype",
16
+ "labels",
17
+ "components",
18
+ "assignee",
19
+ "reporter",
20
+ "created",
21
+ "updated",
22
+ ];
23
+
24
+ /**
25
+ * Cheap structural validation. Throws on obvious errors. Returns the JQL
26
+ * unchanged on success.
27
+ */
28
+ export function validateJql(jql) {
29
+ if (typeof jql !== "string") {
30
+ throw new Error("JQL must be a string");
31
+ }
32
+ const trimmed = jql.trim();
33
+ if (trimmed.length === 0) {
34
+ throw new Error("JQL is empty");
35
+ }
36
+ if (countUnescaped(trimmed, '"') % 2 !== 0) {
37
+ throw new Error(`JQL has unbalanced double quotes: ${trimmed}`);
38
+ }
39
+ if (countUnescaped(trimmed, "'") % 2 !== 0) {
40
+ throw new Error(`JQL has unbalanced single quotes: ${trimmed}`);
41
+ }
42
+ const opens = (trimmed.match(/\(/g) ?? []).length;
43
+ const closes = (trimmed.match(/\)/g) ?? []).length;
44
+ if (opens !== closes) {
45
+ throw new Error(`JQL has unbalanced parentheses (${opens} open, ${closes} close): ${trimmed}`);
46
+ }
47
+ return trimmed;
48
+ }
49
+
50
+ function countUnescaped(s, char) {
51
+ let count = 0;
52
+ for (let i = 0; i < s.length; i++) {
53
+ if (s[i] === char && s[i - 1] !== "\\") count++;
54
+ }
55
+ return count;
56
+ }
57
+
58
+ /**
59
+ * Compute the field list we request from Jira's search endpoint. Pulls only
60
+ * what the adapter needs, including any custom-field IDs from the consumer's
61
+ * field_mapping config.
62
+ */
63
+ export function fieldsForSearch({ fieldMapping = {} } = {}) {
64
+ const fields = new Set(ALWAYS_REQUESTED_FIELDS);
65
+ // The mapping values may be `customfield_NNNNN` IDs or standard field
66
+ // names. Either way we ask for them.
67
+ for (const value of Object.values(fieldMapping)) {
68
+ if (typeof value === "string" && value.length > 0) fields.add(value);
69
+ }
70
+ return [...fields];
71
+ }