@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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/credentials/windows-user-env.d.ts +3 -1
  3. package/dist/adapters/credentials/windows-user-env.js +20 -1
  4. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.d.ts +27 -0
  5. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
  6. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
  7. package/dist/adapters/jira-cloud/jira-read.adapter.js +17 -0
  8. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +13 -7
  9. package/dist/adapters/jira-cloud/jira-write.adapter.js +25 -20
  10. package/dist/application/apply-create-issue.d.ts +20 -0
  11. package/dist/application/apply-create-issue.js +187 -0
  12. package/dist/application/apply-write.js +18 -3
  13. package/dist/application/plan-create-issue.d.ts +44 -0
  14. package/dist/application/plan-create-issue.js +188 -0
  15. package/dist/application/plan-write.d.ts +10 -3
  16. package/dist/application/plan-write.js +54 -11
  17. package/dist/application/write-plan-store.d.ts +2 -2
  18. package/dist/application/write-plan-store.js +13 -1
  19. package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
  20. package/dist/cli/auth.d.ts +6 -0
  21. package/dist/cli/auth.js +2 -1
  22. package/dist/deps.d.ts +11 -0
  23. package/dist/deps.js +6 -0
  24. package/dist/domain/adf.d.ts +35 -0
  25. package/dist/domain/adf.js +65 -0
  26. package/dist/domain/errors.d.ts +1 -1
  27. package/dist/domain/errors.js +9 -0
  28. package/dist/domain/write.d.ts +128 -15
  29. package/dist/domain/write.js +33 -1
  30. package/dist/mcp/create-server.d.ts +4 -0
  31. package/dist/mcp/create-server.js +5 -0
  32. package/dist/mcp/tools/jira-write-plan.tool.js +34 -6
  33. package/dist/policy/consistency-policy.d.ts +10 -4
  34. package/dist/policy/create-policy.d.ts +86 -0
  35. package/dist/policy/create-policy.js +182 -0
  36. package/dist/policy/write-policy.d.ts +10 -1
  37. package/dist/policy/write-policy.js +15 -1
  38. package/dist/ports/jira-create-metadata.port.d.ts +25 -0
  39. package/dist/ports/jira-create-metadata.port.js +1 -0
  40. package/dist/ports/jira-read.port.d.ts +23 -0
  41. package/dist/ports/jira-write.port.d.ts +9 -0
  42. package/package.json +2 -2
@@ -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
- export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput;
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
- * What a plan captured, and what it intends.
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 WritePlan = {
58
- planId: string;
146
+ export type ExistingIssueWritePlan = WritePlanCommon & {
147
+ kind: "existing-issue";
59
148
  issueKey: string;
60
- projectKey: string;
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 {};
@@ -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 = ["comment.add", "field.update", "status.transition"];
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
- key: z.string().min(1).describe('Issue key, e.g. "PROJECT-123". Must be in the configured project.'),
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
- .object({
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(", ")} - custom fields and assignee are not writable.`),
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
+ }