@stonepandastudio/cairn 0.2.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.
@@ -0,0 +1,291 @@
1
+ 'use strict';
2
+
3
+ // Jira Server (on-prem) REST v2 client.
4
+ //
5
+ // Merged from the four hand-copied `ai/scripts/jira.js` files. The only real
6
+ // differences between those copies were the project key, the issue-type name
7
+ // (`Task` on the PROOF board, `Story` on GLO), and whether close-story existed —
8
+ // all three are configuration, so this is one implementation with three knobs.
9
+ //
10
+ // Auth is Basic (username/password): the deployment at jira.stonepandastudio.com
11
+ // runs Jira Server 8.5.1, which predates personal access tokens.
12
+ //
13
+ // Unlike the scripts it replaces, nothing here calls process.exit — failures
14
+ // throw TrackerError and the CLI layer decides the exit code. That is what makes
15
+ // the client usable from a workflow runner and not only from a shell.
16
+
17
+ class TrackerError extends Error {
18
+ constructor(message, { status = null, body = null } = {}) {
19
+ super(message);
20
+ this.name = 'TrackerError';
21
+ this.status = status;
22
+ this.body = body;
23
+ }
24
+ }
25
+
26
+ // Thrown when a transition to the requested status does not exist. Separate type
27
+ // because re-running a workflow step is expected to hit this and it is a no-op,
28
+ // not a failure — the old scripts exited 1 here and every caller had to special-case it.
29
+ class NoTransitionError extends TrackerError {
30
+ constructor(message, { from = null, available = [] } = {}) {
31
+ super(message);
32
+ this.name = 'NoTransitionError';
33
+ this.from = from;
34
+ this.available = available;
35
+ }
36
+ }
37
+
38
+ const REQUIRED_ENV = ['JIRA_BASE_URL', 'JIRA_USER', 'JIRA_PASSWORD'];
39
+
40
+ function createJiraServer(config = {}, env = process.env) {
41
+ const baseUrl = (config.baseUrl || env.JIRA_BASE_URL || '').replace(/\/+$/, '');
42
+ const user = config.user || env.JIRA_USER;
43
+ const password = config.password || env.JIRA_PASSWORD;
44
+ const projectKey = config.projectKey;
45
+ const issueTypes = { story: 'Story', subtask: 'Sub-task', ...(config.issueTypes || {}) };
46
+
47
+ function assertConfig() {
48
+ const missing = REQUIRED_ENV.filter((k, i) => ![baseUrl, user, password][i]);
49
+ if (missing.length) {
50
+ throw new TrackerError(
51
+ `Missing Jira credentials: ${missing.join(', ')}. Set them in the repo's .env or the environment.`,
52
+ );
53
+ }
54
+ if (!projectKey) {
55
+ throw new TrackerError('tracker.projectKey is not set in cairn.config.json');
56
+ }
57
+ }
58
+
59
+ async function api(pathname, options = {}) {
60
+ assertConfig();
61
+ const auth = Buffer.from(`${user}:${password}`).toString('base64');
62
+ let res;
63
+ try {
64
+ res = await fetch(`${baseUrl}${pathname}`, {
65
+ ...options,
66
+ headers: {
67
+ Authorization: `Basic ${auth}`,
68
+ 'Content-Type': 'application/json',
69
+ Accept: 'application/json',
70
+ ...(options.headers || {}),
71
+ },
72
+ });
73
+ } catch (err) {
74
+ throw new TrackerError(`Cannot reach Jira at ${baseUrl}: ${err.message}`);
75
+ }
76
+ const text = await res.text();
77
+ let body = null;
78
+ if (text) {
79
+ try {
80
+ body = JSON.parse(text);
81
+ } catch {
82
+ body = text;
83
+ }
84
+ }
85
+ if (!res.ok) {
86
+ const detail =
87
+ body && body.errorMessages && body.errorMessages.length
88
+ ? body.errorMessages.join('; ')
89
+ : typeof body === 'string'
90
+ ? body.slice(0, 400)
91
+ : JSON.stringify(body);
92
+ throw new TrackerError(`Jira ${res.status} on ${pathname}: ${detail}`, {
93
+ status: res.status,
94
+ body,
95
+ });
96
+ }
97
+ return body;
98
+ }
99
+
100
+ async function listProjects() {
101
+ const projects = await api('/rest/api/2/project');
102
+ return projects.map((p) => ({ key: p.key, name: p.name }));
103
+ }
104
+
105
+ async function listStatuses(key = projectKey) {
106
+ const data = await api(`/rest/api/2/project/${key}/statuses`);
107
+ return data.map((it) => ({ type: it.name, statuses: it.statuses.map((s) => s.name) }));
108
+ }
109
+
110
+ async function createIssue({ summary, description = '', type } = {}) {
111
+ if (!summary) throw new TrackerError('createIssue requires a summary');
112
+ const body = await api('/rest/api/2/issue', {
113
+ method: 'POST',
114
+ body: JSON.stringify({
115
+ fields: {
116
+ project: { key: projectKey },
117
+ summary,
118
+ description,
119
+ issuetype: { name: type || issueTypes.story },
120
+ },
121
+ }),
122
+ });
123
+ return body.key;
124
+ }
125
+
126
+ async function createSubIssue({ parentKey, summary, description = '', type } = {}) {
127
+ if (!parentKey) throw new TrackerError('createSubIssue requires a parentKey');
128
+ if (!summary) throw new TrackerError('createSubIssue requires a summary');
129
+ const body = await api('/rest/api/2/issue', {
130
+ method: 'POST',
131
+ body: JSON.stringify({
132
+ fields: {
133
+ project: { key: projectKey },
134
+ parent: { key: parentKey },
135
+ summary,
136
+ description,
137
+ issuetype: { name: type || issueTypes.subtask },
138
+ },
139
+ }),
140
+ });
141
+ return body.key;
142
+ }
143
+
144
+ async function get(key) {
145
+ const data = await api(`/rest/api/2/issue/${key}?fields=summary,description,status,assignee,parent,subtasks`);
146
+ return {
147
+ key: data.key,
148
+ summary: data.fields.summary,
149
+ description: data.fields.description,
150
+ status: data.fields.status ? data.fields.status.name : null,
151
+ assignee: data.fields.assignee ? data.fields.assignee.name : null,
152
+ parent: data.fields.parent ? data.fields.parent.key : null,
153
+ subtasks: (data.fields.subtasks || []).map((s) => s.key),
154
+ };
155
+ }
156
+
157
+ async function setFields(key, fields = {}) {
158
+ const payload = {};
159
+ if (fields.summary !== undefined) payload.summary = fields.summary;
160
+ if (fields.description !== undefined) payload.description = fields.description;
161
+ if (Object.keys(payload).length === 0) {
162
+ throw new TrackerError('setFields requires at least one of: summary, description');
163
+ }
164
+ await api(`/rest/api/2/issue/${key}`, { method: 'PUT', body: JSON.stringify({ fields: payload }) });
165
+ return key;
166
+ }
167
+
168
+ // Append rather than overwrite: the workflow adds one short summary per step to
169
+ // the parent issue, and earlier steps' text has to survive.
170
+ async function appendDescription(key, text) {
171
+ if (!text) throw new TrackerError('appendDescription requires text');
172
+ const current = await api(`/rest/api/2/issue/${key}?fields=description`);
173
+ const existing = current.fields.description || '';
174
+ const updated = existing ? `${existing}\n\n${text}` : text;
175
+ await api(`/rest/api/2/issue/${key}`, {
176
+ method: 'PUT',
177
+ body: JSON.stringify({ fields: { description: updated } }),
178
+ });
179
+ return updated;
180
+ }
181
+
182
+ // Returns { key, from, to, changed }. Already being in the target status is a
183
+ // success with changed:false, not an error — re-running a step must be safe.
184
+ async function setStatus(key, statusName) {
185
+ if (!statusName) throw new TrackerError('setStatus requires a status name');
186
+ const wanted = statusName.toLowerCase();
187
+ const data = await api(`/rest/api/2/issue/${key}/transitions`);
188
+ const match = data.transitions.find(
189
+ (t) => t.name.toLowerCase() === wanted || (t.to && t.to.name.toLowerCase() === wanted),
190
+ );
191
+ if (!match) {
192
+ const current = await api(`/rest/api/2/issue/${key}?fields=status`);
193
+ const from = current.fields.status ? current.fields.status.name : null;
194
+ if (from && from.toLowerCase() === wanted) {
195
+ return { key, from, to: from, changed: false };
196
+ }
197
+ throw new NoTransitionError(
198
+ `No transition to "${statusName}" from "${from}" for ${key}`,
199
+ { from, available: data.transitions.map((t) => `${t.name} -> ${t.to ? t.to.name : '?'}`) },
200
+ );
201
+ }
202
+ await api(`/rest/api/2/issue/${key}/transitions`, {
203
+ method: 'POST',
204
+ body: JSON.stringify({ transition: { id: match.id } }),
205
+ });
206
+ return { key, to: match.to ? match.to.name : statusName, changed: true };
207
+ }
208
+
209
+ // Move an issue and all of its subtasks. Subtasks go first: most workflows
210
+ // refuse to close a parent while children are open. Subtask failures are
211
+ // collected rather than thrown so one stuck child doesn't strand the rest.
212
+ async function closeIssue(key, statusName = 'Done') {
213
+ const issue = await get(key);
214
+ const results = [];
215
+ for (const subKey of issue.subtasks) {
216
+ try {
217
+ results.push({ ...(await setStatus(subKey, statusName)), ok: true });
218
+ } catch (err) {
219
+ if (!(err instanceof NoTransitionError)) throw err;
220
+ results.push({ key: subKey, ok: false, reason: err.message });
221
+ }
222
+ }
223
+ try {
224
+ results.push({ ...(await setStatus(key, statusName)), ok: true });
225
+ } catch (err) {
226
+ if (!(err instanceof NoTransitionError)) throw err;
227
+ results.push({ key, ok: false, reason: err.message });
228
+ }
229
+ return { key, status: statusName, results, failed: results.filter((r) => !r.ok).length };
230
+ }
231
+
232
+ async function setAssignee(key, username) {
233
+ if (!username) throw new TrackerError('setAssignee requires a username');
234
+ await api(`/rest/api/2/issue/${key}`, {
235
+ method: 'PUT',
236
+ body: JSON.stringify({ fields: { assignee: { name: username } } }),
237
+ });
238
+ return { key, assignee: username };
239
+ }
240
+
241
+ // Markdown -> Jira wiki markup. This used to be a per-repo prose document
242
+ // (`ai/JIRA_MARKUP.md`, three variants) telling agents to convert by hand.
243
+ // Conversion is deterministic, so it belongs in code where it can't drift.
244
+ function toMarkup(markdown) {
245
+ const lines = String(markdown).replace(/\r\n?/g, '\n').split('\n');
246
+ const out = [];
247
+ let inFence = false;
248
+ for (const line of lines) {
249
+ const fence = line.match(/^```(\w*)\s*$/);
250
+ if (fence) {
251
+ out.push(inFence ? '{code}' : fence[1] ? `{code:${fence[1]}}` : '{code}');
252
+ inFence = !inFence;
253
+ continue;
254
+ }
255
+ if (inFence) {
256
+ out.push(line);
257
+ continue;
258
+ }
259
+ let s = line;
260
+ s = s.replace(/^(#{1,6})\s+(.*)$/, (_, h, rest) => `h${h.length}. ${rest}`);
261
+ s = s.replace(/^(\s*)[-*]\s+/, (_, indent) => '*'.repeat(Math.floor(indent.length / 2) + 1) + ' ');
262
+ s = s.replace(/^(\s*)\d+\.\s+/, (_, indent) => '#'.repeat(Math.floor(indent.length / 2) + 1) + ' ');
263
+ s = s.replace(/\*\*([^*]+)\*\*/g, '*$1*');
264
+ s = s.replace(/(^|[^*\w])_([^_]+)_(?=[^*\w]|$)/g, '$1_$2_');
265
+ s = s.replace(/`([^`]+)`/g, '{{$1}}');
266
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1|$2]');
267
+ s = s.replace(/^---+$/, '----');
268
+ out.push(s);
269
+ }
270
+ return out.join('\n');
271
+ }
272
+
273
+ return {
274
+ name: 'jira-server',
275
+ projectKey,
276
+ issueTypes,
277
+ listProjects,
278
+ listStatuses,
279
+ createIssue,
280
+ createSubIssue,
281
+ get,
282
+ setFields,
283
+ appendDescription,
284
+ setStatus,
285
+ closeIssue,
286
+ setAssignee,
287
+ toMarkup,
288
+ };
289
+ }
290
+
291
+ module.exports = { createJiraServer, TrackerError, NoTransitionError };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const { TrackerError } = require('./jira-server');
4
+
5
+ // The null tracker. Repos with no issue tracker (glossr-cli today) still run the
6
+ // same workflow commands; the sync points simply become no-ops instead of every
7
+ // command stub needing an "if this repo has Jira" branch in prose.
8
+ //
9
+ // Reads return null, writes report that they were skipped. Nothing throws — a
10
+ // no-op tracker that threw would just push the branching back into the callers.
11
+
12
+ function createNone() {
13
+ const skip = (action) => ({ skipped: true, reason: `tracker.provider is "none"`, action });
14
+
15
+ return {
16
+ name: 'none',
17
+ projectKey: null,
18
+ issueTypes: {},
19
+ async listProjects() {
20
+ return [];
21
+ },
22
+ async listStatuses() {
23
+ return [];
24
+ },
25
+ async createIssue() {
26
+ return null;
27
+ },
28
+ async createSubIssue() {
29
+ return null;
30
+ },
31
+ async get() {
32
+ return null;
33
+ },
34
+ async setFields(key) {
35
+ return skip(`setFields ${key}`);
36
+ },
37
+ async appendDescription(key) {
38
+ return skip(`appendDescription ${key}`);
39
+ },
40
+ async setStatus(key, status) {
41
+ return { key, to: status, changed: false, ...skip('setStatus') };
42
+ },
43
+ async closeIssue(key) {
44
+ return { key, results: [], failed: 0, ...skip('closeIssue') };
45
+ },
46
+ async setAssignee(key) {
47
+ return skip(`setAssignee ${key}`);
48
+ },
49
+ toMarkup(markdown) {
50
+ return markdown;
51
+ },
52
+ };
53
+ }
54
+
55
+ module.exports = { createNone, TrackerError };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@stonepandastudio/cairn",
3
+ "version": "0.2.0",
4
+ "description": "Shared AI workflow scaffolding for Stone Panda repos — issue tracker client and drift doctor.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/StonePandaStudio/cairn.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/StonePandaStudio/cairn/issues"
12
+ },
13
+ "homepage": "https://github.com/StonePandaStudio/cairn#readme",
14
+ "bin": {
15
+ "cairn": "bin/cairn.js"
16
+ },
17
+ "main": "lib/index.js",
18
+ "exports": {
19
+ ".": "./lib/index.js",
20
+ "./tracker": "./lib/tracker/index.js",
21
+ "./tracker/cli": "./lib/tracker/cli.js"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "lib/",
26
+ "templates/",
27
+ "schema.json",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "doctor": "node bin/cairn.js doctor",
32
+ "doctor:json": "node bin/cairn.js doctor --json",
33
+ "doctor:strict": "node bin/cairn.js doctor --strict",
34
+ "test": "node test/run.js",
35
+ "prepublishOnly": "npm test"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
package/schema.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://stonepandastudio.com/schema/cairn.config.json",
4
+ "title": "cairn.config.json",
5
+ "description": "Per-repo cairn configuration. Hand-edited. Lives at the repo root, never inside _cairn/.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "$schema": { "type": "string" },
10
+ "cairn": {
11
+ "type": "string",
12
+ "description": "Version of @stonepandastudio/cairn this repo is pinned to."
13
+ },
14
+ "stack": {
15
+ "type": "string",
16
+ "description": "Comparison cohort for the doctor and, from v2, the preset to render from.",
17
+ "examples": ["backend", "frontend", "cli"]
18
+ },
19
+ "tracker": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "properties": {
23
+ "provider": {
24
+ "type": "string",
25
+ "enum": ["jira-server", "none"],
26
+ "default": "none"
27
+ },
28
+ "projectKey": {
29
+ "type": "string",
30
+ "description": "Required unless provider is \"none\".",
31
+ "examples": ["PROOF", "GLO"]
32
+ },
33
+ "issueTypes": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "story": { "type": "string", "default": "Story" },
38
+ "subtask": { "type": "string", "default": "Sub-task" }
39
+ }
40
+ },
41
+ "statuses": {
42
+ "type": "object",
43
+ "description": "Board status names the workflow transitions to. Verify with `cairn tracker list-statuses`.",
44
+ "additionalProperties": { "type": "string" }
45
+ },
46
+ "env": {
47
+ "type": "string",
48
+ "default": ".env",
49
+ "description": "Path, relative to the repo root, of the file holding tracker credentials."
50
+ }
51
+ }
52
+ },
53
+ "agents": {
54
+ "type": "object",
55
+ "description": "Role -> agent mapping. Read by the doctor's normalizer today; rendered into command stubs in v2.",
56
+ "additionalProperties": {
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "properties": {
60
+ "name": { "type": "string" },
61
+ "doc": { "type": "string" }
62
+ }
63
+ }
64
+ },
65
+ "vars": {
66
+ "type": "object",
67
+ "description": "Free-form template variables.",
68
+ "additionalProperties": { "type": "string" }
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Shim. The Jira client itself lives in @stonepandastudio/cairn — this file exists so
5
+ // that the ~40 references to `node ai/scripts/jira.js <command>` scattered across
6
+ // agent docs, command stubs and WORKFLOW.md keep working unchanged.
7
+ //
8
+ // Every command name and every output format is preserved by the CLI it calls.
9
+ // When those documents are regenerated (v2), they will point at `cairn tracker`
10
+ // directly and this shim goes away.
11
+
12
+ require('@stonepandastudio/cairn/tracker/cli')(process.argv.slice(2));