@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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { JamError, toJamError } from "../domain/errors.js";
|
|
2
|
+
import { canonicalizePlainText } from "../domain/adf.js";
|
|
3
|
+
import { assertSchemaUnchanged } from "../policy/create-policy.js";
|
|
4
|
+
import { projectKeyOf } from "../policy/write-policy.js";
|
|
5
|
+
import { readIssue } from "./plan-write.js";
|
|
6
|
+
/**
|
|
7
|
+
* Create the issue a plan describes, then go and look at what was created.
|
|
8
|
+
*
|
|
9
|
+
* The same three-step shape as every other apply, with one substitution.
|
|
10
|
+
* Updating an existing issue re-reads that issue and compares its revision;
|
|
11
|
+
* there is no issue to re-read here, so what gets checked instead is the
|
|
12
|
+
* premise the plan was built on - the project's create schema. That is
|
|
13
|
+
* creation's concurrency boundary.
|
|
14
|
+
*
|
|
15
|
+
* 1. Re-derive the schema and check the plan's premises still hold. If the
|
|
16
|
+
* issue type went away, or a required field JAM cannot fill appeared, or a
|
|
17
|
+
* resolved value is no longer offered, nothing is sent.
|
|
18
|
+
* 2. POST the create exactly once. No retry, ever - see below.
|
|
19
|
+
* 3. Read the new issue by the key Jira returned, and check it says what the
|
|
20
|
+
* plan intended. A 201 with a key is Jira accepting a request, not
|
|
21
|
+
* evidence that the issue exists as described.
|
|
22
|
+
*/
|
|
23
|
+
export async function applyCreateIssue(deps, plan) {
|
|
24
|
+
if (plan.mutation.kind !== "create") {
|
|
25
|
+
throw new JamError("CONFIG_INVALID", "A create-issue plan must carry a create mutation.");
|
|
26
|
+
}
|
|
27
|
+
await revalidateSchema(deps, plan);
|
|
28
|
+
const created = await create(deps, plan);
|
|
29
|
+
const after = await verify(deps, plan, created.key);
|
|
30
|
+
deps.writePlans.consume(plan.planId);
|
|
31
|
+
return {
|
|
32
|
+
status: "applied",
|
|
33
|
+
issue: created.key,
|
|
34
|
+
operation: plan.operation,
|
|
35
|
+
before: plan.before,
|
|
36
|
+
after,
|
|
37
|
+
verified: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Ask Jira for the create schema again, and check the plan still stands.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately not a comparison of the two metadata documents. The question is
|
|
44
|
+
* not "is the schema identical" - on an active project it rarely is - but "are
|
|
45
|
+
* this plan's premises still true". See assertSchemaUnchanged.
|
|
46
|
+
*/
|
|
47
|
+
async function revalidateSchema(deps, plan) {
|
|
48
|
+
const { projectKey, schemaRequirements } = plan;
|
|
49
|
+
const issueTypes = await deps.jiraCreateMetadata.getIssueTypes(projectKey);
|
|
50
|
+
const fields = await deps.jiraCreateMetadata.getCreateFields(projectKey, schemaRequirements.issueTypeId);
|
|
51
|
+
assertSchemaUnchanged(schemaRequirements, issueTypes, fields, intendedInput(plan));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The create request as an input again, for re-running the required-field gate.
|
|
55
|
+
*
|
|
56
|
+
* Derived from `intendedAfter` rather than kept as a second copy of the
|
|
57
|
+
* request: one record of what this plan intends, read two ways.
|
|
58
|
+
*/
|
|
59
|
+
function intendedInput(plan) {
|
|
60
|
+
const after = plan.intendedAfter;
|
|
61
|
+
return {
|
|
62
|
+
issueType: plan.schemaRequirements.issueTypeName,
|
|
63
|
+
summary: String(after["summary"] ?? ""),
|
|
64
|
+
...(after["description"] !== undefined ? { description: String(after["description"]) } : {}),
|
|
65
|
+
...(after["priority"] !== undefined ? { priority: String(after["priority"]) } : {}),
|
|
66
|
+
...(Array.isArray(after["labels"]) ? { labels: after["labels"] } : {}),
|
|
67
|
+
...(Array.isArray(after["components"]) ? { components: after["components"] } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Send the create, once.
|
|
72
|
+
*
|
|
73
|
+
* There is no retry here and there must not be one. This is the sharpest
|
|
74
|
+
* version of the rule the whole write plane follows: a create that fails
|
|
75
|
+
* ambiguously may already have produced an issue, and resending it produces a
|
|
76
|
+
* second one - on someone's board, in someone's sprint, with a key nobody is
|
|
77
|
+
* holding. So an ambiguous failure becomes JAM_WRITE_UNCERTAIN and says what
|
|
78
|
+
* to do about it: look in the project, do not send it again.
|
|
79
|
+
*/
|
|
80
|
+
async function create(deps, plan) {
|
|
81
|
+
if (plan.mutation.kind !== "create") {
|
|
82
|
+
throw new JamError("CONFIG_INVALID", "A create-issue plan must carry a create mutation.");
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return await deps.jiraWrite.createIssue(plan.mutation.fields);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
const jamError = toJamError(err);
|
|
89
|
+
if (!isAmbiguous(jamError))
|
|
90
|
+
throw jamError;
|
|
91
|
+
throw new JamError("JAM_WRITE_UNCERTAIN", `JAM could not tell whether the issue was created in ${plan.projectKey}: ${jamError.message} Look in the project to find out - do not retry this write, which could create a second issue.`, { project: plan.projectKey, operation: plan.operation, cause: jamError.code });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A failure that says nothing about whether Jira acted.
|
|
96
|
+
*
|
|
97
|
+
* A 403 or a 400 is a decision Jira made and did not act on. A dropped
|
|
98
|
+
* connection or a 5xx is not: the request may have been processed before the
|
|
99
|
+
* answer went missing. JAM_WRITE_UNCERTAIN raised inside the adapter - Jira
|
|
100
|
+
* accepted a create but named no issue - is ambiguous by construction and
|
|
101
|
+
* passes through unchanged.
|
|
102
|
+
*/
|
|
103
|
+
function isAmbiguous(err) {
|
|
104
|
+
return (err.code === "JIRA_UNAVAILABLE" ||
|
|
105
|
+
err.code === "RATE_LIMITED" ||
|
|
106
|
+
err.code === "JAM_WRITE_UNCERTAIN");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Read the created issue and check it is the one the plan described.
|
|
110
|
+
*
|
|
111
|
+
* Every field in `intendedAfter` is checked, because `intendedAfter` is also
|
|
112
|
+
* what the plan receipt promised `verification.expects` would show. A field
|
|
113
|
+
* the receipt names and the check skips is worse than one it never named: it
|
|
114
|
+
* reports `verified: true` about something nobody looked at.
|
|
115
|
+
*
|
|
116
|
+
* Only the fields the create contract lets a caller ask for are in there.
|
|
117
|
+
* Jira fills in a great deal on its own - reporter, created, status, whatever
|
|
118
|
+
* a project's automation adds - and none of that was requested, so requiring
|
|
119
|
+
* it to match something would be inventing an expectation nobody stated.
|
|
120
|
+
*/
|
|
121
|
+
async function verify(deps, plan, issueKey) {
|
|
122
|
+
// Where the issue landed is part of what was intended. The workspace binding
|
|
123
|
+
// is the whole of JAM's write scope, so a key from another project coming
|
|
124
|
+
// back from a create is the one outcome that must never be reported as the
|
|
125
|
+
// create that was planned.
|
|
126
|
+
const createdProject = projectKeyOf(issueKey);
|
|
127
|
+
if (createdProject !== plan.projectKey) {
|
|
128
|
+
throw verificationFailed(plan, issueKey, { project: plan.projectKey }, { project: createdProject ?? issueKey });
|
|
129
|
+
}
|
|
130
|
+
const { issue } = await readIssue(deps, issueKey);
|
|
131
|
+
const observed = {};
|
|
132
|
+
for (const field of Object.keys(plan.intendedAfter)) {
|
|
133
|
+
observed[field] = observedValue(issue, field);
|
|
134
|
+
}
|
|
135
|
+
for (const [field, expected] of Object.entries(plan.intendedAfter)) {
|
|
136
|
+
if (!sameValue(observed[field], expected)) {
|
|
137
|
+
throw verificationFailed(plan, issueKey, plan.intendedAfter, observed);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return observed;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The created issue's value for one requested field.
|
|
144
|
+
*
|
|
145
|
+
* The description is canonicalized on the way out, the same way the plan
|
|
146
|
+
* canonicalized what was asked for. Jira stores a document and renders it back
|
|
147
|
+
* as text, so the two are never byte-identical - and a comparison that failed
|
|
148
|
+
* on Jira's own formatting would be reporting a problem that is not there.
|
|
149
|
+
*/
|
|
150
|
+
function observedValue(issue, field) {
|
|
151
|
+
switch (field) {
|
|
152
|
+
case "issueType":
|
|
153
|
+
return issue.issueType;
|
|
154
|
+
case "summary":
|
|
155
|
+
return issue.summary;
|
|
156
|
+
case "description":
|
|
157
|
+
return issue.description === undefined
|
|
158
|
+
? undefined
|
|
159
|
+
: canonicalizePlainText(issue.description);
|
|
160
|
+
case "priority":
|
|
161
|
+
return issue.priority;
|
|
162
|
+
case "labels":
|
|
163
|
+
return issue.labels;
|
|
164
|
+
case "components":
|
|
165
|
+
return issue.components;
|
|
166
|
+
default:
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The issue exists. Say so, and say not to make another one.
|
|
172
|
+
*
|
|
173
|
+
* This is the failure an agent is most likely to answer by trying again, and
|
|
174
|
+
* trying again is the one thing that turns a wrong issue into two wrong
|
|
175
|
+
* issues.
|
|
176
|
+
*/
|
|
177
|
+
function verificationFailed(plan, issueKey, expected, observed) {
|
|
178
|
+
return new JamError("JAM_WRITE_VERIFICATION_FAILED", `Jira created ${issueKey}, but a direct read does not show what the plan intended. A workflow rule or a project automation may have altered the issue as it was created. The issue exists - look at it and fix it there, or delete it. Do not create another one.`, { issueKey, operation: plan.operation, expected, observed });
|
|
179
|
+
}
|
|
180
|
+
function sameValue(observed, expected) {
|
|
181
|
+
if (Array.isArray(expected) || Array.isArray(observed)) {
|
|
182
|
+
const a = Array.isArray(observed) ? [...observed].map(String).sort() : [];
|
|
183
|
+
const b = Array.isArray(expected) ? [...expected].map(String).sort() : [];
|
|
184
|
+
return a.length === b.length && a.every((value, i) => value === b[i]);
|
|
185
|
+
}
|
|
186
|
+
return observed === expected;
|
|
187
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { JamError, toJamError } from "../domain/errors.js";
|
|
2
2
|
import { readModeAfterWrite } from "../policy/consistency-policy.js";
|
|
3
|
+
import { assertAssignable } from "../policy/assignee-policy.js";
|
|
3
4
|
import { assertUnchanged } from "../policy/write-policy.js";
|
|
5
|
+
import { applyCreateIssue } from "./apply-create-issue.js";
|
|
4
6
|
import { readIssue } from "./plan-write.js";
|
|
5
7
|
/**
|
|
6
8
|
* Execute a plan JAM made, then go and look at what happened.
|
|
@@ -28,8 +30,18 @@ export async function applyWritePlan(deps, request) {
|
|
|
28
30
|
if (readModeAfterWrite() !== "direct") {
|
|
29
31
|
throw new JamError("CONFIG_INVALID", "Write confirmation must use a direct issue read.");
|
|
30
32
|
}
|
|
33
|
+
// Creation follows the same three steps with a different first one: there is
|
|
34
|
+
// no issue to re-read, so what gets re-checked is the create schema the plan
|
|
35
|
+
// was built on. Both paths still end in a direct read of a real issue.
|
|
36
|
+
if (plan.kind === "create-issue")
|
|
37
|
+
return applyCreateIssue(deps, plan);
|
|
31
38
|
const current = await readIssue(deps, plan.issueKey);
|
|
32
|
-
assertUnchanged(plan.issueKey, plan.baseUpdated, current.updated);
|
|
39
|
+
assertUnchanged(plan.issueKey, plan.baseUpdated, current.issue.updated);
|
|
40
|
+
// Whatever the plan depends on that the revision check cannot see, checked
|
|
41
|
+
// again here. For an assignment that is the target's permission to hold this
|
|
42
|
+
// issue: it can be revoked between planning and applying, and a plan that
|
|
43
|
+
// was valid is not the same as a plan that is still valid.
|
|
44
|
+
await revalidate(deps, plan);
|
|
33
45
|
const outcome = await mutate(deps, plan);
|
|
34
46
|
const after = await verify(deps, plan);
|
|
35
47
|
deps.writePlans.consume(plan.planId);
|
|
@@ -43,6 +55,18 @@ export async function applyWritePlan(deps, request) {
|
|
|
43
55
|
...(outcome.commentId ? { commentId: outcome.commentId } : {}),
|
|
44
56
|
};
|
|
45
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Re-derive the premises the revision check does not cover.
|
|
60
|
+
*
|
|
61
|
+
* Only `assignee.update` has any: the rest are fully described by the issue's
|
|
62
|
+
* own state, which `assertUnchanged` already compared.
|
|
63
|
+
*/
|
|
64
|
+
async function revalidate(deps, plan) {
|
|
65
|
+
if (plan.mutation.kind !== "assignee")
|
|
66
|
+
return;
|
|
67
|
+
const target = plan.intendedAfter["assignee"];
|
|
68
|
+
assertAssignable(plan.issueKey, target, await deps.jiraAssignees.isAssignable(plan.issueKey, plan.mutation.accountId));
|
|
69
|
+
}
|
|
46
70
|
/**
|
|
47
71
|
* Send the mutation, once.
|
|
48
72
|
*
|
|
@@ -65,6 +89,14 @@ async function mutate(deps, plan) {
|
|
|
65
89
|
case "transition":
|
|
66
90
|
await deps.jiraWrite.transitionIssue(plan.issueKey, plan.mutation.transitionId);
|
|
67
91
|
return {};
|
|
92
|
+
case "assignee":
|
|
93
|
+
await deps.jiraWrite.assignIssue(plan.issueKey, plan.mutation.accountId);
|
|
94
|
+
return {};
|
|
95
|
+
case "create":
|
|
96
|
+
// Unreachable: a create plan is routed to applyCreateIssue above. The
|
|
97
|
+
// case exists so adding a mutation kind is a compile error here rather
|
|
98
|
+
// than a silent fall-through that writes nothing and reports success.
|
|
99
|
+
throw new JamError("CONFIG_INVALID", "A create mutation cannot be applied through the existing-issue path.");
|
|
68
100
|
}
|
|
69
101
|
}
|
|
70
102
|
catch (err) {
|
|
@@ -92,12 +124,31 @@ function isAmbiguous(err) {
|
|
|
92
124
|
* ours.
|
|
93
125
|
*/
|
|
94
126
|
async function verify(deps, plan) {
|
|
95
|
-
const
|
|
127
|
+
const snapshot = await readIssue(deps, plan.issueKey);
|
|
128
|
+
const issue = snapshot.issue;
|
|
129
|
+
if (plan.mutation.kind === "assignee") {
|
|
130
|
+
// On the accountId, never on the display name. Two people can share a
|
|
131
|
+
// name, so a name comparison would accept the wrong person's assignment as
|
|
132
|
+
// proof of the right one's - which is the entire reason resolution went to
|
|
133
|
+
// the trouble of producing an identity.
|
|
134
|
+
const expected = plan.intendedAfter["assignee"];
|
|
135
|
+
const observed = snapshot.assigneeAccountId
|
|
136
|
+
? { accountId: snapshot.assigneeAccountId, displayName: issue.assignee ?? "" }
|
|
137
|
+
: null;
|
|
138
|
+
if (snapshot.assigneeAccountId !== expected.accountId) {
|
|
139
|
+
throw verificationFailed(plan, { assignee: expected }, { assignee: observed });
|
|
140
|
+
}
|
|
141
|
+
return { assignee: { accountId: expected.accountId, displayName: issue.assignee ?? expected.displayName } };
|
|
142
|
+
}
|
|
96
143
|
if (plan.mutation.kind === "comment") {
|
|
97
|
-
|
|
98
|
-
|
|
144
|
+
// Direct issue GET again, not the bulk endpoint: this is post-write
|
|
145
|
+
// confirmation, and ConsistencyPolicy makes no exception for the read that
|
|
146
|
+
// happens to want the comment field.
|
|
147
|
+
const { issue: withComments } = await deps.jira.getIssue({
|
|
148
|
+
key: plan.issueKey,
|
|
99
149
|
fields: ["summary", "status", "comment", "updated"],
|
|
100
|
-
})
|
|
150
|
+
});
|
|
151
|
+
const comments = withComments?.comments ?? [];
|
|
101
152
|
const wanted = plan.mutation.text.trim();
|
|
102
153
|
const found = comments.some((c) => c.body.trim() === wanted);
|
|
103
154
|
if (!found) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { JamDeps } from "../deps.js";
|
|
2
|
+
import type { CreateIssueInput, CreateIssueWritePlan, WritePlanReceipt } from "../domain/write.js";
|
|
3
|
+
export type PlanCreateIssueRequest = {
|
|
4
|
+
input: Record<string, unknown>;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Work out whether an issue can be created here, and describe the one JAM
|
|
8
|
+
* would create.
|
|
9
|
+
*
|
|
10
|
+
* Reads only - the create metadata endpoints answer questions about a
|
|
11
|
+
* project's configuration and change nothing. The order is the point:
|
|
12
|
+
*
|
|
13
|
+
* 1. Validate the request against JAM's own contract. Anything refusable
|
|
14
|
+
* without asking Jira is refused before a round trip is spent on it.
|
|
15
|
+
* 2. Ask Jira which issue types this account can create here, and resolve the
|
|
16
|
+
* requested one against that list. An id is never derived from a name.
|
|
17
|
+
* 3. Ask Jira what that issue type's create screen requires, and refuse now if
|
|
18
|
+
* it requires something JAM cannot express. A create JAM knows Jira will
|
|
19
|
+
* reject is not sent.
|
|
20
|
+
* 4. Resolve every constrained value against Jira's own allowed list.
|
|
21
|
+
*
|
|
22
|
+
* What comes out is a plan that records not just the intended issue but the
|
|
23
|
+
* premises it rests on, so apply can check they still hold.
|
|
24
|
+
*
|
|
25
|
+
* The target project is never a parameter. It is the project this workspace is
|
|
26
|
+
* bound to, which is what the user consented to when they set JAM up; taking
|
|
27
|
+
* it from the caller would make the binding advisory.
|
|
28
|
+
*/
|
|
29
|
+
export declare function planCreateIssue(deps: JamDeps, request: PlanCreateIssueRequest): Promise<{
|
|
30
|
+
plan: CreateIssueWritePlan;
|
|
31
|
+
receipt: WritePlanReceipt;
|
|
32
|
+
}>;
|
|
33
|
+
/** The project this workspace is bound to, or a refusal that says so. */
|
|
34
|
+
export declare function configuredProject(deps: JamDeps): string;
|
|
35
|
+
/**
|
|
36
|
+
* Check the request against the create contract, and normalize it.
|
|
37
|
+
*
|
|
38
|
+
* Pure: no Jira, no state. `key` is not accepted here at all - there is no
|
|
39
|
+
* issue to name - and neither is `project`, which comes from the binding.
|
|
40
|
+
* Anything outside CREATABLE_FIELDS is rejected rather than ignored: silently
|
|
41
|
+
* dropping a field an agent asked for would create an issue that is not the
|
|
42
|
+
* one it described.
|
|
43
|
+
*/
|
|
44
|
+
export declare function validateCreateInput(raw: Record<string, unknown>): CreateIssueInput;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { JamError } from "../domain/errors.js";
|
|
2
|
+
import { CREATABLE_FIELDS } from "../domain/write.js";
|
|
3
|
+
import { assertRequiredFieldsSupported, CREATE_FIELD_IDS, resolveAllowedValue, resolveIssueType, } from "../policy/create-policy.js";
|
|
4
|
+
import { PLAN_TTL_MS } from "../policy/write-policy.js";
|
|
5
|
+
import { canonicalizePlainText, textToAdf } from "../domain/adf.js";
|
|
6
|
+
/**
|
|
7
|
+
* Work out whether an issue can be created here, and describe the one JAM
|
|
8
|
+
* would create.
|
|
9
|
+
*
|
|
10
|
+
* Reads only - the create metadata endpoints answer questions about a
|
|
11
|
+
* project's configuration and change nothing. The order is the point:
|
|
12
|
+
*
|
|
13
|
+
* 1. Validate the request against JAM's own contract. Anything refusable
|
|
14
|
+
* without asking Jira is refused before a round trip is spent on it.
|
|
15
|
+
* 2. Ask Jira which issue types this account can create here, and resolve the
|
|
16
|
+
* requested one against that list. An id is never derived from a name.
|
|
17
|
+
* 3. Ask Jira what that issue type's create screen requires, and refuse now if
|
|
18
|
+
* it requires something JAM cannot express. A create JAM knows Jira will
|
|
19
|
+
* reject is not sent.
|
|
20
|
+
* 4. Resolve every constrained value against Jira's own allowed list.
|
|
21
|
+
*
|
|
22
|
+
* What comes out is a plan that records not just the intended issue but the
|
|
23
|
+
* premises it rests on, so apply can check they still hold.
|
|
24
|
+
*
|
|
25
|
+
* The target project is never a parameter. It is the project this workspace is
|
|
26
|
+
* bound to, which is what the user consented to when they set JAM up; taking
|
|
27
|
+
* it from the caller would make the binding advisory.
|
|
28
|
+
*/
|
|
29
|
+
export async function planCreateIssue(deps, request) {
|
|
30
|
+
const projectKey = configuredProject(deps);
|
|
31
|
+
const input = validateCreateInput(request.input);
|
|
32
|
+
const issueTypes = await deps.jiraCreateMetadata.getIssueTypes(projectKey);
|
|
33
|
+
const issueType = resolveIssueType(input.issueType, issueTypes);
|
|
34
|
+
const fields = await deps.jiraCreateMetadata.getCreateFields(projectKey, issueType.id);
|
|
35
|
+
const requiredFieldIds = assertRequiredFieldsSupported(fields, input);
|
|
36
|
+
const byId = new Map(fields.map((f) => [f.id, f]));
|
|
37
|
+
const resolvedValues = [];
|
|
38
|
+
// Only the constrained fields go through resolution. Summary and description
|
|
39
|
+
// are free text, and labels are a Jira-wide vocabulary rather than a
|
|
40
|
+
// per-project one, so there is no list to resolve them against.
|
|
41
|
+
let priority;
|
|
42
|
+
if (input.priority !== undefined) {
|
|
43
|
+
const resolved = resolveAllowedValue(byId.get(CREATE_FIELD_IDS.priority), input.priority, "priority");
|
|
44
|
+
priority = resolved.resolved;
|
|
45
|
+
resolvedValues.push({ fieldId: CREATE_FIELD_IDS.priority, ...resolved });
|
|
46
|
+
}
|
|
47
|
+
let components;
|
|
48
|
+
if (input.components !== undefined) {
|
|
49
|
+
const field = byId.get(CREATE_FIELD_IDS.components);
|
|
50
|
+
components = input.components.map((name) => {
|
|
51
|
+
const resolved = resolveAllowedValue(field, name, "component");
|
|
52
|
+
resolvedValues.push({ fieldId: CREATE_FIELD_IDS.components, ...resolved });
|
|
53
|
+
return resolved.resolved;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const intendedAfter = {
|
|
57
|
+
issueType: issueType.name,
|
|
58
|
+
summary: input.summary,
|
|
59
|
+
// Canonical, not the raw string. `intendedAfter` is also what
|
|
60
|
+
// `verification.expects` promises a direct read will show, and a direct
|
|
61
|
+
// read shows the text as Jira renders it back - so promising the caller's
|
|
62
|
+
// exact bytes would be promising something no read can ever produce.
|
|
63
|
+
...(input.description !== undefined
|
|
64
|
+
? { description: canonicalizePlainText(input.description) }
|
|
65
|
+
: {}),
|
|
66
|
+
...(priority !== undefined ? { priority } : {}),
|
|
67
|
+
...(input.labels !== undefined ? { labels: input.labels } : {}),
|
|
68
|
+
...(components !== undefined ? { components } : {}),
|
|
69
|
+
};
|
|
70
|
+
const createdAt = new Date();
|
|
71
|
+
const plan = deps.writePlans.create({
|
|
72
|
+
kind: "create-issue",
|
|
73
|
+
projectKey,
|
|
74
|
+
operation: "issue.create",
|
|
75
|
+
before: { issue: null },
|
|
76
|
+
intendedAfter,
|
|
77
|
+
schemaRequirements: {
|
|
78
|
+
issueTypeId: issueType.id,
|
|
79
|
+
issueTypeName: issueType.name,
|
|
80
|
+
requiredFieldIds,
|
|
81
|
+
resolvedValues,
|
|
82
|
+
},
|
|
83
|
+
createdAt: createdAt.toISOString(),
|
|
84
|
+
expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
|
|
85
|
+
mutation: {
|
|
86
|
+
kind: "create",
|
|
87
|
+
fields: toJiraCreateFields(projectKey, issueType.id, input, { priority, components }),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
plan,
|
|
92
|
+
receipt: {
|
|
93
|
+
status: "planned",
|
|
94
|
+
planId: plan.planId,
|
|
95
|
+
operation: plan.operation,
|
|
96
|
+
project: plan.projectKey,
|
|
97
|
+
before: plan.before,
|
|
98
|
+
intendedAfter: plan.intendedAfter,
|
|
99
|
+
expiresAt: plan.expiresAt,
|
|
100
|
+
verification: { method: "direct-issue-read", expects: plan.intendedAfter },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** The project this workspace is bound to, or a refusal that says so. */
|
|
105
|
+
export function configuredProject(deps) {
|
|
106
|
+
const configured = deps.config.project.key.trim().toUpperCase();
|
|
107
|
+
if (!configured) {
|
|
108
|
+
throw new JamError("JAM_SETUP_REQUIRED", "No Jira project is configured for this workspace, so JAM does not know where an issue would be created.");
|
|
109
|
+
}
|
|
110
|
+
return configured;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Check the request against the create contract, and normalize it.
|
|
114
|
+
*
|
|
115
|
+
* Pure: no Jira, no state. `key` is not accepted here at all - there is no
|
|
116
|
+
* issue to name - and neither is `project`, which comes from the binding.
|
|
117
|
+
* Anything outside CREATABLE_FIELDS is rejected rather than ignored: silently
|
|
118
|
+
* dropping a field an agent asked for would create an issue that is not the
|
|
119
|
+
* one it described.
|
|
120
|
+
*/
|
|
121
|
+
export function validateCreateInput(raw) {
|
|
122
|
+
const unknown = Object.keys(raw).filter((key) => raw[key] !== undefined && !CREATABLE_FIELDS.includes(key));
|
|
123
|
+
if (unknown.length > 0) {
|
|
124
|
+
throw new JamError("JAM_WRITE_FIELD_NOT_ALLOWED", `issue.create cannot set ${unknown.join(", ")}. Supported: ${CREATABLE_FIELDS.join(", ")}.`, { rejected: unknown, supported: [...CREATABLE_FIELDS] });
|
|
125
|
+
}
|
|
126
|
+
const issueType = requiredText(raw["issueType"], "issueType");
|
|
127
|
+
const summary = requiredText(raw["summary"], "summary");
|
|
128
|
+
const input = { issueType, summary };
|
|
129
|
+
if (raw["description"] !== undefined) {
|
|
130
|
+
// Plain text, never ADF. A caller-supplied document tree would mean
|
|
131
|
+
// panels, mentions and embeds arriving through a field that reads like
|
|
132
|
+
// prose - the same argument that keeps comment.add on plain text.
|
|
133
|
+
if (typeof raw["description"] !== "string") {
|
|
134
|
+
throw notAllowed("issue.create needs `input.description` to be plain text.");
|
|
135
|
+
}
|
|
136
|
+
// An all-whitespace description is not a description. Accepting one would
|
|
137
|
+
// mean promising to verify text that renders to nothing, which no read can
|
|
138
|
+
// confirm - so it is refused here rather than becoming an unverifiable
|
|
139
|
+
// create later.
|
|
140
|
+
if (canonicalizePlainText(raw["description"]).length === 0) {
|
|
141
|
+
throw notAllowed("issue.create needs `input.description` to be non-empty when it is set.");
|
|
142
|
+
}
|
|
143
|
+
input.description = raw["description"];
|
|
144
|
+
}
|
|
145
|
+
if (raw["priority"] !== undefined) {
|
|
146
|
+
input.priority = requiredText(raw["priority"], "priority");
|
|
147
|
+
}
|
|
148
|
+
if (raw["labels"] !== undefined) {
|
|
149
|
+
input.labels = stringArray(raw["labels"], "labels");
|
|
150
|
+
}
|
|
151
|
+
if (raw["components"] !== undefined) {
|
|
152
|
+
input.components = stringArray(raw["components"], "components");
|
|
153
|
+
}
|
|
154
|
+
return input;
|
|
155
|
+
}
|
|
156
|
+
function requiredText(value, field) {
|
|
157
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
158
|
+
throw notAllowed(`issue.create needs non-empty \`input.${field}\`.`);
|
|
159
|
+
}
|
|
160
|
+
return value.trim();
|
|
161
|
+
}
|
|
162
|
+
function stringArray(value, field) {
|
|
163
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
164
|
+
throw notAllowed(`issue.create needs \`input.${field}\` to be an array of strings.`);
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
function notAllowed(message) {
|
|
169
|
+
return new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", message, { operation: "issue.create" });
|
|
170
|
+
}
|
|
171
|
+
/** Whitelisted values to the shapes Jira's create API expects. */
|
|
172
|
+
function toJiraCreateFields(projectKey, issueTypeId, input, resolved) {
|
|
173
|
+
const fields = {
|
|
174
|
+
project: { key: projectKey },
|
|
175
|
+
issuetype: { id: issueTypeId },
|
|
176
|
+
summary: input.summary,
|
|
177
|
+
};
|
|
178
|
+
if (input.description !== undefined)
|
|
179
|
+
fields["description"] = textToAdf(input.description);
|
|
180
|
+
if (resolved.priority !== undefined)
|
|
181
|
+
fields["priority"] = { name: resolved.priority };
|
|
182
|
+
if (input.labels !== undefined)
|
|
183
|
+
fields["labels"] = input.labels;
|
|
184
|
+
if (resolved.components !== undefined) {
|
|
185
|
+
fields["components"] = resolved.components.map((name) => ({ name }));
|
|
186
|
+
}
|
|
187
|
+
return fields;
|
|
188
|
+
}
|
|
@@ -2,7 +2,8 @@ import type { JamDeps } from "../deps.js";
|
|
|
2
2
|
import type { FullIssueContext } from "../domain/context.js";
|
|
3
3
|
import type { WritePlan, WritePlanReceipt } from "../domain/write.js";
|
|
4
4
|
export type PlanWriteRequest = {
|
|
5
|
-
|
|
5
|
+
/** Absent for `issue.create`, which names a project rather than an issue. */
|
|
6
|
+
key?: string;
|
|
6
7
|
operation: string;
|
|
7
8
|
input: Record<string, unknown>;
|
|
8
9
|
};
|
|
@@ -26,7 +27,18 @@ export declare function planWrite(deps: JamDeps, request: PlanWriteRequest): Pro
|
|
|
26
27
|
/**
|
|
27
28
|
* The issue as Jira has it, read directly by key.
|
|
28
29
|
*
|
|
29
|
-
*
|
|
30
|
-
* that decides a write, and
|
|
30
|
+
* `getIssue`, not `getIssues`: ConsistencyPolicy requires a direct issue GET
|
|
31
|
+
* for anything that decides or confirms a write, and the bulk endpoint is not
|
|
32
|
+
* one. A JQL result can lag behind the issue it describes; a bulk fetch is
|
|
33
|
+
* free to answer from a different path than the single-issue endpoint. Neither
|
|
34
|
+
* difference matters for ordinary reads, and both matter here.
|
|
35
|
+
*
|
|
36
|
+
* The one read every write goes through - the pre-write conflict check, the
|
|
37
|
+
* post-write confirmation, and the post-create confirmation.
|
|
31
38
|
*/
|
|
32
|
-
export
|
|
39
|
+
export type IssueSnapshot = {
|
|
40
|
+
issue: FullIssueContext;
|
|
41
|
+
/** Identity of the current assignee, which `issue.assignee` cannot supply. */
|
|
42
|
+
assigneeAccountId?: string;
|
|
43
|
+
};
|
|
44
|
+
export declare function readIssue(deps: JamDeps, issueKey: string): Promise<IssueSnapshot>;
|