@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
package/dist/domain/write.d.ts
CHANGED
|
@@ -13,8 +13,17 @@
|
|
|
13
13
|
* change happen, and did we see it happen". Mixing them would let a confident
|
|
14
14
|
* `complete: true` stand in for a verified mutation.
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* Operations that change an issue that already exists.
|
|
18
|
+
*
|
|
19
|
+
* Kept apart from creation because the two have different shapes at every
|
|
20
|
+
* layer: these name an issue, creation names a project; these compare a
|
|
21
|
+
* revision to detect a conflict, creation has no revision to compare.
|
|
22
|
+
*/
|
|
23
|
+
export declare const EXISTING_ISSUE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "assignee.update"];
|
|
16
24
|
/** The operations the public MCP surface accepts. Nothing else is reachable. */
|
|
17
|
-
export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition"];
|
|
25
|
+
export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "assignee.update", "issue.create"];
|
|
26
|
+
export type ExistingIssueOperation = (typeof EXISTING_ISSUE_OPERATIONS)[number];
|
|
18
27
|
export type WriteOperation = (typeof WRITE_OPERATIONS)[number];
|
|
19
28
|
/**
|
|
20
29
|
* Fields `field.update` may touch.
|
|
@@ -39,7 +48,101 @@ export type FieldUpdateInput = {
|
|
|
39
48
|
export type StatusTransitionInput = {
|
|
40
49
|
status: string;
|
|
41
50
|
};
|
|
42
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Who to assign an issue to, as a person would say it.
|
|
53
|
+
*
|
|
54
|
+
* A display name, or an accountId if the caller already has one. Either way it
|
|
55
|
+
* is a selector, not an identifier: nothing here is ever sent to Jira. It is
|
|
56
|
+
* resolved against Jira's own user directory first, and what gets written is
|
|
57
|
+
* the accountId that resolution produced.
|
|
58
|
+
*/
|
|
59
|
+
export type AssigneeUpdateInput = {
|
|
60
|
+
assignee: string;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* A Jira user as JAM identifies them.
|
|
64
|
+
*
|
|
65
|
+
* `accountId` is the identity; `displayName` is for the human reading the
|
|
66
|
+
* receipt. They are not interchangeable - two people can share a display name,
|
|
67
|
+
* which is precisely why an assignment is verified on the accountId.
|
|
68
|
+
*/
|
|
69
|
+
export type AssigneeRef = {
|
|
70
|
+
accountId: string;
|
|
71
|
+
displayName: string;
|
|
72
|
+
};
|
|
73
|
+
/** A user Jira offered in answer to a search. */
|
|
74
|
+
export type AssigneeCandidate = AssigneeRef & {
|
|
75
|
+
active: boolean;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Fields `issue.create` may set.
|
|
79
|
+
*
|
|
80
|
+
* The same argument as WRITABLE_FIELDS, and the same answer: a closed list, so
|
|
81
|
+
* "what can an agent create" has an answer that does not depend on one
|
|
82
|
+
* project's screen configuration. `issueType` and `summary` are required by
|
|
83
|
+
* every Jira project JAM can serve; the rest are optional and only sent when
|
|
84
|
+
* asked for.
|
|
85
|
+
*/
|
|
86
|
+
export declare const CREATABLE_FIELDS: readonly ["issueType", "summary", "description", "priority", "labels", "components"];
|
|
87
|
+
export type CreatableField = (typeof CREATABLE_FIELDS)[number];
|
|
88
|
+
export type CreateIssueInput = {
|
|
89
|
+
issueType: string;
|
|
90
|
+
summary: string;
|
|
91
|
+
description?: string;
|
|
92
|
+
priority?: string;
|
|
93
|
+
labels?: string[];
|
|
94
|
+
components?: string[];
|
|
95
|
+
};
|
|
96
|
+
export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput | AssigneeUpdateInput | CreateIssueInput;
|
|
97
|
+
/** An issue type as Jira offers it for one project, right now. */
|
|
98
|
+
export type CreateIssueType = {
|
|
99
|
+
id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
subtask: boolean;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* One field on a project's create screen, as Jira describes it.
|
|
105
|
+
*
|
|
106
|
+
* `allowedValues` is present only for fields Jira constrains (priority,
|
|
107
|
+
* components, and issue-type-scoped pickers). Absent means unconstrained, not
|
|
108
|
+
* empty - the difference decides whether a value can be resolved or must be
|
|
109
|
+
* refused.
|
|
110
|
+
*/
|
|
111
|
+
export type CreateFieldMetadata = {
|
|
112
|
+
/** Jira's field id, e.g. `summary` or `customfield_12345`. */
|
|
113
|
+
id: string;
|
|
114
|
+
name: string;
|
|
115
|
+
required: boolean;
|
|
116
|
+
hasDefaultValue: boolean;
|
|
117
|
+
allowedValues?: {
|
|
118
|
+
id?: string;
|
|
119
|
+
name?: string;
|
|
120
|
+
}[];
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* What a create plan depends on, recorded so apply can check it again.
|
|
124
|
+
*
|
|
125
|
+
* Not a hash of the metadata document: an unrelated optional field appearing
|
|
126
|
+
* on the create screen does not invalidate a plan, and treating it as though
|
|
127
|
+
* it did would make every plan fail on a busy project. What is recorded here
|
|
128
|
+
* is the set of premises the plan was built on, and apply re-derives whether
|
|
129
|
+
* each still holds.
|
|
130
|
+
*/
|
|
131
|
+
export type CreateSchemaRequirements = {
|
|
132
|
+
issueTypeId: string;
|
|
133
|
+
issueTypeName: string;
|
|
134
|
+
/** Required field ids JAM undertook to supply or knew Jira would default. */
|
|
135
|
+
requiredFieldIds: string[];
|
|
136
|
+
/**
|
|
137
|
+
* Values resolved from Jira's allowed lists at plan time, by field id. Apply
|
|
138
|
+
* refuses if any of them is no longer offered.
|
|
139
|
+
*/
|
|
140
|
+
resolvedValues: {
|
|
141
|
+
fieldId: string;
|
|
142
|
+
requested: string;
|
|
143
|
+
resolved: string;
|
|
144
|
+
}[];
|
|
145
|
+
};
|
|
43
146
|
/** A transition as Jira currently offers it for one issue. */
|
|
44
147
|
export type JiraTransition = {
|
|
45
148
|
id: string;
|
|
@@ -47,33 +150,65 @@ export type JiraTransition = {
|
|
|
47
150
|
/** The status this transition leads to, as Jira names it. */
|
|
48
151
|
to: string;
|
|
49
152
|
};
|
|
153
|
+
/** Fields every plan carries, whatever it is a plan for. */
|
|
154
|
+
type WritePlanCommon = {
|
|
155
|
+
planId: string;
|
|
156
|
+
projectKey: string;
|
|
157
|
+
/** Only the fields this operation touches. */
|
|
158
|
+
before: Record<string, unknown>;
|
|
159
|
+
intendedAfter: Record<string, unknown>;
|
|
160
|
+
createdAt: string;
|
|
161
|
+
expiresAt: string;
|
|
162
|
+
/** Normalized payload the apply step will send. Never supplied by a caller. */
|
|
163
|
+
mutation: WriteMutation;
|
|
164
|
+
};
|
|
50
165
|
/**
|
|
51
|
-
*
|
|
166
|
+
* A plan against an issue that already exists.
|
|
52
167
|
*
|
|
53
168
|
* `baseUpdated` is the issue's `updated` timestamp at plan time. Apply re-reads
|
|
54
169
|
* the issue and refuses when it has moved: a plan that was valid is not the
|
|
55
170
|
* same as a plan that is still valid.
|
|
56
171
|
*/
|
|
57
|
-
export type
|
|
58
|
-
|
|
172
|
+
export type ExistingIssueWritePlan = WritePlanCommon & {
|
|
173
|
+
kind: "existing-issue";
|
|
59
174
|
issueKey: string;
|
|
60
|
-
|
|
61
|
-
operation: WriteOperation;
|
|
62
|
-
/** Only the fields this operation touches. */
|
|
63
|
-
before: Record<string, unknown>;
|
|
64
|
-
intendedAfter: Record<string, unknown>;
|
|
175
|
+
operation: ExistingIssueOperation;
|
|
65
176
|
baseUpdated: string;
|
|
66
|
-
createdAt: string;
|
|
67
|
-
expiresAt: string;
|
|
68
177
|
/**
|
|
69
178
|
* The transition Jira offered for this target status, resolved at plan time.
|
|
70
179
|
* Present only for `status.transition` - a transition id is never guessed
|
|
71
180
|
* from a status name.
|
|
72
181
|
*/
|
|
73
182
|
transition?: JiraTransition;
|
|
74
|
-
/**
|
|
75
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Who the issue was assigned to when the plan was made, by identity.
|
|
185
|
+
*
|
|
186
|
+
* Present only for `assignee.update`, and separate from `before` because
|
|
187
|
+
* `before` is what a receipt shows a human while this is what apply compares.
|
|
188
|
+
* `undefined` means the issue was unassigned.
|
|
189
|
+
*/
|
|
190
|
+
baseAssigneeAccountId?: string;
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* A plan to create an issue that does not exist yet.
|
|
194
|
+
*
|
|
195
|
+
* There is no `issueKey` and no `baseUpdated`, and neither is filled with a
|
|
196
|
+
* placeholder: nothing to name, and no revision to compare. What takes their
|
|
197
|
+
* place is `schemaRequirements` - creation's concurrency boundary is the
|
|
198
|
+
* project's create schema, not one issue's revision, so that is what apply
|
|
199
|
+
* re-checks before it sends anything.
|
|
200
|
+
*/
|
|
201
|
+
export type CreateIssueWritePlan = WritePlanCommon & {
|
|
202
|
+
kind: "create-issue";
|
|
203
|
+
operation: "issue.create";
|
|
204
|
+
before: {
|
|
205
|
+
issue: null;
|
|
206
|
+
};
|
|
207
|
+
schemaRequirements: CreateSchemaRequirements;
|
|
76
208
|
};
|
|
209
|
+
export type WritePlan = ExistingIssueWritePlan | CreateIssueWritePlan;
|
|
210
|
+
/** A plan as it is handed to the store, before an id has been minted. */
|
|
211
|
+
export type NewWritePlan = Omit<ExistingIssueWritePlan, "planId"> | Omit<CreateIssueWritePlan, "planId">;
|
|
77
212
|
/** What apply will actually send. Produced by planning, never by an agent. */
|
|
78
213
|
export type WriteMutation = {
|
|
79
214
|
kind: "comment";
|
|
@@ -84,16 +219,28 @@ export type WriteMutation = {
|
|
|
84
219
|
} | {
|
|
85
220
|
kind: "transition";
|
|
86
221
|
transitionId: string;
|
|
222
|
+
} | {
|
|
223
|
+
kind: "assignee";
|
|
224
|
+
accountId: string;
|
|
225
|
+
} | {
|
|
226
|
+
kind: "create";
|
|
227
|
+
fields: Record<string, unknown>;
|
|
87
228
|
};
|
|
88
229
|
/** What `jira_write_plan` returns. The mutation itself is not exposed. */
|
|
89
230
|
export type WritePlanReceipt = {
|
|
90
231
|
status: "planned";
|
|
91
232
|
planId: string;
|
|
92
|
-
issue: string;
|
|
93
233
|
operation: WriteOperation;
|
|
94
234
|
before: Record<string, unknown>;
|
|
95
235
|
intendedAfter: Record<string, unknown>;
|
|
96
236
|
expiresAt: string;
|
|
237
|
+
/**
|
|
238
|
+
* The issue this plan changes. Absent for `issue.create`, which has no issue
|
|
239
|
+
* yet - a placeholder key here would be a claim JAM cannot make.
|
|
240
|
+
*/
|
|
241
|
+
issue?: string;
|
|
242
|
+
/** The project a new issue would be created in. Present for `issue.create`. */
|
|
243
|
+
project?: string;
|
|
97
244
|
/** How the result of applying this plan will be confirmed. */
|
|
98
245
|
verification: {
|
|
99
246
|
method: "direct-issue-read";
|
|
@@ -104,6 +251,7 @@ export type WritePlanReceipt = {
|
|
|
104
251
|
/** What `jira_write_apply` returns once a direct read has confirmed the change. */
|
|
105
252
|
export type WriteApplyReceipt = {
|
|
106
253
|
status: "applied";
|
|
254
|
+
/** For `issue.create`, the key Jira minted - known only after applying. */
|
|
107
255
|
issue: string;
|
|
108
256
|
operation: WriteOperation;
|
|
109
257
|
before: Record<string, unknown>;
|
|
@@ -114,4 +262,6 @@ export type WriteApplyReceipt = {
|
|
|
114
262
|
commentId?: string;
|
|
115
263
|
};
|
|
116
264
|
export declare function isWriteOperation(value: string): value is WriteOperation;
|
|
265
|
+
export declare function isExistingIssueOperation(value: string): value is ExistingIssueOperation;
|
|
117
266
|
export declare function isWritableField(value: string): value is WritableField;
|
|
267
|
+
export {};
|
package/dist/domain/write.js
CHANGED
|
@@ -13,8 +13,21 @@
|
|
|
13
13
|
* change happen, and did we see it happen". Mixing them would let a confident
|
|
14
14
|
* `complete: true` stand in for a verified mutation.
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* Operations that change an issue that already exists.
|
|
18
|
+
*
|
|
19
|
+
* Kept apart from creation because the two have different shapes at every
|
|
20
|
+
* layer: these name an issue, creation names a project; these compare a
|
|
21
|
+
* revision to detect a conflict, creation has no revision to compare.
|
|
22
|
+
*/
|
|
23
|
+
export const EXISTING_ISSUE_OPERATIONS = [
|
|
24
|
+
"comment.add",
|
|
25
|
+
"field.update",
|
|
26
|
+
"status.transition",
|
|
27
|
+
"assignee.update",
|
|
28
|
+
];
|
|
16
29
|
/** The operations the public MCP surface accepts. Nothing else is reachable. */
|
|
17
|
-
export const WRITE_OPERATIONS = [
|
|
30
|
+
export const WRITE_OPERATIONS = [...EXISTING_ISSUE_OPERATIONS, "issue.create"];
|
|
18
31
|
/**
|
|
19
32
|
* Fields `field.update` may touch.
|
|
20
33
|
*
|
|
@@ -25,9 +38,29 @@ export const WRITE_OPERATIONS = ["comment.add", "field.update", "status.transiti
|
|
|
25
38
|
* discovery, accountId lookup) that belongs in its own round.
|
|
26
39
|
*/
|
|
27
40
|
export const WRITABLE_FIELDS = ["summary", "priority", "labels", "components"];
|
|
41
|
+
/**
|
|
42
|
+
* Fields `issue.create` may set.
|
|
43
|
+
*
|
|
44
|
+
* The same argument as WRITABLE_FIELDS, and the same answer: a closed list, so
|
|
45
|
+
* "what can an agent create" has an answer that does not depend on one
|
|
46
|
+
* project's screen configuration. `issueType` and `summary` are required by
|
|
47
|
+
* every Jira project JAM can serve; the rest are optional and only sent when
|
|
48
|
+
* asked for.
|
|
49
|
+
*/
|
|
50
|
+
export const CREATABLE_FIELDS = [
|
|
51
|
+
"issueType",
|
|
52
|
+
"summary",
|
|
53
|
+
"description",
|
|
54
|
+
"priority",
|
|
55
|
+
"labels",
|
|
56
|
+
"components",
|
|
57
|
+
];
|
|
28
58
|
export function isWriteOperation(value) {
|
|
29
59
|
return WRITE_OPERATIONS.includes(value);
|
|
30
60
|
}
|
|
61
|
+
export function isExistingIssueOperation(value) {
|
|
62
|
+
return EXISTING_ISSUE_OPERATIONS.includes(value);
|
|
63
|
+
}
|
|
31
64
|
export function isWritableField(value) {
|
|
32
65
|
return WRITABLE_FIELDS.includes(value);
|
|
33
66
|
}
|
|
@@ -5,6 +5,10 @@ export declare const TOOL_COUNT: number;
|
|
|
5
5
|
/**
|
|
6
6
|
* The external contract: three read tools and two write tools.
|
|
7
7
|
*
|
|
8
|
+
* What JAM can write grows as operations inside `jira_write_plan`, never as
|
|
9
|
+
* tools. `issue.create` arrived that way: an agent that knew the write pair
|
|
10
|
+
* already knew how to reach it.
|
|
11
|
+
*
|
|
8
12
|
* The read three have been stable since the first release and do not change.
|
|
9
13
|
* The write pair is a single operation split in half on purpose - deciding and
|
|
10
14
|
* doing are separate calls, so an agent cannot mutate Jira without first
|
|
@@ -24,6 +24,10 @@ export const TOOL_COUNT = REGISTER_TOOLS.length;
|
|
|
24
24
|
/**
|
|
25
25
|
* The external contract: three read tools and two write tools.
|
|
26
26
|
*
|
|
27
|
+
* What JAM can write grows as operations inside `jira_write_plan`, never as
|
|
28
|
+
* tools. `issue.create` arrived that way: an agent that knew the write pair
|
|
29
|
+
* already knew how to reach it.
|
|
30
|
+
*
|
|
27
31
|
* The read three have been stable since the first release and do not change.
|
|
28
32
|
* The write pair is a single operation split in half on purpose - deciding and
|
|
29
33
|
* doing are separate calls, so an agent cannot mutate Jira without first
|
|
@@ -47,6 +51,7 @@ export function createServer(deps) {
|
|
|
47
51
|
"jira_write_plan changes nothing - it reads the issue, checks the change is possible, and describes what would happen.",
|
|
48
52
|
"jira_write_apply takes only a planId. There is no way to write without planning first, and no payload to override what the plan decided.",
|
|
49
53
|
"Writes are confined to the configured Jira project, and confirmed by reading the issue back. A write JAM could not verify is never reported as done.",
|
|
54
|
+
"jira_write_plan also creates issues: operation \"issue.create\", no key, and no project - the new issue goes into the project this workspace is bound to. Planning reads Jira's create schema first, so an unavailable issue type, a disallowed priority or component, and a create screen requiring a field JAM cannot set are refused before anything is sent.",
|
|
50
55
|
"On JAM_WRITE_CONFLICT or JAM_WRITE_PLAN_EXPIRED, plan again against the current state. On JAM_WRITE_UNCERTAIN, read the issue - never retry the apply, which could apply the change twice.",
|
|
51
56
|
].join("\n"),
|
|
52
57
|
});
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { planWrite } from "../../application/plan-write.js";
|
|
3
|
-
import { WRITABLE_FIELDS, WRITE_OPERATIONS } from "../../domain/write.js";
|
|
3
|
+
import { CREATABLE_FIELDS, WRITABLE_FIELDS, WRITE_OPERATIONS } from "../../domain/write.js";
|
|
4
4
|
import { runTool } from "../tool-result.js";
|
|
5
5
|
const DESCRIPTION = `Work out how to change one Jira issue, and get back a plan. Changes nothing.
|
|
6
6
|
|
|
7
7
|
This is the first half of every write. Call it, read what it says the issue looks like now and what it would become, then pass the returned planId to jira_write_apply. There is no way to write to Jira without a plan, and a plan cannot be assembled by hand - only jira_write_plan issues one.
|
|
8
8
|
|
|
9
|
-
Operations
|
|
9
|
+
Operations on an issue that already exists - these need \`key\`:
|
|
10
10
|
- comment.add input: { "text": "..." } plain text; JAM converts it, do not send ADF
|
|
11
11
|
- field.update input: { "summary"?, "priority"?, "labels"?, "components"? }
|
|
12
12
|
- status.transition input: { "status": "Done" } JAM asks Jira which transitions exist and matches yours
|
|
13
|
+
- assignee.update input: { "assignee": "..." } a display name or an accountId; JAM resolves it against Jira's own directory
|
|
14
|
+
|
|
15
|
+
Creating an issue - no \`key\`, because there is no issue yet:
|
|
16
|
+
- issue.create input: { "issueType": "Task", "summary": "...", "description"?, "priority"?, "labels"?, "components"? }
|
|
17
|
+
|
|
18
|
+
issue.create goes into the project this workspace is bound to; the project is not a parameter. Planning reads Jira's create schema for that project first, so an issue type Jira does not offer, a priority or component outside its allowed values, and a project whose create screen requires a field JAM cannot set are all refused here rather than attempted. \`description\` is plain text, like a comment. Not settable in this version: assignee, reporter, parent, custom fields, attachments.
|
|
19
|
+
|
|
20
|
+
assignee.update never sends the name you pass. JAM searches Jira's user directory, and assigns only when exactly one user matches your string exactly - an exact display name (case-insensitive) or an accountId. A partial match is Jira reporting a similarity, not identifying a person, so several matches or none come back as a refusal with the candidates attached: name one exactly, or pass their accountId. JAM also checks Jira offers that person as an assignee for this issue, before planning and again before writing, and confirms the result by accountId rather than by name. Unassigning, and setting an assignee while creating, are not in this version.
|
|
13
21
|
|
|
14
22
|
Writes are limited to the Jira project this workspace is bound to; a key from another project is refused rather than attempted.
|
|
15
23
|
|
|
@@ -21,18 +29,46 @@ export function registerJiraWritePlan(server, deps) {
|
|
|
21
29
|
title: "Plan a change to a Jira issue (writes nothing)",
|
|
22
30
|
description: DESCRIPTION,
|
|
23
31
|
inputSchema: {
|
|
24
|
-
|
|
32
|
+
// Optional at the schema level because issue.create has no issue to
|
|
33
|
+
// name. Every other operation requires it, and planning refuses one
|
|
34
|
+
// that arrives without it - so the schema says "sometimes", and the
|
|
35
|
+
// server says which times.
|
|
36
|
+
key: z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1)
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Issue key, e.g. "PROJECT-123". Required for every operation that changes an existing issue; omit for issue.create, which has no issue yet. Must be in the configured project.'),
|
|
25
41
|
operation: z
|
|
26
42
|
.enum(WRITE_OPERATIONS)
|
|
27
43
|
.describe(`What to do: ${WRITE_OPERATIONS.join(", ")}.`),
|
|
44
|
+
// Loose, not stripping. A strict object would refuse an unknown field
|
|
45
|
+
// with a schema error, and the default stripping one would silently
|
|
46
|
+
// drop it - which is worse: an agent that asked to set an assignee
|
|
47
|
+
// would get an issue without one and a receipt that never mentions it.
|
|
48
|
+
// Letting unknown keys through means JAM refuses them itself, by name,
|
|
49
|
+
// with the supported list attached.
|
|
28
50
|
input: z
|
|
29
|
-
.
|
|
51
|
+
.looseObject({
|
|
30
52
|
text: z.string().min(1).optional().describe("comment.add: the comment, as plain text."),
|
|
31
53
|
status: z
|
|
32
54
|
.string()
|
|
33
55
|
.min(1)
|
|
34
56
|
.optional()
|
|
35
57
|
.describe("status.transition: the status to move to, e.g. \"Done\"."),
|
|
58
|
+
assignee: z
|
|
59
|
+
.string()
|
|
60
|
+
.min(1)
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("assignee.update: who to assign, as an exact display name or an accountId. Not settable through field.update."),
|
|
63
|
+
issueType: z
|
|
64
|
+
.string()
|
|
65
|
+
.min(1)
|
|
66
|
+
.optional()
|
|
67
|
+
.describe('issue.create: the issue type by name, e.g. "Task". Matched against the types Jira offers for this project.'),
|
|
68
|
+
description: z
|
|
69
|
+
.string()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("issue.create: the description, as plain text. JAM converts it; do not send ADF."),
|
|
36
72
|
summary: z.string().min(1).optional(),
|
|
37
73
|
priority: z.string().min(1).optional().describe('Priority name, e.g. "High".'),
|
|
38
74
|
labels: z.array(z.string()).optional().describe("Replaces the whole label set."),
|
|
@@ -41,14 +77,14 @@ export function registerJiraWritePlan(server, deps) {
|
|
|
41
77
|
.optional()
|
|
42
78
|
.describe("Component names. Replaces the whole component set."),
|
|
43
79
|
})
|
|
44
|
-
.describe(`Operation input. field.update accepts only ${WRITABLE_FIELDS.join(", ")}
|
|
80
|
+
.describe(`Operation input. field.update accepts only ${WRITABLE_FIELDS.join(", ")}; issue.create accepts only ${CREATABLE_FIELDS.join(", ")}. Custom fields are not writable by either, and the assignee is changed through assignee.update rather than through field.update.`),
|
|
45
81
|
},
|
|
46
82
|
// Planning reads Jira and decides; it never mutates. Hosts are free to
|
|
47
83
|
// run it without asking, which is what keeps the two-step shape cheap.
|
|
48
84
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
49
85
|
}, async (args) => runTool("jira_write_plan", deps.telemetry, async () => {
|
|
50
86
|
const { receipt } = await planWrite(deps, {
|
|
51
|
-
key: args.key,
|
|
87
|
+
...(args.key !== undefined ? { key: args.key } : {}),
|
|
52
88
|
operation: args.operation,
|
|
53
89
|
input: args.input,
|
|
54
90
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AssigneeCandidate, AssigneeRef } from "../domain/write.js";
|
|
2
|
+
/**
|
|
3
|
+
* Turning a name into a person, without ever guessing which person.
|
|
4
|
+
*
|
|
5
|
+
* Jira's user search is a substring search: "min" finds Min Kim and Minho
|
|
6
|
+
* Park, and it returns them in whatever order it likes. An agent handing JAM a
|
|
7
|
+
* name is describing an intent, not identifying an account - so the search is
|
|
8
|
+
* how candidates are found, and never how one of them is chosen.
|
|
9
|
+
*
|
|
10
|
+
* The rule is that JAM assigns only when the answer is unambiguous on its own
|
|
11
|
+
* terms: one exact identity. Everything else comes back as a refusal carrying
|
|
12
|
+
* the candidates, so the next move is to name one of them precisely rather
|
|
13
|
+
* than to hope the same query resolves differently.
|
|
14
|
+
*
|
|
15
|
+
* This costs an agent a round trip on ambiguity. The alternative costs someone
|
|
16
|
+
* an issue assigned to the wrong colleague, discovered later.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Which candidate the caller meant, if exactly one is certain.
|
|
20
|
+
*
|
|
21
|
+
* In order:
|
|
22
|
+
*
|
|
23
|
+
* 1. An exact accountId. The caller already had an identity; nothing to guess.
|
|
24
|
+
* 2. Exactly one candidate whose display name matches exactly, ignoring case
|
|
25
|
+
* and surrounding space. "task" and "Task" are the same intent, and an
|
|
26
|
+
* agent cannot learn a directory's casing before asking.
|
|
27
|
+
*
|
|
28
|
+
* Nothing else resolves. A single substring hit is still a substring hit: it
|
|
29
|
+
* is Jira saying "this contains what you typed", not "this is who you meant".
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveAssignee(requested: string, candidates: AssigneeCandidate[]): AssigneeRef;
|
|
32
|
+
/**
|
|
33
|
+
* Refuse an assignment Jira would not permit, before asking it to.
|
|
34
|
+
*
|
|
35
|
+
* Assignability is a permission question with a per-project answer, and JAM
|
|
36
|
+
* does not model Jira's permission scheme - it asks. Called at plan time so a
|
|
37
|
+
* refusal is a JAM decision rather than a 400, and again immediately before
|
|
38
|
+
* the write, because a permission that held when the plan was made is not the
|
|
39
|
+
* same as one that still holds.
|
|
40
|
+
*/
|
|
41
|
+
export declare function assertAssignable(issueKey: string, target: AssigneeRef, assignable: boolean): void;
|
|
42
|
+
/**
|
|
43
|
+
* Refuse an assignment that would change nothing.
|
|
44
|
+
*
|
|
45
|
+
* Not an error in Jira's eyes, and not harmful - but a write JAM reports as
|
|
46
|
+
* applied should be a write that happened. Saying so plainly is more useful
|
|
47
|
+
* than a receipt claiming to have changed something that already was.
|
|
48
|
+
*/
|
|
49
|
+
export declare function assertNotAlreadyAssigned(issueKey: string, current: string | undefined, target: AssigneeRef): void;
|
|
50
|
+
/**
|
|
51
|
+
* The candidates this string identifies exactly, if any.
|
|
52
|
+
*
|
|
53
|
+
* Exported so the caller can tell "the search settled it" from "the search did
|
|
54
|
+
* not" without reimplementing the rule - a second copy of what counts as exact
|
|
55
|
+
* is a second answer waiting to disagree with this one.
|
|
56
|
+
*
|
|
57
|
+
* An accountId match wins outright: it is an identity, and a display name that
|
|
58
|
+
* happens to equal somebody's account id is not a reason to consider them.
|
|
59
|
+
*/
|
|
60
|
+
export declare function exactMatches(requested: string, candidates: AssigneeCandidate[]): AssigneeCandidate[];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { JamError } from "../domain/errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Turning a name into a person, without ever guessing which person.
|
|
4
|
+
*
|
|
5
|
+
* Jira's user search is a substring search: "min" finds Min Kim and Minho
|
|
6
|
+
* Park, and it returns them in whatever order it likes. An agent handing JAM a
|
|
7
|
+
* name is describing an intent, not identifying an account - so the search is
|
|
8
|
+
* how candidates are found, and never how one of them is chosen.
|
|
9
|
+
*
|
|
10
|
+
* The rule is that JAM assigns only when the answer is unambiguous on its own
|
|
11
|
+
* terms: one exact identity. Everything else comes back as a refusal carrying
|
|
12
|
+
* the candidates, so the next move is to name one of them precisely rather
|
|
13
|
+
* than to hope the same query resolves differently.
|
|
14
|
+
*
|
|
15
|
+
* This costs an agent a round trip on ambiguity. The alternative costs someone
|
|
16
|
+
* an issue assigned to the wrong colleague, discovered later.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Which candidate the caller meant, if exactly one is certain.
|
|
20
|
+
*
|
|
21
|
+
* In order:
|
|
22
|
+
*
|
|
23
|
+
* 1. An exact accountId. The caller already had an identity; nothing to guess.
|
|
24
|
+
* 2. Exactly one candidate whose display name matches exactly, ignoring case
|
|
25
|
+
* and surrounding space. "task" and "Task" are the same intent, and an
|
|
26
|
+
* agent cannot learn a directory's casing before asking.
|
|
27
|
+
*
|
|
28
|
+
* Nothing else resolves. A single substring hit is still a substring hit: it
|
|
29
|
+
* is Jira saying "this contains what you typed", not "this is who you meant".
|
|
30
|
+
*/
|
|
31
|
+
export function resolveAssignee(requested, candidates) {
|
|
32
|
+
const wanted = requested.trim();
|
|
33
|
+
if (wanted.length === 0) {
|
|
34
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "assignee.update needs a non-empty `input.assignee`.", { operation: "assignee.update" });
|
|
35
|
+
}
|
|
36
|
+
const exact = exactMatches(wanted, candidates);
|
|
37
|
+
if (exact.length === 0) {
|
|
38
|
+
throw new JamError("JAM_WRITE_ASSIGNEE_NOT_FOUND", candidates.length === 0
|
|
39
|
+
? `Jira has no user matching "${requested}".`
|
|
40
|
+
: `No Jira user is exactly "${requested}". JAM assigns only on an exact display name or an accountId, because a partial match is Jira reporting a similarity rather than identifying a person. Name one of the candidates exactly, or pass their accountId.`, { requested, candidates: describe(candidates) });
|
|
41
|
+
}
|
|
42
|
+
if (exact.length > 1) {
|
|
43
|
+
// Two people really can share a display name. Picking either would be a
|
|
44
|
+
// coin toss whose result is somebody's issue.
|
|
45
|
+
throw new JamError("JAM_WRITE_ASSIGNEE_AMBIGUOUS", `"${requested}" matches ${exact.length} Jira users exactly. Pass the accountId of the one you mean.`, { requested, candidates: describe(exact) });
|
|
46
|
+
}
|
|
47
|
+
const match = exact[0];
|
|
48
|
+
if (!match.active) {
|
|
49
|
+
throw new JamError("JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", `${match.displayName} is a deactivated Jira account, so this issue cannot be assigned to them.`, { requested, accountId: match.accountId, reason: "INACTIVE" });
|
|
50
|
+
}
|
|
51
|
+
return { accountId: match.accountId, displayName: match.displayName };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Refuse an assignment Jira would not permit, before asking it to.
|
|
55
|
+
*
|
|
56
|
+
* Assignability is a permission question with a per-project answer, and JAM
|
|
57
|
+
* does not model Jira's permission scheme - it asks. Called at plan time so a
|
|
58
|
+
* refusal is a JAM decision rather than a 400, and again immediately before
|
|
59
|
+
* the write, because a permission that held when the plan was made is not the
|
|
60
|
+
* same as one that still holds.
|
|
61
|
+
*/
|
|
62
|
+
export function assertAssignable(issueKey, target, assignable) {
|
|
63
|
+
if (assignable)
|
|
64
|
+
return;
|
|
65
|
+
throw new JamError("JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", `Jira does not offer ${target.displayName} as an assignee for ${issueKey}. They may lack the assignable-user permission in this project, or have lost it since this plan was made.`, { issueKey, accountId: target.accountId, displayName: target.displayName });
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Refuse an assignment that would change nothing.
|
|
69
|
+
*
|
|
70
|
+
* Not an error in Jira's eyes, and not harmful - but a write JAM reports as
|
|
71
|
+
* applied should be a write that happened. Saying so plainly is more useful
|
|
72
|
+
* than a receipt claiming to have changed something that already was.
|
|
73
|
+
*/
|
|
74
|
+
export function assertNotAlreadyAssigned(issueKey, current, target) {
|
|
75
|
+
if (current !== target.accountId)
|
|
76
|
+
return;
|
|
77
|
+
throw new JamError("JAM_WRITE_ASSIGNEE_ALREADY_SET", `${issueKey} is already assigned to ${target.displayName}. Nothing to change.`, { issueKey, accountId: target.accountId, displayName: target.displayName });
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The candidates this string identifies exactly, if any.
|
|
81
|
+
*
|
|
82
|
+
* Exported so the caller can tell "the search settled it" from "the search did
|
|
83
|
+
* not" without reimplementing the rule - a second copy of what counts as exact
|
|
84
|
+
* is a second answer waiting to disagree with this one.
|
|
85
|
+
*
|
|
86
|
+
* An accountId match wins outright: it is an identity, and a display name that
|
|
87
|
+
* happens to equal somebody's account id is not a reason to consider them.
|
|
88
|
+
*/
|
|
89
|
+
export function exactMatches(requested, candidates) {
|
|
90
|
+
const wanted = requested.trim();
|
|
91
|
+
const byAccountId = candidates.filter((c) => c.accountId === wanted);
|
|
92
|
+
if (byAccountId.length > 0)
|
|
93
|
+
return byAccountId;
|
|
94
|
+
return candidates.filter((c) => c.displayName.trim().toLowerCase() === wanted.toLowerCase());
|
|
95
|
+
}
|
|
96
|
+
/** Candidates as an agent can act on them: a name to repeat, and an id to be sure. */
|
|
97
|
+
function describe(candidates) {
|
|
98
|
+
return candidates.map((c) => ({
|
|
99
|
+
accountId: c.accountId,
|
|
100
|
+
displayName: c.displayName,
|
|
101
|
+
active: c.active,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read-after-write rule.
|
|
3
3
|
*
|
|
4
|
-
* JAM is read-only in this release, so nothing enforces this at runtime yet.
|
|
5
|
-
* The rule is fixed here so the write adapter, when it lands, cannot quietly
|
|
6
|
-
* confirm a write with a stale JQL search result.
|
|
7
|
-
*
|
|
8
4
|
* Normal read -> Enhanced JQL search (`jira_search`)
|
|
9
5
|
* Post-write read -> direct issue GET for the affected key
|
|
6
|
+
*
|
|
7
|
+
* "Direct issue GET" means `JiraReadPort.getIssue` - one key, one
|
|
8
|
+
* `GET /rest/api/3/issue/{key}`. Not a search, whose index can lag behind the
|
|
9
|
+
* issue it describes, and not `getIssues`: that is a bulk endpoint taking a
|
|
10
|
+
* list, and a bulk read is free to answer from a different path than the
|
|
11
|
+
* single-issue one. The difference is invisible in a listing and decisive in
|
|
12
|
+
* the read that says whether a mutation may proceed, or whether one landed.
|
|
13
|
+
*
|
|
14
|
+
* Every read the write plane makes goes through it: the pre-write conflict
|
|
15
|
+
* check, the post-write confirmation, and the post-create confirmation.
|
|
10
16
|
*/
|
|
11
17
|
export type ReadMode = "search" | "direct";
|
|
12
18
|
export declare function readModeAfterWrite(): ReadMode;
|