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