@jam-mcp/server 1.2.0 → 1.3.1
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 +21 -21
- package/README.md +90 -86
- 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-edit-metadata.adapter.d.ts +25 -0
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.js +84 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.js +20 -1
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +9 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.js +16 -0
- package/dist/application/apply-create-issue.js +1 -1
- package/dist/application/apply-write.js +86 -3
- package/dist/application/plan-write.d.ts +20 -2
- package/dist/application/plan-write.js +166 -18
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/bootstrap/setup-plan.d.ts +11 -0
- package/dist/bootstrap/setup-plan.js +10 -1
- package/dist/cli-entry.js +33 -33
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +35 -1
- package/dist/deps.d.ts +18 -0
- package/dist/deps.js +12 -0
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +14 -0
- package/dist/domain/write.d.ts +136 -3
- package/dist/domain/write.js +12 -0
- package/dist/index.js +0 -0
- package/dist/mcp/tools/jira-write-apply.tool.js +14 -14
- package/dist/mcp/tools/jira-write-plan.tool.js +40 -20
- package/dist/policy/assignee-policy.d.ts +60 -0
- package/dist/policy/assignee-policy.js +103 -0
- package/dist/policy/custom-field-policy.d.ts +93 -0
- package/dist/policy/custom-field-policy.js +230 -0
- 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-edit-metadata.port.d.ts +22 -0
- package/dist/ports/jira-edit-metadata.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +21 -0
- package/dist/ports/jira-write.port.d.ts +8 -0
- package/package.json +69 -69
|
@@ -1,8 +1,10 @@
|
|
|
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";
|
|
4
|
+
import { assertCustomFieldUnchanged } from "../policy/custom-field-policy.js";
|
|
3
5
|
import { assertUnchanged } from "../policy/write-policy.js";
|
|
4
6
|
import { applyCreateIssue } from "./apply-create-issue.js";
|
|
5
|
-
import { readIssue } from "./plan-write.js";
|
|
7
|
+
import { currentCustomFieldView, readIssue } from "./plan-write.js";
|
|
6
8
|
/**
|
|
7
9
|
* Execute a plan JAM made, then go and look at what happened.
|
|
8
10
|
*
|
|
@@ -35,7 +37,12 @@ export async function applyWritePlan(deps, request) {
|
|
|
35
37
|
if (plan.kind === "create-issue")
|
|
36
38
|
return applyCreateIssue(deps, plan);
|
|
37
39
|
const current = await readIssue(deps, plan.issueKey);
|
|
38
|
-
assertUnchanged(plan.issueKey, plan.baseUpdated, current.updated);
|
|
40
|
+
assertUnchanged(plan.issueKey, plan.baseUpdated, current.issue.updated);
|
|
41
|
+
// Whatever the plan depends on that the revision check cannot see, checked
|
|
42
|
+
// again here. For an assignment that is the target's permission to hold this
|
|
43
|
+
// issue: it can be revoked between planning and applying, and a plan that
|
|
44
|
+
// was valid is not the same as a plan that is still valid.
|
|
45
|
+
await revalidate(deps, plan);
|
|
39
46
|
const outcome = await mutate(deps, plan);
|
|
40
47
|
const after = await verify(deps, plan);
|
|
41
48
|
deps.writePlans.consume(plan.planId);
|
|
@@ -49,6 +56,26 @@ export async function applyWritePlan(deps, request) {
|
|
|
49
56
|
...(outcome.commentId ? { commentId: outcome.commentId } : {}),
|
|
50
57
|
};
|
|
51
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Re-derive the premises the revision check does not cover.
|
|
61
|
+
*
|
|
62
|
+
* Only `assignee.update` has any: the rest are fully described by the issue's
|
|
63
|
+
* own state, which `assertUnchanged` already compared.
|
|
64
|
+
*/
|
|
65
|
+
async function revalidate(deps, plan) {
|
|
66
|
+
if (plan.mutation.kind === "assignee") {
|
|
67
|
+
const target = plan.intendedAfter["assignee"];
|
|
68
|
+
assertAssignable(plan.issueKey, target, await deps.jiraAssignees.isAssignable(plan.issueKey, plan.mutation.accountId));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (plan.mutation.kind === "custom-field" && plan.customFieldRequirements) {
|
|
72
|
+
// A field can be taken off a screen, lose its `set` operation, change type
|
|
73
|
+
// or have an option renamed without the issue's own revision moving, so
|
|
74
|
+
// `assertUnchanged` cannot see any of it. These are the premises the plan
|
|
75
|
+
// actually rested on, re-derived.
|
|
76
|
+
assertCustomFieldUnchanged(plan.issueKey, plan.customFieldRequirements, await deps.jiraEditMetadata.getEditableFields(plan.issueKey));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
52
79
|
/**
|
|
53
80
|
* Send the mutation, once.
|
|
54
81
|
*
|
|
@@ -71,6 +98,17 @@ async function mutate(deps, plan) {
|
|
|
71
98
|
case "transition":
|
|
72
99
|
await deps.jiraWrite.transitionIssue(plan.issueKey, plan.mutation.transitionId);
|
|
73
100
|
return {};
|
|
101
|
+
case "assignee":
|
|
102
|
+
await deps.jiraWrite.assignIssue(plan.issueKey, plan.mutation.accountId);
|
|
103
|
+
return {};
|
|
104
|
+
case "custom-field":
|
|
105
|
+
// The ordinary issue edit endpoint. A custom field is a field; what
|
|
106
|
+
// made it need its own operation was deciding whether it may be
|
|
107
|
+
// written and in what shape, and that is already settled here.
|
|
108
|
+
await deps.jiraWrite.updateIssue(plan.issueKey, {
|
|
109
|
+
[plan.mutation.fieldId]: plan.mutation.value,
|
|
110
|
+
});
|
|
111
|
+
return {};
|
|
74
112
|
case "create":
|
|
75
113
|
// Unreachable: a create plan is routed to applyCreateIssue above. The
|
|
76
114
|
// case exists so adding a mutation kind is a compile error here rather
|
|
@@ -103,7 +141,22 @@ function isAmbiguous(err) {
|
|
|
103
141
|
* ours.
|
|
104
142
|
*/
|
|
105
143
|
async function verify(deps, plan) {
|
|
106
|
-
const
|
|
144
|
+
const snapshot = await readIssue(deps, plan.issueKey, plan.mutation.kind === "custom-field" ? [plan.mutation.fieldId] : []);
|
|
145
|
+
const issue = snapshot.issue;
|
|
146
|
+
if (plan.mutation.kind === "assignee") {
|
|
147
|
+
// On the accountId, never on the display name. Two people can share a
|
|
148
|
+
// name, so a name comparison would accept the wrong person's assignment as
|
|
149
|
+
// proof of the right one's - which is the entire reason resolution went to
|
|
150
|
+
// the trouble of producing an identity.
|
|
151
|
+
const expected = plan.intendedAfter["assignee"];
|
|
152
|
+
const observed = snapshot.assigneeAccountId
|
|
153
|
+
? { accountId: snapshot.assigneeAccountId, displayName: issue.assignee ?? "" }
|
|
154
|
+
: null;
|
|
155
|
+
if (snapshot.assigneeAccountId !== expected.accountId) {
|
|
156
|
+
throw verificationFailed(plan, { assignee: expected }, { assignee: observed });
|
|
157
|
+
}
|
|
158
|
+
return { assignee: { accountId: expected.accountId, displayName: issue.assignee ?? expected.displayName } };
|
|
159
|
+
}
|
|
107
160
|
if (plan.mutation.kind === "comment") {
|
|
108
161
|
// Direct issue GET again, not the bulk endpoint: this is post-write
|
|
109
162
|
// confirmation, and ConsistencyPolicy makes no exception for the read that
|
|
@@ -120,6 +173,15 @@ async function verify(deps, plan) {
|
|
|
120
173
|
}
|
|
121
174
|
return { comments: comments.length, commentAdded: wanted };
|
|
122
175
|
}
|
|
176
|
+
if (plan.mutation.kind === "custom-field" && plan.customFieldRequirements) {
|
|
177
|
+
const requirements = plan.customFieldRequirements;
|
|
178
|
+
const expected = plan.intendedAfter["customField"];
|
|
179
|
+
const observedValue = currentCustomFieldView({ id: requirements.fieldId, name: requirements.fieldName }, requirements.kind, snapshot.customFieldValues?.[requirements.fieldId]);
|
|
180
|
+
if (!sameCustomFieldValue(requirements.kind, expected.value, observedValue.value)) {
|
|
181
|
+
throw verificationFailed(plan, { customField: expected }, { customField: observedValue });
|
|
182
|
+
}
|
|
183
|
+
return { customField: observedValue };
|
|
184
|
+
}
|
|
123
185
|
const observed = observedFor(plan, issue);
|
|
124
186
|
for (const [field, expected] of Object.entries(plan.intendedAfter)) {
|
|
125
187
|
if (!sameValue(observed[field], expected)) {
|
|
@@ -153,6 +215,27 @@ function observedFor(plan, issue) {
|
|
|
153
215
|
}
|
|
154
216
|
return observed;
|
|
155
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Did the field end up holding what was planned?
|
|
220
|
+
*
|
|
221
|
+
* Options are compared on their ids, never on their labels - an option is
|
|
222
|
+
* identified by its id, and a label is what a person reads. For a multi-select
|
|
223
|
+
* the comparison is set-wise: Jira is free to return the same selection in a
|
|
224
|
+
* different order, and that is not a different selection.
|
|
225
|
+
*/
|
|
226
|
+
function sameCustomFieldValue(kind, expected, observed) {
|
|
227
|
+
if (kind === "multi-option") {
|
|
228
|
+
const ids = (v) => (Array.isArray(v) ? v.map((o) => o.id) : []).sort();
|
|
229
|
+
const a = ids(expected);
|
|
230
|
+
const b = ids(observed);
|
|
231
|
+
return a.length === b.length && a.every((id, i) => id === b[i]);
|
|
232
|
+
}
|
|
233
|
+
if (kind === "single-option") {
|
|
234
|
+
const id = (v) => v && typeof v === "object" && !Array.isArray(v) ? v.id : undefined;
|
|
235
|
+
return id(expected) === id(observed);
|
|
236
|
+
}
|
|
237
|
+
return expected === observed;
|
|
238
|
+
}
|
|
156
239
|
function sameValue(observed, expected) {
|
|
157
240
|
if (Array.isArray(expected) || Array.isArray(observed)) {
|
|
158
241
|
const a = Array.isArray(observed) ? [...observed].map(String).sort() : [];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { JamDeps } from "../deps.js";
|
|
2
2
|
import type { FullIssueContext } from "../domain/context.js";
|
|
3
|
-
import type { WritePlan, WritePlanReceipt } from "../domain/write.js";
|
|
3
|
+
import type { CustomFieldKind, CustomFieldValueView, 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
6
|
key?: string;
|
|
@@ -36,4 +36,22 @@ export declare function planWrite(deps: JamDeps, request: PlanWriteRequest): Pro
|
|
|
36
36
|
* The one read every write goes through - the pre-write conflict check, the
|
|
37
37
|
* post-write confirmation, and the post-create confirmation.
|
|
38
38
|
*/
|
|
39
|
-
export
|
|
39
|
+
export type IssueSnapshot = {
|
|
40
|
+
issue: FullIssueContext;
|
|
41
|
+
/** Identity of the current assignee, which `issue.assignee` cannot supply. */
|
|
42
|
+
assigneeAccountId?: string;
|
|
43
|
+
/** Raw values for any custom field ids that were asked for. */
|
|
44
|
+
customFieldValues?: Record<string, unknown>;
|
|
45
|
+
};
|
|
46
|
+
export declare function readIssue(deps: JamDeps, issueKey: string, extraFields?: string[]): Promise<IssueSnapshot>;
|
|
47
|
+
/**
|
|
48
|
+
* What the field holds now, in the shape a receipt shows.
|
|
49
|
+
*
|
|
50
|
+
* Jira stores an option as an object and a scalar as itself; a person reading
|
|
51
|
+
* `before` wants the same canonical form they will see in `intendedAfter`, so
|
|
52
|
+
* they can compare the two rather than a payload against a summary.
|
|
53
|
+
*/
|
|
54
|
+
export declare function currentCustomFieldView(field: {
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
}, kind: CustomFieldKind, raw: unknown): CustomFieldValueView;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { JamError } from "../domain/errors.js";
|
|
2
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 { assertEditable, classifyKind, resolveCustomFieldValue, resolveWritableField, } from "../policy/custom-field-policy.js";
|
|
3
5
|
import { planCreateIssue } from "./plan-create-issue.js";
|
|
4
6
|
/**
|
|
5
7
|
* Work out whether a requested change is currently possible, and describe it.
|
|
@@ -30,8 +32,15 @@ export async function planWrite(deps, request) {
|
|
|
30
32
|
// does not write should get that answer, not a round trip and then that
|
|
31
33
|
// answer.
|
|
32
34
|
const input = validateInput(operation, request.input);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
// Which custom field the whitelist says this is, settled before the read so
|
|
36
|
+
// the read can fetch its current value in the same request. A selector the
|
|
37
|
+
// team never opted in costs no Jira call at all.
|
|
38
|
+
const targetField = operation === "custom-field.update"
|
|
39
|
+
? resolveWritableField(deps.config, input.field)
|
|
40
|
+
: undefined;
|
|
41
|
+
const snapshot = await readIssue(deps, issueKey, targetField ? [targetField.id] : []);
|
|
42
|
+
const issue = snapshot.issue;
|
|
43
|
+
const { before, intendedAfter, mutation, transition, baseAssigneeAccountId, customFieldRequirements } = await describe(deps, operation, issueKey, snapshot, input, targetField);
|
|
35
44
|
const createdAt = new Date();
|
|
36
45
|
const plan = deps.writePlans.create({
|
|
37
46
|
kind: "existing-issue",
|
|
@@ -44,6 +53,8 @@ export async function planWrite(deps, request) {
|
|
|
44
53
|
createdAt: createdAt.toISOString(),
|
|
45
54
|
expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
|
|
46
55
|
...(transition ? { transition } : {}),
|
|
56
|
+
...(baseAssigneeAccountId ? { baseAssigneeAccountId } : {}),
|
|
57
|
+
...(customFieldRequirements ? { customFieldRequirements } : {}),
|
|
47
58
|
mutation,
|
|
48
59
|
});
|
|
49
60
|
return {
|
|
@@ -60,20 +71,8 @@ export async function planWrite(deps, request) {
|
|
|
60
71
|
},
|
|
61
72
|
};
|
|
62
73
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
*
|
|
66
|
-
* `getIssue`, not `getIssues`: ConsistencyPolicy requires a direct issue GET
|
|
67
|
-
* for anything that decides or confirms a write, and the bulk endpoint is not
|
|
68
|
-
* one. A JQL result can lag behind the issue it describes; a bulk fetch is
|
|
69
|
-
* free to answer from a different path than the single-issue endpoint. Neither
|
|
70
|
-
* difference matters for ordinary reads, and both matter here.
|
|
71
|
-
*
|
|
72
|
-
* The one read every write goes through - the pre-write conflict check, the
|
|
73
|
-
* post-write confirmation, and the post-create confirmation.
|
|
74
|
-
*/
|
|
75
|
-
export async function readIssue(deps, issueKey) {
|
|
76
|
-
const { issue: found } = await deps.jira.getIssue({
|
|
74
|
+
export async function readIssue(deps, issueKey, extraFields = []) {
|
|
75
|
+
const { issue: found, assigneeAccountId, customFieldValues } = await deps.jira.getIssue({
|
|
77
76
|
key: issueKey,
|
|
78
77
|
// `issuetype` and `description` are here for creation's verification step,
|
|
79
78
|
// which has to confirm the issue Jira made is the one that was asked for.
|
|
@@ -85,17 +84,38 @@ export async function readIssue(deps, issueKey) {
|
|
|
85
84
|
"status",
|
|
86
85
|
"issuetype",
|
|
87
86
|
"description",
|
|
87
|
+
"assignee",
|
|
88
88
|
"priority",
|
|
89
89
|
"labels",
|
|
90
90
|
"components",
|
|
91
91
|
"updated",
|
|
92
|
+
// A custom field is only read when one is being written, and then only
|
|
93
|
+
// that one - so a custom-field update still costs a single direct GET
|
|
94
|
+
// rather than a second read for the field it is about to change.
|
|
95
|
+
...extraFields.filter((f) => !BASE_WRITE_FIELDS.has(f)),
|
|
92
96
|
],
|
|
93
97
|
});
|
|
94
98
|
if (!found) {
|
|
95
99
|
throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
|
|
96
100
|
}
|
|
97
|
-
return
|
|
101
|
+
return {
|
|
102
|
+
issue: found,
|
|
103
|
+
...(assigneeAccountId ? { assigneeAccountId } : {}),
|
|
104
|
+
...(customFieldValues ? { customFieldValues } : {}),
|
|
105
|
+
};
|
|
98
106
|
}
|
|
107
|
+
/** Requested on every write-plane read, so an extra field is never a duplicate. */
|
|
108
|
+
const BASE_WRITE_FIELDS = new Set([
|
|
109
|
+
"summary",
|
|
110
|
+
"status",
|
|
111
|
+
"issuetype",
|
|
112
|
+
"description",
|
|
113
|
+
"assignee",
|
|
114
|
+
"priority",
|
|
115
|
+
"labels",
|
|
116
|
+
"components",
|
|
117
|
+
"updated",
|
|
118
|
+
]);
|
|
99
119
|
/**
|
|
100
120
|
* The issue an existing-issue operation names, or a refusal that says why.
|
|
101
121
|
*
|
|
@@ -136,9 +156,41 @@ function validateInput(operation, raw) {
|
|
|
136
156
|
}
|
|
137
157
|
return { status: status.trim() };
|
|
138
158
|
}
|
|
159
|
+
case "custom-field.update": {
|
|
160
|
+
// Anything outside this operation's own two keys is refused rather than
|
|
161
|
+
// ignored. Silently dropping a key an agent supplied is how a caller
|
|
162
|
+
// ends up with a write that is not the one it described - and the shared
|
|
163
|
+
// input object means another operation's key is a plausible mistake.
|
|
164
|
+
const extra = Object.keys(raw).filter((k) => raw[k] !== undefined && k !== "field" && k !== "value");
|
|
165
|
+
if (extra.length > 0) {
|
|
166
|
+
throw new JamError("JAM_WRITE_FIELD_NOT_ALLOWED", `custom-field.update takes only \`field\` and \`value\`. Remove: ${extra.join(", ")}.`, { operation, rejected: extra });
|
|
167
|
+
}
|
|
168
|
+
const { field, value } = raw;
|
|
169
|
+
if (typeof field !== "string" || field.trim().length === 0) {
|
|
170
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "custom-field.update needs non-empty `input.field` - a configured custom field id or name.", { operation });
|
|
171
|
+
}
|
|
172
|
+
// The value's family is checked against Jira's schema later; what is
|
|
173
|
+
// checked here is that it is a shape the contract admits at all. An
|
|
174
|
+
// object, a boolean or a null never reaches the type policy.
|
|
175
|
+
const isString = typeof value === "string";
|
|
176
|
+
const isNumber = typeof value === "number";
|
|
177
|
+
const isStringArray = Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
178
|
+
if (!isString && !isNumber && !isStringArray) {
|
|
179
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "custom-field.update needs `input.value` to be a string, a number, or an array of strings.", { operation, received: Array.isArray(value) ? "array" : typeof value });
|
|
180
|
+
}
|
|
181
|
+
return { field: field.trim(), value };
|
|
182
|
+
}
|
|
183
|
+
case "assignee.update": {
|
|
184
|
+
const assignee = raw.assignee;
|
|
185
|
+
if (typeof assignee !== "string" || assignee.trim().length === 0) {
|
|
186
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "assignee.update needs non-empty `input.assignee` - a display name, or an accountId.", { operation });
|
|
187
|
+
}
|
|
188
|
+
return { assignee: assignee.trim() };
|
|
189
|
+
}
|
|
139
190
|
}
|
|
140
191
|
}
|
|
141
|
-
async function describe(deps, operation, issueKey,
|
|
192
|
+
async function describe(deps, operation, issueKey, snapshot, input, targetField) {
|
|
193
|
+
const issue = snapshot.issue;
|
|
142
194
|
switch (operation) {
|
|
143
195
|
case "comment.add": {
|
|
144
196
|
const { text } = input;
|
|
@@ -178,8 +230,79 @@ async function describe(deps, operation, issueKey, issue, input) {
|
|
|
178
230
|
transition,
|
|
179
231
|
};
|
|
180
232
|
}
|
|
233
|
+
case "custom-field.update": {
|
|
234
|
+
const field = targetField;
|
|
235
|
+
const requested = input;
|
|
236
|
+
// Jira decides what is editable here and now, and in what shape. JAM
|
|
237
|
+
// does not model project contexts, screens or permissions - it asks the
|
|
238
|
+
// one endpoint that answers all three at once for this issue.
|
|
239
|
+
const metadata = await deps.jiraEditMetadata.getEditableFields(issueKey);
|
|
240
|
+
const editable = assertEditable(issueKey, field, metadata);
|
|
241
|
+
const kind = classifyKind(editable);
|
|
242
|
+
const { jiraValue, view, resolvedOptions } = resolveCustomFieldValue(editable, kind, requested);
|
|
243
|
+
return {
|
|
244
|
+
before: {
|
|
245
|
+
customField: currentCustomFieldView(field, kind, snapshot.customFieldValues?.[field.id]),
|
|
246
|
+
},
|
|
247
|
+
intendedAfter: { customField: view },
|
|
248
|
+
mutation: { kind: "custom-field", fieldId: field.id, value: jiraValue },
|
|
249
|
+
customFieldRequirements: {
|
|
250
|
+
fieldId: field.id,
|
|
251
|
+
fieldName: field.name,
|
|
252
|
+
kind,
|
|
253
|
+
schema: editable.schema,
|
|
254
|
+
...(resolvedOptions ? { resolvedOptions } : {}),
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
case "assignee.update": {
|
|
259
|
+
const { assignee: requested } = input;
|
|
260
|
+
// Ask Jira who this is, and decide from what it says. The requested
|
|
261
|
+
// string never reaches a mutation: what gets written is the accountId
|
|
262
|
+
// that resolution settled on, and resolution refuses rather than picks
|
|
263
|
+
// when the answer is not one person.
|
|
264
|
+
const target = resolveAssignee(requested, await findCandidates(deps, requested));
|
|
265
|
+
// Two independent refusals, in the order that costs least. Already-set
|
|
266
|
+
// needs no Jira call; assignability does.
|
|
267
|
+
assertNotAlreadyAssigned(issueKey, snapshot.assigneeAccountId, target);
|
|
268
|
+
assertAssignable(issueKey, target, await deps.jiraAssignees.isAssignable(issueKey, target.accountId));
|
|
269
|
+
return {
|
|
270
|
+
before: {
|
|
271
|
+
assignee: snapshot.assigneeAccountId
|
|
272
|
+
? { accountId: snapshot.assigneeAccountId, displayName: issue.assignee ?? "" }
|
|
273
|
+
: null,
|
|
274
|
+
},
|
|
275
|
+
intendedAfter: { assignee: target },
|
|
276
|
+
mutation: { kind: "assignee", accountId: target.accountId },
|
|
277
|
+
...(snapshot.assigneeAccountId
|
|
278
|
+
? { baseAssigneeAccountId: snapshot.assigneeAccountId }
|
|
279
|
+
: {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
181
282
|
}
|
|
182
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Who Jira thinks this string could be.
|
|
286
|
+
*
|
|
287
|
+
* The search first, because it answers both halves of the contract most of the
|
|
288
|
+
* time - Jira's user search currently matches an accountId as readily as a
|
|
289
|
+
* name. "Currently" is the problem: that is a property of a substring search
|
|
290
|
+
* rather than a promise, and the contract says an accountId identifies a
|
|
291
|
+
* person. So when the search settles nothing, the exact lookup is asked before
|
|
292
|
+
* giving up.
|
|
293
|
+
*
|
|
294
|
+
* Ordered this way because it costs nothing on the paths that work. The extra
|
|
295
|
+
* request happens only where resolution was about to fail anyway, and the
|
|
296
|
+
* string is never inspected to guess whether it looks like an accountId - Jira
|
|
297
|
+
* is asked, and Jira answers.
|
|
298
|
+
*/
|
|
299
|
+
async function findCandidates(deps, requested) {
|
|
300
|
+
const candidates = await deps.jiraAssignees.searchUsers(requested);
|
|
301
|
+
if (exactMatches(requested, candidates).length > 0)
|
|
302
|
+
return candidates;
|
|
303
|
+
const byId = await deps.jiraAssignees.getUserByAccountId(requested);
|
|
304
|
+
return byId ? [byId] : candidates;
|
|
305
|
+
}
|
|
183
306
|
function currentValue(issue, field) {
|
|
184
307
|
switch (field) {
|
|
185
308
|
case "summary":
|
|
@@ -194,6 +317,31 @@ function currentValue(issue, field) {
|
|
|
194
317
|
return undefined;
|
|
195
318
|
}
|
|
196
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* What the field holds now, in the shape a receipt shows.
|
|
322
|
+
*
|
|
323
|
+
* Jira stores an option as an object and a scalar as itself; a person reading
|
|
324
|
+
* `before` wants the same canonical form they will see in `intendedAfter`, so
|
|
325
|
+
* they can compare the two rather than a payload against a summary.
|
|
326
|
+
*/
|
|
327
|
+
export function currentCustomFieldView(field, kind, raw) {
|
|
328
|
+
const named = { id: field.id, name: field.name };
|
|
329
|
+
if (kind === "multi-option") {
|
|
330
|
+
return { ...named, value: Array.isArray(raw) ? raw.map(toOptionView) : [] };
|
|
331
|
+
}
|
|
332
|
+
if (kind === "single-option") {
|
|
333
|
+
return { ...named, value: raw == null ? null : toOptionView(raw) };
|
|
334
|
+
}
|
|
335
|
+
if (raw == null)
|
|
336
|
+
return { ...named, value: null };
|
|
337
|
+
return { ...named, value: kind === "number" ? Number(raw) : String(raw) };
|
|
338
|
+
}
|
|
339
|
+
function toOptionView(raw) {
|
|
340
|
+
const o = raw;
|
|
341
|
+
const id = typeof o?.id === "string" ? o.id : typeof o?.id === "number" ? String(o.id) : "";
|
|
342
|
+
const label = typeof o?.value === "string" ? o.value : typeof o?.name === "string" ? o.name : String(raw);
|
|
343
|
+
return { id, label };
|
|
344
|
+
}
|
|
197
345
|
/** Whitelisted values to the shapes Jira's field API expects. */
|
|
198
346
|
function toJiraFields(input) {
|
|
199
347
|
const fields = {};
|
|
@@ -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.1", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Recognise wiring from before the launcher existed: a hard-coded path to one
|
|
@@ -64,10 +64,21 @@ export type SetupPlan = {
|
|
|
64
64
|
* configured - so it is an `npx` bootstrap invocation, never a bare `jam`.
|
|
65
65
|
* A human interface is free to render the short form; this field is the one
|
|
66
66
|
* a script runs, and a script has no PATH to rely on.
|
|
67
|
+
*
|
|
68
|
+
* `userCommand` is the opposite: a command for the person, which the agent
|
|
69
|
+
* relays and never runs. Authentication is the only step of that shape, and
|
|
70
|
+
* it carries no `command` precisely so that no caller can execute it. The
|
|
71
|
+
* separation is the point - one field is for running, the other for showing.
|
|
72
|
+
*
|
|
73
|
+
* `env` names the variables that would satisfy the same requirement without
|
|
74
|
+
* the interactive command, so an agent that cannot show a prompt still knows
|
|
75
|
+
* what the person has to provide - never their values.
|
|
67
76
|
*/
|
|
68
77
|
nextAction?: {
|
|
69
78
|
type: "authenticate" | "select_project" | "configure_runtime";
|
|
70
79
|
command?: string;
|
|
80
|
+
userCommand?: string;
|
|
81
|
+
env?: string[];
|
|
71
82
|
};
|
|
72
83
|
project?: {
|
|
73
84
|
root: string;
|
|
@@ -113,7 +113,16 @@ function finish(changes, state, project) {
|
|
|
113
113
|
code: "JAM_AUTH_REQUIRED",
|
|
114
114
|
changes,
|
|
115
115
|
requiresUserAction: true,
|
|
116
|
-
nextAction: {
|
|
116
|
+
nextAction: {
|
|
117
|
+
type: "authenticate",
|
|
118
|
+
// Deliberately no `command`: an agent must not run the login, and the
|
|
119
|
+
// absence is what stops it. `userCommand` is what it hands the person
|
|
120
|
+
// instead - previously that instruction existed only in CLI prose, so
|
|
121
|
+
// an agent reading the JSON alone knew a human was needed but not for
|
|
122
|
+
// what.
|
|
123
|
+
userCommand: portableBootstrapCommand("auth login"),
|
|
124
|
+
env: ["JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"],
|
|
125
|
+
},
|
|
117
126
|
project,
|
|
118
127
|
};
|
|
119
128
|
}
|
package/dist/cli-entry.js
CHANGED
|
@@ -11,39 +11,39 @@ import { authStatusCommand, doctorJsonCommand, setupAgentCommand, setupApplyComm
|
|
|
11
11
|
* points (notably @jam-mcp/bootstrap) can forward to exactly these commands
|
|
12
12
|
* instead of reimplementing them.
|
|
13
13
|
*/
|
|
14
|
-
export const USAGE = `jam - Jira Agent MCP
|
|
15
|
-
|
|
16
|
-
Usage:
|
|
17
|
-
jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)
|
|
18
|
-
jam doctor Diagnose config, credentials and Jira connectivity
|
|
19
|
-
jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]
|
|
20
|
-
Wire up this project and run doctor. Binds it to you
|
|
21
|
-
alone, writing nothing to the repository; --shared
|
|
22
|
-
adopts JAM for the team (project.yaml, .mcp.json)
|
|
23
|
-
jam runtime Show which JAM build this machine runs
|
|
24
|
-
jam runtime use package | development <path>
|
|
25
|
-
Change it (writes ~/.jam/config.yaml only, never a project)
|
|
26
|
-
jam auth login Store Jira credentials in this user's OS secret store
|
|
27
|
-
jam auth logout Remove them again
|
|
28
|
-
|
|
29
|
-
For coding agents and scripts (stdout is JSON only, never prompts):
|
|
30
|
-
jam setup --agent One shot: detect, plan, apply what is safe, verify
|
|
31
|
-
jam setup plan --json Report what setup would change, changing nothing
|
|
32
|
-
jam setup apply --non-interactive --json
|
|
33
|
-
Execute the plan
|
|
34
|
-
jam doctor --json Health check as structured output
|
|
35
|
-
jam auth status --json Whether Jira credentials are configured (never their value)
|
|
36
|
-
|
|
37
|
-
Environment:
|
|
38
|
-
JIRA_BASE_URL https://your-site.atlassian.net
|
|
39
|
-
JIRA_EMAIL Atlassian account email
|
|
40
|
-
JIRA_API_TOKEN Atlassian API token
|
|
41
|
-
JAM_PROJECT_KEY Jira project key, used by \`jam setup\`/\`jam serve\` when no
|
|
42
|
-
.jira-agent/project.yaml exists yet
|
|
43
|
-
|
|
44
|
-
Credentials and JAM_PROJECT_KEY are read from the current shell's environment
|
|
45
|
-
first, then (on Windows) from the User environment - so a value set with
|
|
46
|
-
\`setx\` works without opening a new terminal.
|
|
14
|
+
export const USAGE = `jam - Jira Agent MCP
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)
|
|
18
|
+
jam doctor Diagnose config, credentials and Jira connectivity
|
|
19
|
+
jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]
|
|
20
|
+
Wire up this project and run doctor. Binds it to you
|
|
21
|
+
alone, writing nothing to the repository; --shared
|
|
22
|
+
adopts JAM for the team (project.yaml, .mcp.json)
|
|
23
|
+
jam runtime Show which JAM build this machine runs
|
|
24
|
+
jam runtime use package | development <path>
|
|
25
|
+
Change it (writes ~/.jam/config.yaml only, never a project)
|
|
26
|
+
jam auth login Store Jira credentials in this user's OS secret store
|
|
27
|
+
jam auth logout Remove them again
|
|
28
|
+
|
|
29
|
+
For coding agents and scripts (stdout is JSON only, never prompts):
|
|
30
|
+
jam setup --agent One shot: detect, plan, apply what is safe, verify
|
|
31
|
+
jam setup plan --json Report what setup would change, changing nothing
|
|
32
|
+
jam setup apply --non-interactive --json
|
|
33
|
+
Execute the plan
|
|
34
|
+
jam doctor --json Health check as structured output
|
|
35
|
+
jam auth status --json Whether Jira credentials are configured (never their value)
|
|
36
|
+
|
|
37
|
+
Environment:
|
|
38
|
+
JIRA_BASE_URL https://your-site.atlassian.net
|
|
39
|
+
JIRA_EMAIL Atlassian account email
|
|
40
|
+
JIRA_API_TOKEN Atlassian API token
|
|
41
|
+
JAM_PROJECT_KEY Jira project key, used by \`jam setup\`/\`jam serve\` when no
|
|
42
|
+
.jira-agent/project.yaml exists yet
|
|
43
|
+
|
|
44
|
+
Credentials and JAM_PROJECT_KEY are read from the current shell's environment
|
|
45
|
+
first, then (on Windows) from the User environment - so a value set with
|
|
46
|
+
\`setx\` works without opening a new terminal.
|
|
47
47
|
`;
|
|
48
48
|
function findFlagValue(argv, flag) {
|
|
49
49
|
const index = argv.indexOf(flag);
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export declare const ProjectConfigSchema: z.ZodObject<{
|
|
|
19
19
|
customFields: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
20
20
|
id: z.ZodString;
|
|
21
21
|
name: z.ZodString;
|
|
22
|
+
writable: z.ZodDefault<z.ZodBoolean>;
|
|
22
23
|
}, z.core.$strip>>>;
|
|
23
24
|
output: z.ZodPrefault<z.ZodObject<{
|
|
24
25
|
searchTokens: z.ZodDefault<z.ZodNumber>;
|
package/dist/config/schema.js
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/** Values that appear more than once, each named once. */
|
|
3
|
+
function duplicates(values) {
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
const repeated = new Set();
|
|
6
|
+
for (const value of values) {
|
|
7
|
+
if (seen.has(value))
|
|
8
|
+
repeated.add(value);
|
|
9
|
+
seen.add(value);
|
|
10
|
+
}
|
|
11
|
+
return [...repeated];
|
|
12
|
+
}
|
|
13
|
+
function report(ctx, repeated, what) {
|
|
14
|
+
for (const value of repeated) {
|
|
15
|
+
ctx.addIssue({
|
|
16
|
+
code: "custom",
|
|
17
|
+
message: `duplicate custom field ${what} "${value}" - a selector must name one field`,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
2
21
|
/**
|
|
3
22
|
* `.jira-agent/project.yaml` - per-project policy only.
|
|
4
23
|
* Credentials are never stored here; they come from the CredentialPort.
|
|
@@ -41,13 +60,28 @@ export const ProjectConfigSchema = z.object({
|
|
|
41
60
|
/**
|
|
42
61
|
* Whitelisted project-specific custom fields, surfaced at CONTEXT level and up.
|
|
43
62
|
* `id` is the Jira field id (customfield_10011); `name` is what the agent sees.
|
|
63
|
+
*
|
|
64
|
+
* `writable` is a second, separate consent. Reading a field and letting an
|
|
65
|
+
* agent change it are different decisions, and a config written when JAM
|
|
66
|
+
* could only read must not start granting writes because JAM learned how.
|
|
67
|
+
* So it defaults to false: every whitelist that predates this is read-only,
|
|
68
|
+
* and a team opts a field in by saying so.
|
|
44
69
|
*/
|
|
45
70
|
customFields: z
|
|
46
71
|
.array(z.object({
|
|
47
72
|
id: z.string().regex(/^customfield_\d+$/),
|
|
48
73
|
name: z.string().min(1),
|
|
74
|
+
writable: z.boolean().default(false),
|
|
49
75
|
}))
|
|
50
|
-
.default([])
|
|
76
|
+
.default([])
|
|
77
|
+
.superRefine((fields, ctx) => {
|
|
78
|
+
// Ambiguity in a whitelist is worse than an omission: `custom-field.update`
|
|
79
|
+
// resolves a selector against these entries, and two rows answering to
|
|
80
|
+
// the same selector would make which field gets written a matter of
|
|
81
|
+
// ordering.
|
|
82
|
+
report(ctx, duplicates(fields.map((f) => f.id.toLowerCase())), "id");
|
|
83
|
+
report(ctx, duplicates(fields.filter((f) => f.writable).map((f) => f.name.trim().toLowerCase())), "writable name");
|
|
84
|
+
}),
|
|
51
85
|
output: z
|
|
52
86
|
.object({
|
|
53
87
|
/** Rough token ceilings per level. Enforced by OutputBudgetPolicy. */
|
package/dist/deps.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ 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";
|
|
7
8
|
import type { JiraCreateMetadataPort } from "./ports/jira-create-metadata.port.js";
|
|
9
|
+
import type { JiraEditMetadataPort } from "./ports/jira-edit-metadata.port.js";
|
|
8
10
|
import type { JiraWritePort } from "./ports/jira-write.port.js";
|
|
9
11
|
import { WritePlanStore } from "./application/write-plan-store.js";
|
|
10
12
|
import type { TelemetryPort } from "./ports/telemetry.port.js";
|
|
@@ -29,6 +31,18 @@ export type JamDeps = {
|
|
|
29
31
|
* completeness semantics would mean nothing for it.
|
|
30
32
|
*/
|
|
31
33
|
jiraCreateMetadata: JiraCreateMetadataPort;
|
|
34
|
+
/**
|
|
35
|
+
* Who a name refers to, and who may hold an issue. A fourth port for the
|
|
36
|
+
* same reason as the third: it reads a directory rather than an issue, and
|
|
37
|
+
* it mutates nothing.
|
|
38
|
+
*/
|
|
39
|
+
jiraAssignees: JiraAssigneeResolutionPort;
|
|
40
|
+
/**
|
|
41
|
+
* What Jira will let this account change on one issue. A fifth read-shaped
|
|
42
|
+
* port, for the same reason as the third and fourth: it mutates nothing, and
|
|
43
|
+
* it answers a question about a configuration rather than about an issue.
|
|
44
|
+
*/
|
|
45
|
+
jiraEditMetadata: JiraEditMetadataPort;
|
|
32
46
|
/**
|
|
33
47
|
* Plans awaiting apply. Lives for the life of this server process - see
|
|
34
48
|
* WritePlanStore for why it is not persisted.
|
|
@@ -46,6 +60,10 @@ export type BuildDepsOptions = {
|
|
|
46
60
|
jiraWrite?: JiraWritePort;
|
|
47
61
|
/** Injected by tests so create metadata comes from a fixture, not a site. */
|
|
48
62
|
jiraCreateMetadata?: JiraCreateMetadataPort;
|
|
63
|
+
/** Injected by tests so user resolution never reaches a real directory. */
|
|
64
|
+
jiraAssignees?: JiraAssigneeResolutionPort;
|
|
65
|
+
/** Injected by tests so edit metadata comes from a fixture, not a site. */
|
|
66
|
+
jiraEditMetadata?: JiraEditMetadataPort;
|
|
49
67
|
/** Injected by tests to bypass the real process/registry credential lookup. */
|
|
50
68
|
credentials?: CredentialPort;
|
|
51
69
|
/**
|