@jam-mcp/server 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-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 +17 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +13 -7
- package/dist/adapters/jira-cloud/jira-write.adapter.js +25 -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 +18 -3
- 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 +10 -3
- package/dist/application/plan-write.js +54 -11
- 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 +11 -0
- package/dist/deps.js +6 -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 +9 -0
- package/dist/domain/write.d.ts +128 -15
- package/dist/domain/write.js +33 -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 +34 -6
- 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-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 +23 -0
- package/dist/ports/jira-write.port.d.ts +9 -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"];
|
|
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", "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,75 @@ export type FieldUpdateInput = {
|
|
|
39
48
|
export type StatusTransitionInput = {
|
|
40
49
|
status: string;
|
|
41
50
|
};
|
|
42
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Fields `issue.create` may set.
|
|
53
|
+
*
|
|
54
|
+
* The same argument as WRITABLE_FIELDS, and the same answer: a closed list, so
|
|
55
|
+
* "what can an agent create" has an answer that does not depend on one
|
|
56
|
+
* project's screen configuration. `issueType` and `summary` are required by
|
|
57
|
+
* every Jira project JAM can serve; the rest are optional and only sent when
|
|
58
|
+
* asked for.
|
|
59
|
+
*/
|
|
60
|
+
export declare const CREATABLE_FIELDS: readonly ["issueType", "summary", "description", "priority", "labels", "components"];
|
|
61
|
+
export type CreatableField = (typeof CREATABLE_FIELDS)[number];
|
|
62
|
+
export type CreateIssueInput = {
|
|
63
|
+
issueType: string;
|
|
64
|
+
summary: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
priority?: string;
|
|
67
|
+
labels?: string[];
|
|
68
|
+
components?: string[];
|
|
69
|
+
};
|
|
70
|
+
export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput | CreateIssueInput;
|
|
71
|
+
/** An issue type as Jira offers it for one project, right now. */
|
|
72
|
+
export type CreateIssueType = {
|
|
73
|
+
id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
subtask: boolean;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* One field on a project's create screen, as Jira describes it.
|
|
79
|
+
*
|
|
80
|
+
* `allowedValues` is present only for fields Jira constrains (priority,
|
|
81
|
+
* components, and issue-type-scoped pickers). Absent means unconstrained, not
|
|
82
|
+
* empty - the difference decides whether a value can be resolved or must be
|
|
83
|
+
* refused.
|
|
84
|
+
*/
|
|
85
|
+
export type CreateFieldMetadata = {
|
|
86
|
+
/** Jira's field id, e.g. `summary` or `customfield_12345`. */
|
|
87
|
+
id: string;
|
|
88
|
+
name: string;
|
|
89
|
+
required: boolean;
|
|
90
|
+
hasDefaultValue: boolean;
|
|
91
|
+
allowedValues?: {
|
|
92
|
+
id?: string;
|
|
93
|
+
name?: string;
|
|
94
|
+
}[];
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* What a create plan depends on, recorded so apply can check it again.
|
|
98
|
+
*
|
|
99
|
+
* Not a hash of the metadata document: an unrelated optional field appearing
|
|
100
|
+
* on the create screen does not invalidate a plan, and treating it as though
|
|
101
|
+
* it did would make every plan fail on a busy project. What is recorded here
|
|
102
|
+
* is the set of premises the plan was built on, and apply re-derives whether
|
|
103
|
+
* each still holds.
|
|
104
|
+
*/
|
|
105
|
+
export type CreateSchemaRequirements = {
|
|
106
|
+
issueTypeId: string;
|
|
107
|
+
issueTypeName: string;
|
|
108
|
+
/** Required field ids JAM undertook to supply or knew Jira would default. */
|
|
109
|
+
requiredFieldIds: string[];
|
|
110
|
+
/**
|
|
111
|
+
* Values resolved from Jira's allowed lists at plan time, by field id. Apply
|
|
112
|
+
* refuses if any of them is no longer offered.
|
|
113
|
+
*/
|
|
114
|
+
resolvedValues: {
|
|
115
|
+
fieldId: string;
|
|
116
|
+
requested: string;
|
|
117
|
+
resolved: string;
|
|
118
|
+
}[];
|
|
119
|
+
};
|
|
43
120
|
/** A transition as Jira currently offers it for one issue. */
|
|
44
121
|
export type JiraTransition = {
|
|
45
122
|
id: string;
|
|
@@ -47,33 +124,57 @@ export type JiraTransition = {
|
|
|
47
124
|
/** The status this transition leads to, as Jira names it. */
|
|
48
125
|
to: string;
|
|
49
126
|
};
|
|
127
|
+
/** Fields every plan carries, whatever it is a plan for. */
|
|
128
|
+
type WritePlanCommon = {
|
|
129
|
+
planId: string;
|
|
130
|
+
projectKey: string;
|
|
131
|
+
/** Only the fields this operation touches. */
|
|
132
|
+
before: Record<string, unknown>;
|
|
133
|
+
intendedAfter: Record<string, unknown>;
|
|
134
|
+
createdAt: string;
|
|
135
|
+
expiresAt: string;
|
|
136
|
+
/** Normalized payload the apply step will send. Never supplied by a caller. */
|
|
137
|
+
mutation: WriteMutation;
|
|
138
|
+
};
|
|
50
139
|
/**
|
|
51
|
-
*
|
|
140
|
+
* A plan against an issue that already exists.
|
|
52
141
|
*
|
|
53
142
|
* `baseUpdated` is the issue's `updated` timestamp at plan time. Apply re-reads
|
|
54
143
|
* the issue and refuses when it has moved: a plan that was valid is not the
|
|
55
144
|
* same as a plan that is still valid.
|
|
56
145
|
*/
|
|
57
|
-
export type
|
|
58
|
-
|
|
146
|
+
export type ExistingIssueWritePlan = WritePlanCommon & {
|
|
147
|
+
kind: "existing-issue";
|
|
59
148
|
issueKey: string;
|
|
60
|
-
|
|
61
|
-
operation: WriteOperation;
|
|
62
|
-
/** Only the fields this operation touches. */
|
|
63
|
-
before: Record<string, unknown>;
|
|
64
|
-
intendedAfter: Record<string, unknown>;
|
|
149
|
+
operation: ExistingIssueOperation;
|
|
65
150
|
baseUpdated: string;
|
|
66
|
-
createdAt: string;
|
|
67
|
-
expiresAt: string;
|
|
68
151
|
/**
|
|
69
152
|
* The transition Jira offered for this target status, resolved at plan time.
|
|
70
153
|
* Present only for `status.transition` - a transition id is never guessed
|
|
71
154
|
* from a status name.
|
|
72
155
|
*/
|
|
73
156
|
transition?: JiraTransition;
|
|
74
|
-
/** Normalized payload the apply step will send. Never supplied by a caller. */
|
|
75
|
-
mutation: WriteMutation;
|
|
76
157
|
};
|
|
158
|
+
/**
|
|
159
|
+
* A plan to create an issue that does not exist yet.
|
|
160
|
+
*
|
|
161
|
+
* There is no `issueKey` and no `baseUpdated`, and neither is filled with a
|
|
162
|
+
* placeholder: nothing to name, and no revision to compare. What takes their
|
|
163
|
+
* place is `schemaRequirements` - creation's concurrency boundary is the
|
|
164
|
+
* project's create schema, not one issue's revision, so that is what apply
|
|
165
|
+
* re-checks before it sends anything.
|
|
166
|
+
*/
|
|
167
|
+
export type CreateIssueWritePlan = WritePlanCommon & {
|
|
168
|
+
kind: "create-issue";
|
|
169
|
+
operation: "issue.create";
|
|
170
|
+
before: {
|
|
171
|
+
issue: null;
|
|
172
|
+
};
|
|
173
|
+
schemaRequirements: CreateSchemaRequirements;
|
|
174
|
+
};
|
|
175
|
+
export type WritePlan = ExistingIssueWritePlan | CreateIssueWritePlan;
|
|
176
|
+
/** A plan as it is handed to the store, before an id has been minted. */
|
|
177
|
+
export type NewWritePlan = Omit<ExistingIssueWritePlan, "planId"> | Omit<CreateIssueWritePlan, "planId">;
|
|
77
178
|
/** What apply will actually send. Produced by planning, never by an agent. */
|
|
78
179
|
export type WriteMutation = {
|
|
79
180
|
kind: "comment";
|
|
@@ -84,16 +185,25 @@ export type WriteMutation = {
|
|
|
84
185
|
} | {
|
|
85
186
|
kind: "transition";
|
|
86
187
|
transitionId: string;
|
|
188
|
+
} | {
|
|
189
|
+
kind: "create";
|
|
190
|
+
fields: Record<string, unknown>;
|
|
87
191
|
};
|
|
88
192
|
/** What `jira_write_plan` returns. The mutation itself is not exposed. */
|
|
89
193
|
export type WritePlanReceipt = {
|
|
90
194
|
status: "planned";
|
|
91
195
|
planId: string;
|
|
92
|
-
issue: string;
|
|
93
196
|
operation: WriteOperation;
|
|
94
197
|
before: Record<string, unknown>;
|
|
95
198
|
intendedAfter: Record<string, unknown>;
|
|
96
199
|
expiresAt: string;
|
|
200
|
+
/**
|
|
201
|
+
* The issue this plan changes. Absent for `issue.create`, which has no issue
|
|
202
|
+
* yet - a placeholder key here would be a claim JAM cannot make.
|
|
203
|
+
*/
|
|
204
|
+
issue?: string;
|
|
205
|
+
/** The project a new issue would be created in. Present for `issue.create`. */
|
|
206
|
+
project?: string;
|
|
97
207
|
/** How the result of applying this plan will be confirmed. */
|
|
98
208
|
verification: {
|
|
99
209
|
method: "direct-issue-read";
|
|
@@ -104,6 +214,7 @@ export type WritePlanReceipt = {
|
|
|
104
214
|
/** What `jira_write_apply` returns once a direct read has confirmed the change. */
|
|
105
215
|
export type WriteApplyReceipt = {
|
|
106
216
|
status: "applied";
|
|
217
|
+
/** For `issue.create`, the key Jira minted - known only after applying. */
|
|
107
218
|
issue: string;
|
|
108
219
|
operation: WriteOperation;
|
|
109
220
|
before: Record<string, unknown>;
|
|
@@ -114,4 +225,6 @@ export type WriteApplyReceipt = {
|
|
|
114
225
|
commentId?: string;
|
|
115
226
|
};
|
|
116
227
|
export declare function isWriteOperation(value: string): value is WriteOperation;
|
|
228
|
+
export declare function isExistingIssueOperation(value: string): value is ExistingIssueOperation;
|
|
117
229
|
export declare function isWritableField(value: string): value is WritableField;
|
|
230
|
+
export {};
|
package/dist/domain/write.js
CHANGED
|
@@ -13,8 +13,20 @@
|
|
|
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
|
+
];
|
|
16
28
|
/** The operations the public MCP surface accepts. Nothing else is reachable. */
|
|
17
|
-
export const WRITE_OPERATIONS = [
|
|
29
|
+
export const WRITE_OPERATIONS = [...EXISTING_ISSUE_OPERATIONS, "issue.create"];
|
|
18
30
|
/**
|
|
19
31
|
* Fields `field.update` may touch.
|
|
20
32
|
*
|
|
@@ -25,9 +37,29 @@ export const WRITE_OPERATIONS = ["comment.add", "field.update", "status.transiti
|
|
|
25
37
|
* discovery, accountId lookup) that belongs in its own round.
|
|
26
38
|
*/
|
|
27
39
|
export const WRITABLE_FIELDS = ["summary", "priority", "labels", "components"];
|
|
40
|
+
/**
|
|
41
|
+
* Fields `issue.create` may set.
|
|
42
|
+
*
|
|
43
|
+
* The same argument as WRITABLE_FIELDS, and the same answer: a closed list, so
|
|
44
|
+
* "what can an agent create" has an answer that does not depend on one
|
|
45
|
+
* project's screen configuration. `issueType` and `summary` are required by
|
|
46
|
+
* every Jira project JAM can serve; the rest are optional and only sent when
|
|
47
|
+
* asked for.
|
|
48
|
+
*/
|
|
49
|
+
export const CREATABLE_FIELDS = [
|
|
50
|
+
"issueType",
|
|
51
|
+
"summary",
|
|
52
|
+
"description",
|
|
53
|
+
"priority",
|
|
54
|
+
"labels",
|
|
55
|
+
"components",
|
|
56
|
+
];
|
|
28
57
|
export function isWriteOperation(value) {
|
|
29
58
|
return WRITE_OPERATIONS.includes(value);
|
|
30
59
|
}
|
|
60
|
+
export function isExistingIssueOperation(value) {
|
|
61
|
+
return EXISTING_ISSUE_OPERATIONS.includes(value);
|
|
62
|
+
}
|
|
31
63
|
export function isWritableField(value) {
|
|
32
64
|
return WRITABLE_FIELDS.includes(value);
|
|
33
65
|
}
|
|
@@ -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,16 +1,21 @@
|
|
|
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
13
|
|
|
14
|
+
Creating an issue - no \`key\`, because there is no issue yet:
|
|
15
|
+
- issue.create input: { "issueType": "Task", "summary": "...", "description"?, "priority"?, "labels"?, "components"? }
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
14
19
|
Writes are limited to the Jira project this workspace is bound to; a key from another project is refused rather than attempted.
|
|
15
20
|
|
|
16
21
|
The plan records what the issue looked like when it was made, and expires. If the issue changes in the meantime, jira_write_apply refuses with JAM_WRITE_CONFLICT - re-plan against the new state rather than forcing the old one through.
|
|
@@ -21,18 +26,41 @@ export function registerJiraWritePlan(server, deps) {
|
|
|
21
26
|
title: "Plan a change to a Jira issue (writes nothing)",
|
|
22
27
|
description: DESCRIPTION,
|
|
23
28
|
inputSchema: {
|
|
24
|
-
|
|
29
|
+
// Optional at the schema level because issue.create has no issue to
|
|
30
|
+
// name. Every other operation requires it, and planning refuses one
|
|
31
|
+
// that arrives without it - so the schema says "sometimes", and the
|
|
32
|
+
// server says which times.
|
|
33
|
+
key: z
|
|
34
|
+
.string()
|
|
35
|
+
.min(1)
|
|
36
|
+
.optional()
|
|
37
|
+
.describe('Issue key, e.g. "PROJECT-123". Required for comment.add, field.update and status.transition; omit for issue.create, which has no issue yet. Must be in the configured project.'),
|
|
25
38
|
operation: z
|
|
26
39
|
.enum(WRITE_OPERATIONS)
|
|
27
40
|
.describe(`What to do: ${WRITE_OPERATIONS.join(", ")}.`),
|
|
41
|
+
// Loose, not stripping. A strict object would refuse an unknown field
|
|
42
|
+
// with a schema error, and the default stripping one would silently
|
|
43
|
+
// drop it - which is worse: an agent that asked to set an assignee
|
|
44
|
+
// would get an issue without one and a receipt that never mentions it.
|
|
45
|
+
// Letting unknown keys through means JAM refuses them itself, by name,
|
|
46
|
+
// with the supported list attached.
|
|
28
47
|
input: z
|
|
29
|
-
.
|
|
48
|
+
.looseObject({
|
|
30
49
|
text: z.string().min(1).optional().describe("comment.add: the comment, as plain text."),
|
|
31
50
|
status: z
|
|
32
51
|
.string()
|
|
33
52
|
.min(1)
|
|
34
53
|
.optional()
|
|
35
54
|
.describe("status.transition: the status to move to, e.g. \"Done\"."),
|
|
55
|
+
issueType: z
|
|
56
|
+
.string()
|
|
57
|
+
.min(1)
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('issue.create: the issue type by name, e.g. "Task". Matched against the types Jira offers for this project.'),
|
|
60
|
+
description: z
|
|
61
|
+
.string()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("issue.create: the description, as plain text. JAM converts it; do not send ADF."),
|
|
36
64
|
summary: z.string().min(1).optional(),
|
|
37
65
|
priority: z.string().min(1).optional().describe('Priority name, e.g. "High".'),
|
|
38
66
|
labels: z.array(z.string()).optional().describe("Replaces the whole label set."),
|
|
@@ -41,14 +69,14 @@ export function registerJiraWritePlan(server, deps) {
|
|
|
41
69
|
.optional()
|
|
42
70
|
.describe("Component names. Replaces the whole component set."),
|
|
43
71
|
})
|
|
44
|
-
.describe(`Operation input. field.update accepts only ${WRITABLE_FIELDS.join(", ")}
|
|
72
|
+
.describe(`Operation input. field.update accepts only ${WRITABLE_FIELDS.join(", ")}; issue.create accepts only ${CREATABLE_FIELDS.join(", ")}. Custom fields and assignee are not writable by either.`),
|
|
45
73
|
},
|
|
46
74
|
// Planning reads Jira and decides; it never mutates. Hosts are free to
|
|
47
75
|
// run it without asking, which is what keeps the two-step shape cheap.
|
|
48
76
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
49
77
|
}, async (args) => runTool("jira_write_plan", deps.telemetry, async () => {
|
|
50
78
|
const { receipt } = await planWrite(deps, {
|
|
51
|
-
key: args.key,
|
|
79
|
+
...(args.key !== undefined ? { key: args.key } : {}),
|
|
52
80
|
operation: args.operation,
|
|
53
81
|
input: args.input,
|
|
54
82
|
});
|
|
@@ -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;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type CreateFieldMetadata, type CreateIssueInput, type CreateIssueType, type CreateSchemaRequirements } from "../domain/write.js";
|
|
2
|
+
/**
|
|
3
|
+
* What JAM will agree to create, decided from what Jira says it accepts.
|
|
4
|
+
*
|
|
5
|
+
* Creation is the one write with no issue to look at first, so every check
|
|
6
|
+
* here is against the project's create schema instead. The rule throughout is
|
|
7
|
+
* the one the rest of the write plane follows: resolve against what Jira just
|
|
8
|
+
* reported, never against what the caller asserted or what a name suggests. An
|
|
9
|
+
* issue type id is not derived from a type name, a priority is not sent
|
|
10
|
+
* because it looked plausible, and a required field JAM cannot express is
|
|
11
|
+
* refused here rather than posted and rejected as a 400.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Jira field ids for the fields JAM can put on a create.
|
|
15
|
+
*
|
|
16
|
+
* The bridge between the public contract (CREATABLE_FIELDS, which an agent
|
|
17
|
+
* sees) and Jira's own ids (which the required-field gate compares against).
|
|
18
|
+
* Both directions matter: one decides what may be asked for, the other decides
|
|
19
|
+
* what counts as "JAM supplies this".
|
|
20
|
+
*/
|
|
21
|
+
export declare const CREATE_FIELD_IDS: {
|
|
22
|
+
readonly issueType: "issuetype";
|
|
23
|
+
readonly summary: "summary";
|
|
24
|
+
readonly description: "description";
|
|
25
|
+
readonly priority: "priority";
|
|
26
|
+
readonly labels: "labels";
|
|
27
|
+
readonly components: "components";
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Match a requested issue type against the ones Jira offers for this project.
|
|
31
|
+
*
|
|
32
|
+
* Case-insensitive, because "task" and "Task" are the same intent and an agent
|
|
33
|
+
* has no way to learn Jira's casing before asking. Nothing else is inferred:
|
|
34
|
+
* the id comes from Jira's own list, and a type that is not on it is refused
|
|
35
|
+
* with the list attached, so the next move is to pick one rather than to
|
|
36
|
+
* rephrase the same one.
|
|
37
|
+
*
|
|
38
|
+
* Subtask types are refused separately. They need a parent, which is not in
|
|
39
|
+
* this version's contract, so "not available" would be the wrong answer - the
|
|
40
|
+
* type exists, and JAM cannot use it yet.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveIssueType(requested: string, available: CreateIssueType[]): CreateIssueType;
|
|
43
|
+
/**
|
|
44
|
+
* Refuse a create whose project requires something JAM cannot put on it.
|
|
45
|
+
*
|
|
46
|
+
* The alternative - post it and let Jira answer 400 - is worse twice over: the
|
|
47
|
+
* agent gets a vendor error instead of a JAM decision, and creation is the one
|
|
48
|
+
* write where "did it happen?" is expensive to answer after the fact. So the
|
|
49
|
+
* answer is worked out before anything is sent.
|
|
50
|
+
*
|
|
51
|
+
* A field Jira says it will default is not JAM's to supply. That is Jira
|
|
52
|
+
* stating a fact about its own configuration, not JAM guessing one.
|
|
53
|
+
*
|
|
54
|
+
* Returns the required field ids, which the plan records so apply can tell a
|
|
55
|
+
* newly-required field from one that was always there.
|
|
56
|
+
*/
|
|
57
|
+
export declare function assertRequiredFieldsSupported(fields: CreateFieldMetadata[], input: CreateIssueInput): string[];
|
|
58
|
+
/**
|
|
59
|
+
* Turn a requested value into one Jira currently offers for that field.
|
|
60
|
+
*
|
|
61
|
+
* An unconstrained field passes the value through: there is no list to check
|
|
62
|
+
* it against, and inventing one would refuse valid input. Absent and empty are
|
|
63
|
+
* different - absent means Jira did not constrain the field, empty means it
|
|
64
|
+
* constrains it and offers nothing.
|
|
65
|
+
*
|
|
66
|
+
* The shape is resolveTransition's, deliberately: human intent, then
|
|
67
|
+
* Jira-provided candidates, then a concrete Jira value. Nothing in between
|
|
68
|
+
* guesses.
|
|
69
|
+
*/
|
|
70
|
+
export declare function resolveAllowedValue(field: CreateFieldMetadata | undefined, requested: string, label: string): {
|
|
71
|
+
requested: string;
|
|
72
|
+
resolved: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Are this plan's premises still true?
|
|
76
|
+
*
|
|
77
|
+
* Semantic, not a document comparison. Comparing a hash of the metadata would
|
|
78
|
+
* make an unrelated optional field appearing on the create screen invalidate
|
|
79
|
+
* every outstanding plan - which is wrong, and on an active project constant.
|
|
80
|
+
* What matters is narrower: the issue type still exists, no new required field
|
|
81
|
+
* has appeared that JAM cannot fill, and every value resolved from an allowed
|
|
82
|
+
* list is still on it.
|
|
83
|
+
*
|
|
84
|
+
* Everything else about the schema may change freely between plan and apply.
|
|
85
|
+
*/
|
|
86
|
+
export declare function assertSchemaUnchanged(requirements: CreateSchemaRequirements, issueTypes: CreateIssueType[], fields: CreateFieldMetadata[], input: CreateIssueInput): void;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { JamError } from "../domain/errors.js";
|
|
2
|
+
import { CREATABLE_FIELDS, } from "../domain/write.js";
|
|
3
|
+
/**
|
|
4
|
+
* What JAM will agree to create, decided from what Jira says it accepts.
|
|
5
|
+
*
|
|
6
|
+
* Creation is the one write with no issue to look at first, so every check
|
|
7
|
+
* here is against the project's create schema instead. The rule throughout is
|
|
8
|
+
* the one the rest of the write plane follows: resolve against what Jira just
|
|
9
|
+
* reported, never against what the caller asserted or what a name suggests. An
|
|
10
|
+
* issue type id is not derived from a type name, a priority is not sent
|
|
11
|
+
* because it looked plausible, and a required field JAM cannot express is
|
|
12
|
+
* refused here rather than posted and rejected as a 400.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Jira field ids for the fields JAM can put on a create.
|
|
16
|
+
*
|
|
17
|
+
* The bridge between the public contract (CREATABLE_FIELDS, which an agent
|
|
18
|
+
* sees) and Jira's own ids (which the required-field gate compares against).
|
|
19
|
+
* Both directions matter: one decides what may be asked for, the other decides
|
|
20
|
+
* what counts as "JAM supplies this".
|
|
21
|
+
*/
|
|
22
|
+
export const CREATE_FIELD_IDS = {
|
|
23
|
+
issueType: "issuetype",
|
|
24
|
+
summary: "summary",
|
|
25
|
+
description: "description",
|
|
26
|
+
priority: "priority",
|
|
27
|
+
labels: "labels",
|
|
28
|
+
components: "components",
|
|
29
|
+
};
|
|
30
|
+
/** Always sent by JAM, so a project requiring them is still servable. */
|
|
31
|
+
const ALWAYS_SUPPLIED = [CREATE_FIELD_IDS.issueType, CREATE_FIELD_IDS.summary, "project"];
|
|
32
|
+
/**
|
|
33
|
+
* Fields JAM can supply when asked, and so can satisfy a required flag - but
|
|
34
|
+
* only if the caller actually asked for them. Required-and-absent is refused.
|
|
35
|
+
*/
|
|
36
|
+
const SUPPLIABLE_ON_REQUEST = {
|
|
37
|
+
[CREATE_FIELD_IDS.description]: "description",
|
|
38
|
+
[CREATE_FIELD_IDS.priority]: "priority",
|
|
39
|
+
[CREATE_FIELD_IDS.labels]: "labels",
|
|
40
|
+
[CREATE_FIELD_IDS.components]: "components",
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Match a requested issue type against the ones Jira offers for this project.
|
|
44
|
+
*
|
|
45
|
+
* Case-insensitive, because "task" and "Task" are the same intent and an agent
|
|
46
|
+
* has no way to learn Jira's casing before asking. Nothing else is inferred:
|
|
47
|
+
* the id comes from Jira's own list, and a type that is not on it is refused
|
|
48
|
+
* with the list attached, so the next move is to pick one rather than to
|
|
49
|
+
* rephrase the same one.
|
|
50
|
+
*
|
|
51
|
+
* Subtask types are refused separately. They need a parent, which is not in
|
|
52
|
+
* this version's contract, so "not available" would be the wrong answer - the
|
|
53
|
+
* type exists, and JAM cannot use it yet.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveIssueType(requested, available) {
|
|
56
|
+
const wanted = requested.trim().toLowerCase();
|
|
57
|
+
const match = available.find((t) => t.name.toLowerCase() === wanted);
|
|
58
|
+
if (!match) {
|
|
59
|
+
const creatable = available.filter((t) => !t.subtask).map((t) => t.name);
|
|
60
|
+
throw new JamError("JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", creatable.length === 0
|
|
61
|
+
? `Jira offers no issue types this account can create in this project, so "${requested}" cannot be created.`
|
|
62
|
+
: `"${requested}" is not an issue type this account can create in this project. Available: ${creatable.join(", ")}.`, {
|
|
63
|
+
requested,
|
|
64
|
+
available: available.map((t) => ({ id: t.id, name: t.name, subtask: t.subtask })),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (match.subtask) {
|
|
68
|
+
throw new JamError("JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", `"${match.name}" is a subtask type, which needs a parent issue. JAM does not set a parent, so it cannot create one.`, { requested, issueType: match.name, reason: "SUBTASK_UNSUPPORTED" });
|
|
69
|
+
}
|
|
70
|
+
return match;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Refuse a create whose project requires something JAM cannot put on it.
|
|
74
|
+
*
|
|
75
|
+
* The alternative - post it and let Jira answer 400 - is worse twice over: the
|
|
76
|
+
* agent gets a vendor error instead of a JAM decision, and creation is the one
|
|
77
|
+
* write where "did it happen?" is expensive to answer after the fact. So the
|
|
78
|
+
* answer is worked out before anything is sent.
|
|
79
|
+
*
|
|
80
|
+
* A field Jira says it will default is not JAM's to supply. That is Jira
|
|
81
|
+
* stating a fact about its own configuration, not JAM guessing one.
|
|
82
|
+
*
|
|
83
|
+
* Returns the required field ids, which the plan records so apply can tell a
|
|
84
|
+
* newly-required field from one that was always there.
|
|
85
|
+
*/
|
|
86
|
+
export function assertRequiredFieldsSupported(fields, input) {
|
|
87
|
+
const required = fields.filter((f) => f.required);
|
|
88
|
+
const unsupported = [];
|
|
89
|
+
for (const field of required) {
|
|
90
|
+
if (ALWAYS_SUPPLIED.includes(field.id))
|
|
91
|
+
continue;
|
|
92
|
+
if (field.hasDefaultValue)
|
|
93
|
+
continue;
|
|
94
|
+
const inputKey = SUPPLIABLE_ON_REQUEST[field.id];
|
|
95
|
+
if (!inputKey) {
|
|
96
|
+
unsupported.push({ id: field.id, name: field.name, reason: "NOT_IN_JAM_CREATE_CONTRACT" });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (input[inputKey] === undefined) {
|
|
100
|
+
unsupported.push({ id: field.id, name: field.name, reason: "REQUIRED_BUT_NOT_PROVIDED" });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (unsupported.length > 0) {
|
|
104
|
+
const named = unsupported.map((f) => `${f.name} (${f.id})`).join(", ");
|
|
105
|
+
const it = unsupported.length === 1 ? "it" : "them";
|
|
106
|
+
throw new JamError("JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", `This project requires ${named} when creating this issue type, and JAM cannot supply ${it}. Create this issue in Jira instead - JAM will not send a create it already knows Jira will reject.`, { unsupported, supported: [...CREATABLE_FIELDS] });
|
|
107
|
+
}
|
|
108
|
+
return required.map((f) => f.id);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Turn a requested value into one Jira currently offers for that field.
|
|
112
|
+
*
|
|
113
|
+
* An unconstrained field passes the value through: there is no list to check
|
|
114
|
+
* it against, and inventing one would refuse valid input. Absent and empty are
|
|
115
|
+
* different - absent means Jira did not constrain the field, empty means it
|
|
116
|
+
* constrains it and offers nothing.
|
|
117
|
+
*
|
|
118
|
+
* The shape is resolveTransition's, deliberately: human intent, then
|
|
119
|
+
* Jira-provided candidates, then a concrete Jira value. Nothing in between
|
|
120
|
+
* guesses.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveAllowedValue(field, requested, label) {
|
|
123
|
+
if (!field?.allowedValues)
|
|
124
|
+
return { requested, resolved: requested };
|
|
125
|
+
const wanted = requested.trim().toLowerCase();
|
|
126
|
+
const match = field.allowedValues.find((v) => v.name?.toLowerCase() === wanted);
|
|
127
|
+
if (!match?.name) {
|
|
128
|
+
const allowed = field.allowedValues.map((v) => v.name).filter(Boolean);
|
|
129
|
+
throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", allowed.length === 0
|
|
130
|
+
? `Jira offers no ${label} values for this project and issue type, so "${requested}" cannot be set.`
|
|
131
|
+
: `"${requested}" is not an allowed ${label} for this project and issue type. Allowed: ${allowed.join(", ")}.`, { field: field.id, requested, allowed });
|
|
132
|
+
}
|
|
133
|
+
return { requested, resolved: match.name };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Are this plan's premises still true?
|
|
137
|
+
*
|
|
138
|
+
* Semantic, not a document comparison. Comparing a hash of the metadata would
|
|
139
|
+
* make an unrelated optional field appearing on the create screen invalidate
|
|
140
|
+
* every outstanding plan - which is wrong, and on an active project constant.
|
|
141
|
+
* What matters is narrower: the issue type still exists, no new required field
|
|
142
|
+
* has appeared that JAM cannot fill, and every value resolved from an allowed
|
|
143
|
+
* list is still on it.
|
|
144
|
+
*
|
|
145
|
+
* Everything else about the schema may change freely between plan and apply.
|
|
146
|
+
*/
|
|
147
|
+
export function assertSchemaUnchanged(requirements, issueTypes, fields, input) {
|
|
148
|
+
const stillOffered = issueTypes.find((t) => t.id === requirements.issueTypeId);
|
|
149
|
+
if (!stillOffered) {
|
|
150
|
+
throw schemaChanged(`Issue type ${requirements.issueTypeName} is no longer available to this account in this project.`, { issueTypeId: requirements.issueTypeId, issueType: requirements.issueTypeName });
|
|
151
|
+
}
|
|
152
|
+
// A required field JAM cannot fill is a refusal whether it was there at plan
|
|
153
|
+
// time or arrived since - but arriving since is a changed schema rather than
|
|
154
|
+
// a bad request, so it is reported as one.
|
|
155
|
+
try {
|
|
156
|
+
assertRequiredFieldsSupported(fields, input);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
if (err instanceof JamError && err.code === "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED") {
|
|
160
|
+
throw schemaChanged("The create screen for this issue type now requires a field JAM cannot supply.", { cause: err.details });
|
|
161
|
+
}
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
const byId = new Map(fields.map((f) => [f.id, f]));
|
|
165
|
+
for (const resolved of requirements.resolvedValues) {
|
|
166
|
+
const allowed = byId.get(resolved.fieldId)?.allowedValues;
|
|
167
|
+
// A field that stopped being constrained is not a problem: the value JAM
|
|
168
|
+
// resolved is still a value, and Jira is no longer restricting it.
|
|
169
|
+
if (!allowed)
|
|
170
|
+
continue;
|
|
171
|
+
if (!allowed.some((v) => v.name === resolved.resolved)) {
|
|
172
|
+
throw schemaChanged(`"${resolved.resolved}" is no longer an allowed value for ${resolved.fieldId} in this project.`, {
|
|
173
|
+
field: resolved.fieldId,
|
|
174
|
+
planned: resolved.resolved,
|
|
175
|
+
allowed: allowed.map((v) => v.name).filter(Boolean),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function schemaChanged(what, details) {
|
|
181
|
+
return new JamError("JAM_WRITE_SCHEMA_CHANGED", `${what} This plan was built on the create schema as it was, so it no longer describes a create JAM can make. Nothing was created - plan again against the current schema.`, details);
|
|
182
|
+
}
|