@jam-mcp/server 1.1.0 → 1.3.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/README.md +2 -2
- package/dist/adapters/credentials/windows-user-env.d.ts +3 -1
- package/dist/adapters/credentials/windows-user-env.js +20 -1
- package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.d.ts +39 -0
- package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.js +94 -0
- package/dist/adapters/jira-cloud/jira-create-metadata.adapter.d.ts +27 -0
- package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
- package/dist/adapters/jira-cloud/jira-read.adapter.js +26 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +22 -7
- package/dist/adapters/jira-cloud/jira-write.adapter.js +41 -20
- package/dist/application/apply-create-issue.d.ts +20 -0
- package/dist/application/apply-create-issue.js +187 -0
- package/dist/application/apply-write.js +56 -5
- package/dist/application/plan-create-issue.d.ts +44 -0
- package/dist/application/plan-create-issue.js +188 -0
- package/dist/application/plan-write.d.ts +16 -4
- package/dist/application/plan-write.js +107 -18
- package/dist/application/write-plan-store.d.ts +2 -2
- package/dist/application/write-plan-store.js +13 -1
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/cli/auth.d.ts +6 -0
- package/dist/cli/auth.js +2 -1
- package/dist/deps.d.ts +20 -0
- package/dist/deps.js +12 -0
- package/dist/domain/adf.d.ts +35 -0
- package/dist/domain/adf.js +65 -0
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +17 -0
- package/dist/domain/write.d.ts +165 -15
- package/dist/domain/write.js +34 -1
- package/dist/mcp/create-server.d.ts +4 -0
- package/dist/mcp/create-server.js +5 -0
- package/dist/mcp/tools/jira-write-plan.tool.js +42 -6
- package/dist/policy/assignee-policy.d.ts +60 -0
- package/dist/policy/assignee-policy.js +103 -0
- package/dist/policy/consistency-policy.d.ts +10 -4
- package/dist/policy/create-policy.d.ts +86 -0
- package/dist/policy/create-policy.js +182 -0
- package/dist/policy/write-policy.d.ts +10 -1
- package/dist/policy/write-policy.js +15 -1
- package/dist/ports/jira-assignee-resolution.port.d.ts +51 -0
- package/dist/ports/jira-assignee-resolution.port.js +1 -0
- package/dist/ports/jira-create-metadata.port.d.ts +25 -0
- package/dist/ports/jira-create-metadata.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +34 -0
- package/dist/ports/jira-write.port.d.ts +17 -0
- package/package.json +2 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { JamError } from "../domain/errors.js";
|
|
2
|
-
import { assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
|
|
2
|
+
import { assertExistingIssueOperation, assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
|
|
3
|
+
import { assertAssignable, assertNotAlreadyAssigned, exactMatches, resolveAssignee, } from "../policy/assignee-policy.js";
|
|
4
|
+
import { planCreateIssue } from "./plan-create-issue.js";
|
|
3
5
|
/**
|
|
4
6
|
* Work out whether a requested change is currently possible, and describe it.
|
|
5
7
|
*
|
|
@@ -14,18 +16,27 @@ import { assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL
|
|
|
14
16
|
* it right now, never from what the caller asserted about it.
|
|
15
17
|
*/
|
|
16
18
|
export async function planWrite(deps, request) {
|
|
17
|
-
|
|
19
|
+
// Creation branches before anything else touches `key`, because it has none.
|
|
20
|
+
// Routing on the operation rather than on whether a key happened to be
|
|
21
|
+
// supplied keeps the two request shapes genuinely separate instead of one
|
|
22
|
+
// shape with holes in it.
|
|
23
|
+
if (assertOperationAllowed(request.operation) === "issue.create") {
|
|
24
|
+
return planCreateIssue(deps, { input: request.input });
|
|
25
|
+
}
|
|
26
|
+
const issueKey = requireIssueKey(request);
|
|
18
27
|
const projectKey = assertWriteScope(issueKey, deps.config.project.key);
|
|
19
|
-
const operation =
|
|
28
|
+
const operation = assertExistingIssueOperation(request.operation);
|
|
20
29
|
// Everything that can be refused from the request alone is refused here,
|
|
21
30
|
// before a Jira call is spent on it. An agent asking to write a field JAM
|
|
22
31
|
// does not write should get that answer, not a round trip and then that
|
|
23
32
|
// answer.
|
|
24
33
|
const input = validateInput(operation, request.input);
|
|
25
|
-
const
|
|
26
|
-
const
|
|
34
|
+
const snapshot = await readIssue(deps, issueKey);
|
|
35
|
+
const issue = snapshot.issue;
|
|
36
|
+
const { before, intendedAfter, mutation, transition, baseAssigneeAccountId } = await describe(deps, operation, issueKey, snapshot, input);
|
|
27
37
|
const createdAt = new Date();
|
|
28
38
|
const plan = deps.writePlans.create({
|
|
39
|
+
kind: "existing-issue",
|
|
29
40
|
issueKey,
|
|
30
41
|
projectKey,
|
|
31
42
|
operation,
|
|
@@ -35,6 +46,7 @@ export async function planWrite(deps, request) {
|
|
|
35
46
|
createdAt: createdAt.toISOString(),
|
|
36
47
|
expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
|
|
37
48
|
...(transition ? { transition } : {}),
|
|
49
|
+
...(baseAssigneeAccountId ? { baseAssigneeAccountId } : {}),
|
|
38
50
|
mutation,
|
|
39
51
|
});
|
|
40
52
|
return {
|
|
@@ -51,22 +63,45 @@ export async function planWrite(deps, request) {
|
|
|
51
63
|
},
|
|
52
64
|
};
|
|
53
65
|
}
|
|
54
|
-
/**
|
|
55
|
-
* The issue as Jira has it, read directly by key.
|
|
56
|
-
*
|
|
57
|
-
* A direct read, never a search: ConsistencyPolicy requires it for anything
|
|
58
|
-
* that decides a write, and a JQL result can lag behind the issue it describes.
|
|
59
|
-
*/
|
|
60
66
|
export async function readIssue(deps, issueKey) {
|
|
61
|
-
const {
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
const { issue: found, assigneeAccountId } = await deps.jira.getIssue({
|
|
68
|
+
key: issueKey,
|
|
69
|
+
// `issuetype` and `description` are here for creation's verification step,
|
|
70
|
+
// which has to confirm the issue Jira made is the one that was asked for.
|
|
71
|
+
// A field a plan promises to check has to be a field this read returns -
|
|
72
|
+
// otherwise the check silently passes on `undefined`. They cost nothing on
|
|
73
|
+
// the other operations, which do not compare them.
|
|
74
|
+
fields: [
|
|
75
|
+
"summary",
|
|
76
|
+
"status",
|
|
77
|
+
"issuetype",
|
|
78
|
+
"description",
|
|
79
|
+
"assignee",
|
|
80
|
+
"priority",
|
|
81
|
+
"labels",
|
|
82
|
+
"components",
|
|
83
|
+
"updated",
|
|
84
|
+
],
|
|
64
85
|
});
|
|
65
|
-
|
|
66
|
-
if (!issue) {
|
|
86
|
+
if (!found) {
|
|
67
87
|
throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
|
|
68
88
|
}
|
|
69
|
-
return issue;
|
|
89
|
+
return { issue: found, ...(assigneeAccountId ? { assigneeAccountId } : {}) };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The issue an existing-issue operation names, or a refusal that says why.
|
|
93
|
+
*
|
|
94
|
+
* `key` is optional on the request only because `issue.create` has no issue.
|
|
95
|
+
* Reaching here means the operation does have one, so its absence is the
|
|
96
|
+
* caller using the wrong shape - which is worth saying, rather than reading as
|
|
97
|
+
* an empty key and failing further in.
|
|
98
|
+
*/
|
|
99
|
+
function requireIssueKey(request) {
|
|
100
|
+
const key = request.key?.trim();
|
|
101
|
+
if (!key) {
|
|
102
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", `${request.operation} changes an issue that already exists, so it needs \`key\`.`, { operation: request.operation });
|
|
103
|
+
}
|
|
104
|
+
return key.toUpperCase();
|
|
70
105
|
}
|
|
71
106
|
/**
|
|
72
107
|
* Check the request against the contract, and normalize it.
|
|
@@ -93,9 +128,17 @@ function validateInput(operation, raw) {
|
|
|
93
128
|
}
|
|
94
129
|
return { status: status.trim() };
|
|
95
130
|
}
|
|
131
|
+
case "assignee.update": {
|
|
132
|
+
const assignee = raw.assignee;
|
|
133
|
+
if (typeof assignee !== "string" || assignee.trim().length === 0) {
|
|
134
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "assignee.update needs non-empty `input.assignee` - a display name, or an accountId.", { operation });
|
|
135
|
+
}
|
|
136
|
+
return { assignee: assignee.trim() };
|
|
137
|
+
}
|
|
96
138
|
}
|
|
97
139
|
}
|
|
98
|
-
async function describe(deps, operation, issueKey,
|
|
140
|
+
async function describe(deps, operation, issueKey, snapshot, input) {
|
|
141
|
+
const issue = snapshot.issue;
|
|
99
142
|
switch (operation) {
|
|
100
143
|
case "comment.add": {
|
|
101
144
|
const { text } = input;
|
|
@@ -135,8 +178,54 @@ async function describe(deps, operation, issueKey, issue, input) {
|
|
|
135
178
|
transition,
|
|
136
179
|
};
|
|
137
180
|
}
|
|
181
|
+
case "assignee.update": {
|
|
182
|
+
const { assignee: requested } = input;
|
|
183
|
+
// Ask Jira who this is, and decide from what it says. The requested
|
|
184
|
+
// string never reaches a mutation: what gets written is the accountId
|
|
185
|
+
// that resolution settled on, and resolution refuses rather than picks
|
|
186
|
+
// when the answer is not one person.
|
|
187
|
+
const target = resolveAssignee(requested, await findCandidates(deps, requested));
|
|
188
|
+
// Two independent refusals, in the order that costs least. Already-set
|
|
189
|
+
// needs no Jira call; assignability does.
|
|
190
|
+
assertNotAlreadyAssigned(issueKey, snapshot.assigneeAccountId, target);
|
|
191
|
+
assertAssignable(issueKey, target, await deps.jiraAssignees.isAssignable(issueKey, target.accountId));
|
|
192
|
+
return {
|
|
193
|
+
before: {
|
|
194
|
+
assignee: snapshot.assigneeAccountId
|
|
195
|
+
? { accountId: snapshot.assigneeAccountId, displayName: issue.assignee ?? "" }
|
|
196
|
+
: null,
|
|
197
|
+
},
|
|
198
|
+
intendedAfter: { assignee: target },
|
|
199
|
+
mutation: { kind: "assignee", accountId: target.accountId },
|
|
200
|
+
...(snapshot.assigneeAccountId
|
|
201
|
+
? { baseAssigneeAccountId: snapshot.assigneeAccountId }
|
|
202
|
+
: {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
138
205
|
}
|
|
139
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Who Jira thinks this string could be.
|
|
209
|
+
*
|
|
210
|
+
* The search first, because it answers both halves of the contract most of the
|
|
211
|
+
* time - Jira's user search currently matches an accountId as readily as a
|
|
212
|
+
* name. "Currently" is the problem: that is a property of a substring search
|
|
213
|
+
* rather than a promise, and the contract says an accountId identifies a
|
|
214
|
+
* person. So when the search settles nothing, the exact lookup is asked before
|
|
215
|
+
* giving up.
|
|
216
|
+
*
|
|
217
|
+
* Ordered this way because it costs nothing on the paths that work. The extra
|
|
218
|
+
* request happens only where resolution was about to fail anyway, and the
|
|
219
|
+
* string is never inspected to guess whether it looks like an accountId - Jira
|
|
220
|
+
* is asked, and Jira answers.
|
|
221
|
+
*/
|
|
222
|
+
async function findCandidates(deps, requested) {
|
|
223
|
+
const candidates = await deps.jiraAssignees.searchUsers(requested);
|
|
224
|
+
if (exactMatches(requested, candidates).length > 0)
|
|
225
|
+
return candidates;
|
|
226
|
+
const byId = await deps.jiraAssignees.getUserByAccountId(requested);
|
|
227
|
+
return byId ? [byId] : candidates;
|
|
228
|
+
}
|
|
140
229
|
function currentValue(issue, field) {
|
|
141
230
|
switch (field) {
|
|
142
231
|
case "summary":
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { WritePlan } from "../domain/write.js";
|
|
1
|
+
import type { NewWritePlan, WritePlan } from "../domain/write.js";
|
|
2
2
|
/**
|
|
3
3
|
* Where a plan lives between `jira_write_plan` and `jira_write_apply`.
|
|
4
4
|
*
|
|
@@ -21,7 +21,7 @@ export declare class WritePlanStore {
|
|
|
21
21
|
private readonly plans;
|
|
22
22
|
/** Injected by tests so expiry does not depend on wall-clock timing. */
|
|
23
23
|
constructor(now?: () => Date);
|
|
24
|
-
create(plan:
|
|
24
|
+
create(plan: NewWritePlan): WritePlan;
|
|
25
25
|
/**
|
|
26
26
|
* Resolve a plan for applying.
|
|
27
27
|
*
|
|
@@ -45,7 +45,19 @@ export class WritePlanStore {
|
|
|
45
45
|
}
|
|
46
46
|
if (planExpired(plan.expiresAt, this.now())) {
|
|
47
47
|
this.plans.delete(planId);
|
|
48
|
-
|
|
48
|
+
// What to re-plan against differs by plan: an existing issue has a
|
|
49
|
+
// current state, a create has only the project's current create schema.
|
|
50
|
+
// Naming an issue key here for a create would name an issue that has
|
|
51
|
+
// never existed.
|
|
52
|
+
throw new JamError("JAM_WRITE_PLAN_EXPIRED", plan.kind === "create-issue"
|
|
53
|
+
? `This write plan expired at ${plan.expiresAt}. Nothing was created - re-plan against the current create schema for project ${plan.projectKey}.`
|
|
54
|
+
: `This write plan expired at ${plan.expiresAt}. Re-plan against the current state of ${plan.issueKey}.`, {
|
|
55
|
+
planId,
|
|
56
|
+
expiresAt: plan.expiresAt,
|
|
57
|
+
...(plan.kind === "create-issue"
|
|
58
|
+
? { project: plan.projectKey }
|
|
59
|
+
: { issueKey: plan.issueKey }),
|
|
60
|
+
});
|
|
49
61
|
}
|
|
50
62
|
return plan;
|
|
51
63
|
}
|
|
@@ -13,7 +13,7 @@ import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
|
|
|
13
13
|
export { LAUNCHER_PACKAGE_SPEC };
|
|
14
14
|
export declare const JAM_MCP_ENTRY: {
|
|
15
15
|
readonly command: "npx";
|
|
16
|
-
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.3.0", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Recognise wiring from before the launcher existed: a hard-coded path to one
|
package/dist/cli/auth.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export type AuthOptions = {
|
|
|
24
24
|
* it) is about the ordering, not about the network.
|
|
25
25
|
*/
|
|
26
26
|
verify?: (values: StoredCredentials) => Promise<string | undefined>;
|
|
27
|
+
/**
|
|
28
|
+
* Injected by tests. The suite runs with JAM_DISABLE_SECRET_STORE set so it
|
|
29
|
+
* cannot reach a real keychain, which would otherwise make every "no store
|
|
30
|
+
* on this system" case report the disabled one instead.
|
|
31
|
+
*/
|
|
32
|
+
env?: NodeJS.ProcessEnv;
|
|
27
33
|
};
|
|
28
34
|
export declare function authLoginCommand(options?: AuthOptions): Promise<number>;
|
|
29
35
|
export declare function authLogoutCommand(options?: AuthOptions): number;
|
package/dist/cli/auth.js
CHANGED
|
@@ -48,11 +48,12 @@ export async function authLoginCommand(options = {}) {
|
|
|
48
48
|
const ui = options.ui ?? new Ui();
|
|
49
49
|
const store = "store" in options ? options.store : resolveSecretStore();
|
|
50
50
|
const readBack = options.readBack ?? freshPort;
|
|
51
|
+
const env = options.env ?? process.env;
|
|
51
52
|
ui.section("Authentication");
|
|
52
53
|
if (!store) {
|
|
53
54
|
// Disabled and absent are different problems with different fixes, and
|
|
54
55
|
// saying "no store" when one was switched off sends the user hunting.
|
|
55
|
-
if (secretStoreDisabled()) {
|
|
56
|
+
if (secretStoreDisabled(env)) {
|
|
56
57
|
ui.failure("Secret store disabled by JAM_DISABLE_SECRET_STORE");
|
|
57
58
|
ui.line(" That variable is for isolated test sandboxes. Unset it and run this again.");
|
|
58
59
|
ui.next(ENV_HINT);
|
package/dist/deps.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { ProjectConfig } from "./config/schema.js";
|
|
|
4
4
|
import type { CachePort } from "./ports/cache.port.js";
|
|
5
5
|
import type { CredentialPort } from "./ports/credentials.port.js";
|
|
6
6
|
import type { JiraReadPort } from "./ports/jira-read.port.js";
|
|
7
|
+
import type { JiraAssigneeResolutionPort } from "./ports/jira-assignee-resolution.port.js";
|
|
8
|
+
import type { JiraCreateMetadataPort } from "./ports/jira-create-metadata.port.js";
|
|
7
9
|
import type { JiraWritePort } from "./ports/jira-write.port.js";
|
|
8
10
|
import { WritePlanStore } from "./application/write-plan-store.js";
|
|
9
11
|
import type { TelemetryPort } from "./ports/telemetry.port.js";
|
|
@@ -20,6 +22,20 @@ export type JamDeps = {
|
|
|
20
22
|
* that did both would blur them.
|
|
21
23
|
*/
|
|
22
24
|
jiraWrite: JiraWritePort;
|
|
25
|
+
/**
|
|
26
|
+
* What Jira will accept when creating an issue here. A third port rather
|
|
27
|
+
* than a method on either of the others: it mutates nothing, so it does not
|
|
28
|
+
* belong behind the write port's no-retry contract, and it answers a
|
|
29
|
+
* question about a project rather than about an issue, so the read port's
|
|
30
|
+
* completeness semantics would mean nothing for it.
|
|
31
|
+
*/
|
|
32
|
+
jiraCreateMetadata: JiraCreateMetadataPort;
|
|
33
|
+
/**
|
|
34
|
+
* Who a name refers to, and who may hold an issue. A fourth port for the
|
|
35
|
+
* same reason as the third: it reads a directory rather than an issue, and
|
|
36
|
+
* it mutates nothing.
|
|
37
|
+
*/
|
|
38
|
+
jiraAssignees: JiraAssigneeResolutionPort;
|
|
23
39
|
/**
|
|
24
40
|
* Plans awaiting apply. Lives for the life of this server process - see
|
|
25
41
|
* WritePlanStore for why it is not persisted.
|
|
@@ -35,6 +51,10 @@ export type BuildDepsOptions = {
|
|
|
35
51
|
jira?: JiraReadPort;
|
|
36
52
|
/** Injected by tests so no test can reach a real Jira write endpoint. */
|
|
37
53
|
jiraWrite?: JiraWritePort;
|
|
54
|
+
/** Injected by tests so create metadata comes from a fixture, not a site. */
|
|
55
|
+
jiraCreateMetadata?: JiraCreateMetadataPort;
|
|
56
|
+
/** Injected by tests so user resolution never reaches a real directory. */
|
|
57
|
+
jiraAssignees?: JiraAssigneeResolutionPort;
|
|
38
58
|
/** Injected by tests to bypass the real process/registry credential lookup. */
|
|
39
59
|
credentials?: CredentialPort;
|
|
40
60
|
/**
|
package/dist/deps.js
CHANGED
|
@@ -33,12 +33,24 @@ export async function buildDeps(options = {}) {
|
|
|
33
33
|
const { JiraCloudWriteAdapter } = await import("./adapters/jira-cloud/jira-write.adapter.js");
|
|
34
34
|
jiraWrite = new JiraCloudWriteAdapter(credentials);
|
|
35
35
|
}
|
|
36
|
+
let jiraCreateMetadata = options.jiraCreateMetadata;
|
|
37
|
+
if (!jiraCreateMetadata) {
|
|
38
|
+
const { JiraCloudCreateMetadataAdapter } = await import("./adapters/jira-cloud/jira-create-metadata.adapter.js");
|
|
39
|
+
jiraCreateMetadata = new JiraCloudCreateMetadataAdapter(credentials);
|
|
40
|
+
}
|
|
41
|
+
let jiraAssignees = options.jiraAssignees;
|
|
42
|
+
if (!jiraAssignees) {
|
|
43
|
+
const { JiraCloudAssigneeResolutionAdapter } = await import("./adapters/jira-cloud/jira-assignee-resolution.adapter.js");
|
|
44
|
+
jiraAssignees = new JiraCloudAssigneeResolutionAdapter(credentials);
|
|
45
|
+
}
|
|
36
46
|
return {
|
|
37
47
|
config: resolved.config,
|
|
38
48
|
configPath: resolved.configPath,
|
|
39
49
|
keySource: resolved.keySource,
|
|
40
50
|
jira,
|
|
41
51
|
jiraWrite,
|
|
52
|
+
jiraCreateMetadata,
|
|
53
|
+
jiraAssignees,
|
|
42
54
|
writePlans: new WritePlanStore(),
|
|
43
55
|
cache: new NoopCache(),
|
|
44
56
|
telemetry,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain text to the narrowest Atlassian Document Format that represents it.
|
|
3
|
+
*
|
|
4
|
+
* Jira's rich-text fields - a comment body, an issue description - are document
|
|
5
|
+
* trees. Accepting one from a caller would mean accepting arbitrary structure
|
|
6
|
+
* through a field that reads like "text": panels, mentions, embedded content,
|
|
7
|
+
* links to anywhere. So JAM's contract is plain text in both places, and this
|
|
8
|
+
* is the single conversion that produces the document.
|
|
9
|
+
*
|
|
10
|
+
* Shared rather than duplicated per field on purpose. Two copies of this would
|
|
11
|
+
* be two answers to "what can an agent put in a Jira document", and the second
|
|
12
|
+
* one would drift.
|
|
13
|
+
*
|
|
14
|
+
* Blank lines separate paragraphs; everything else is literal. No markdown is
|
|
15
|
+
* interpreted, so text containing `*` or `#` says what it says.
|
|
16
|
+
*/
|
|
17
|
+
export declare function textToAdf(text: string): unknown;
|
|
18
|
+
/**
|
|
19
|
+
* The text as it will exist once Jira has it.
|
|
20
|
+
*
|
|
21
|
+
* A write is only verified if what a direct read shows can be compared to what
|
|
22
|
+
* was asked for - and for a rich-text field those two are never byte-identical.
|
|
23
|
+
* The caller's string becomes a document, Jira stores the document, and reading
|
|
24
|
+
* it back renders a document into text again. Blank-line runs collapse, block
|
|
25
|
+
* edges lose their whitespace, a trailing newline disappears.
|
|
26
|
+
*
|
|
27
|
+
* None of that changes what the description says, so comparing raw strings
|
|
28
|
+
* would fail every time. Comparing canonical forms fails only when the text
|
|
29
|
+
* actually differs.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately the same normalization `textToAdf` performs, from the same
|
|
32
|
+
* place: "what JAM sends" and "what JAM will accept as proof it arrived" must
|
|
33
|
+
* not be able to drift into two answers.
|
|
34
|
+
*/
|
|
35
|
+
export declare function canonicalizePlainText(text: string): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain text to the narrowest Atlassian Document Format that represents it.
|
|
3
|
+
*
|
|
4
|
+
* Jira's rich-text fields - a comment body, an issue description - are document
|
|
5
|
+
* trees. Accepting one from a caller would mean accepting arbitrary structure
|
|
6
|
+
* through a field that reads like "text": panels, mentions, embedded content,
|
|
7
|
+
* links to anywhere. So JAM's contract is plain text in both places, and this
|
|
8
|
+
* is the single conversion that produces the document.
|
|
9
|
+
*
|
|
10
|
+
* Shared rather than duplicated per field on purpose. Two copies of this would
|
|
11
|
+
* be two answers to "what can an agent put in a Jira document", and the second
|
|
12
|
+
* one would drift.
|
|
13
|
+
*
|
|
14
|
+
* Blank lines separate paragraphs; everything else is literal. No markdown is
|
|
15
|
+
* interpreted, so text containing `*` or `#` says what it says.
|
|
16
|
+
*/
|
|
17
|
+
export function textToAdf(text) {
|
|
18
|
+
const paragraphs = toParagraphs(text);
|
|
19
|
+
return {
|
|
20
|
+
type: "doc",
|
|
21
|
+
version: 1,
|
|
22
|
+
content: (paragraphs.length > 0 ? paragraphs : [text]).map((block) => ({
|
|
23
|
+
type: "paragraph",
|
|
24
|
+
content: [{ type: "text", text: block }],
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The text as it will exist once Jira has it.
|
|
30
|
+
*
|
|
31
|
+
* A write is only verified if what a direct read shows can be compared to what
|
|
32
|
+
* was asked for - and for a rich-text field those two are never byte-identical.
|
|
33
|
+
* The caller's string becomes a document, Jira stores the document, and reading
|
|
34
|
+
* it back renders a document into text again. Blank-line runs collapse, block
|
|
35
|
+
* edges lose their whitespace, a trailing newline disappears.
|
|
36
|
+
*
|
|
37
|
+
* None of that changes what the description says, so comparing raw strings
|
|
38
|
+
* would fail every time. Comparing canonical forms fails only when the text
|
|
39
|
+
* actually differs.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately the same normalization `textToAdf` performs, from the same
|
|
42
|
+
* place: "what JAM sends" and "what JAM will accept as proof it arrived" must
|
|
43
|
+
* not be able to drift into two answers.
|
|
44
|
+
*/
|
|
45
|
+
export function canonicalizePlainText(text) {
|
|
46
|
+
return toParagraphs(text).join("\n\n");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Blank lines separate paragraphs; everything else is literal.
|
|
50
|
+
*
|
|
51
|
+
* A single newline inside a block survives - it is a line break the author
|
|
52
|
+
* wrote, and ADF round-trips it - so only the edges of each block are trimmed.
|
|
53
|
+
*/
|
|
54
|
+
function toParagraphs(text) {
|
|
55
|
+
return (text
|
|
56
|
+
// Line endings first. A CRLF document would otherwise carry a stray \r
|
|
57
|
+
// at the end of every block into ADF, and Jira renders it back as LF -
|
|
58
|
+
// so a create that was correct would fail verification, on nothing more
|
|
59
|
+
// than which editor the caller used. Worse, `\r\n\r\n` and `\n\n` would
|
|
60
|
+
// split into paragraphs differently.
|
|
61
|
+
.replace(/\r\n?/g, "\n")
|
|
62
|
+
.split(/\n{2,}/)
|
|
63
|
+
.map((block) => block.trim())
|
|
64
|
+
.filter(Boolean));
|
|
65
|
+
}
|
package/dist/domain/errors.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* is mapped onto one of these codes so the agent (and `jam doctor`) can reason
|
|
6
6
|
* about failures without parsing vendor-specific payloads.
|
|
7
7
|
*/
|
|
8
|
-
export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
|
|
8
|
+
export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_ASSIGNEE_NOT_FOUND", "JAM_WRITE_ASSIGNEE_AMBIGUOUS", "JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", "JAM_WRITE_ASSIGNEE_ALREADY_SET", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
|
|
9
9
|
export type JamErrorCode = (typeof JAM_ERROR_CODES)[number];
|
|
10
10
|
export type JamErrorPayload = {
|
|
11
11
|
error: {
|
package/dist/domain/errors.js
CHANGED
|
@@ -25,6 +25,23 @@ export const JAM_ERROR_CODES = [
|
|
|
25
25
|
"JAM_WRITE_OPERATION_NOT_ALLOWED",
|
|
26
26
|
"JAM_WRITE_FIELD_NOT_ALLOWED",
|
|
27
27
|
"JAM_WRITE_TRANSITION_NOT_AVAILABLE",
|
|
28
|
+
// Creation. Each is a refusal JAM makes before Jira is asked to act, or a
|
|
29
|
+
// premise that stopped holding between planning and applying - never a raw
|
|
30
|
+
// Jira 400 passed along. "That type is not on offer", "this project needs a
|
|
31
|
+
// field JAM cannot fill" and "the schema moved under the plan" are three
|
|
32
|
+
// different next steps for whoever is holding the agent.
|
|
33
|
+
"JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE",
|
|
34
|
+
"JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED",
|
|
35
|
+
"JAM_WRITE_VALUE_NOT_ALLOWED",
|
|
36
|
+
"JAM_WRITE_SCHEMA_CHANGED",
|
|
37
|
+
// Assignment. A name is not an identity, and Jira decides who may hold an
|
|
38
|
+
// issue - so "nobody by that name", "several people by that name", "that
|
|
39
|
+
// person may not hold this issue" and "they already do" are four different
|
|
40
|
+
// things for a caller to do next, and none of them is "try again".
|
|
41
|
+
"JAM_WRITE_ASSIGNEE_NOT_FOUND",
|
|
42
|
+
"JAM_WRITE_ASSIGNEE_AMBIGUOUS",
|
|
43
|
+
"JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE",
|
|
44
|
+
"JAM_WRITE_ASSIGNEE_ALREADY_SET",
|
|
28
45
|
"JAM_WRITE_PLAN_NOT_FOUND",
|
|
29
46
|
"JAM_WRITE_PLAN_EXPIRED",
|
|
30
47
|
"JAM_WRITE_CONFLICT",
|