@jam-mcp/server 1.0.0 → 1.1.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 (35) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +86 -65
  3. package/dist/adapters/jira-cloud/jira-client.d.ts +10 -1
  4. package/dist/adapters/jira-cloud/jira-client.js +1 -1
  5. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +35 -6
  6. package/dist/adapters/jira-cloud/jira-write.adapter.js +90 -10
  7. package/dist/application/apply-write.d.ts +25 -0
  8. package/dist/application/apply-write.js +151 -0
  9. package/dist/application/plan-write.d.ts +32 -0
  10. package/dist/application/plan-write.js +167 -0
  11. package/dist/application/write-plan-store.d.ts +42 -0
  12. package/dist/application/write-plan-store.js +69 -0
  13. package/dist/bootstrap/boot-health-gate.js +2 -2
  14. package/dist/bootstrap/mcp-config-merger.d.ts +8 -7
  15. package/dist/bootstrap/mcp-config-merger.js +7 -7
  16. package/dist/bootstrap/setup-plan.d.ts +8 -0
  17. package/dist/bootstrap/setup-plan.js +3 -2
  18. package/dist/cli/setup-wizard.js +1 -1
  19. package/dist/cli-entry.js +33 -33
  20. package/dist/deps.d.ts +15 -0
  21. package/dist/deps.js +8 -0
  22. package/dist/domain/errors.d.ts +1 -1
  23. package/dist/domain/errors.js +13 -0
  24. package/dist/domain/write.d.ts +117 -0
  25. package/dist/domain/write.js +33 -0
  26. package/dist/mcp/create-server.d.ts +8 -3
  27. package/dist/mcp/create-server.js +30 -6
  28. package/dist/mcp/tools/jira-write-apply.tool.d.ts +3 -0
  29. package/dist/mcp/tools/jira-write-apply.tool.js +33 -0
  30. package/dist/mcp/tools/jira-write-plan.tool.d.ts +3 -0
  31. package/dist/mcp/tools/jira-write-plan.tool.js +57 -0
  32. package/dist/policy/write-policy.d.ts +60 -0
  33. package/dist/policy/write-policy.js +114 -0
  34. package/dist/ports/jira-write.port.d.ts +18 -4
  35. package/package.json +69 -69
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 colosair
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 colosair
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,65 +1,86 @@
1
- # @jam-mcp/server
2
-
3
- JAM (Jira Agent MCP) itself: the MCP server, the setup core, and the `jam` CLI.
4
-
5
- JAM is an agent-facing Jira **read** layer. It takes over the decisions an agent
6
- should not be making — which fields to request, when to paginate, when to read
7
- the comment thread, what to do when a result is too big — so everyday reads stay
8
- cheap and important judgements still get full context.
9
-
10
- ## The three tools
11
-
12
- The external contract is exactly three tools. Adding or renaming one is a
13
- breaking change.
14
-
15
- | Tool | Use it for |
16
- |---|---|
17
- | `jira_search` | listing, discovery, "what's open", picking candidates |
18
- | `jira_context` | readiness, blockers, dependencies, priority |
19
- | `jira_full` | agreement, contract, approval, closure |
20
-
21
- A `jira_search` result is never complete issue context nothing about
22
- agreement, approval, or done-ness follows from it.
23
-
24
- ## Evidence boundary
25
-
26
- Every result carries a `meta` block, and nothing is ever truncated silently.
27
-
28
- `meta.complete` means **JAM finished the Jira retrieval with no known loss**. It
29
- is not a statement about the project: not readiness, not "unblocked", not "the
30
- whole story". What JAM did not look at is named in the same block —
31
- `evidenceScope` and `limitations` call out the repository, external sources, and
32
- dependencies that live outside Jira. `provenance` and `source` say where the
33
- records came from.
34
-
35
- So an empty comment thread on a complete read is a complete read *of Jira*, not
36
- proof that nothing was agreed. If the issue points at an external canonical
37
- source, that source is what settles the question.
38
-
39
- ## Running it
40
-
41
- Normally you don't run this package directly — your coding agent launches
42
- [`@jam-mcp/launcher`](https://www.npmjs.com/package/@jam-mcp/launcher), which
43
- resolves which JAM build this machine runs and dispatches here. Going through
44
- the launcher is what keeps a committed `.mcp.json` free of machine-specific
45
- paths.
46
-
47
- The `jam` CLI is the same binary:
48
-
49
- ```text
50
- jam serve Run the MCP server over stdio
51
- jam doctor Diagnose config, credentials and Jira connectivity
52
- jam setup Wire up JAM and verify (personal by default; --shared for the team)
53
- jam auth login Store Jira credentials in this user's OS secret store
54
- jam runtime Show or change which JAM build this machine runs
55
- ```
56
-
57
- Credentials come from the process environment or this user's OS secret store —
58
- never from a repository file and never appear in logs, telemetry, or tool
59
- results. `stdout` is reserved for the MCP protocol and for JSON output;
60
- diagnostics go to `stderr`.
61
-
62
- ## More
63
-
64
- - [Repository README](https://github.com/colosair/jam#readme)
65
- - [JAM design of record](https://github.com/colosair/jam/blob/main/docs/architecture/jira-agent-mcp-design.md)
1
+ # @jam-mcp/server
2
+
3
+ JAM (Jira Agent MCP) itself: the MCP server, the setup core, and the `jam` CLI.
4
+
5
+ JAM is an agent-facing Jira **read** layer. It takes over the decisions an agent
6
+ should not be making — which fields to request, when to paginate, when to read
7
+ the comment thread, what to do when a result is too big — so everyday reads stay
8
+ cheap and important judgements still get full context.
9
+
10
+ ## The tools
11
+
12
+ The external contract is five tools. Adding or renaming one is a breaking
13
+ change.
14
+
15
+ | Tool | Use it for |
16
+ |---|---|
17
+ | `jira_search` | listing, discovery, "what's open", picking candidates |
18
+ | `jira_context` | readiness, blockers, dependencies, priority |
19
+ | `jira_full` | agreement, contract, approval, closure |
20
+ | `jira_write_plan` | work out how to change an issue — changes nothing |
21
+ | `jira_write_apply` | apply a plan, then confirm it by reading the issue back |
22
+
23
+ A `jira_search` result is never complete issue context — nothing about
24
+ agreement, approval, or done-ness follows from it.
25
+
26
+ Writing is deliberately two calls. `jira_write_apply` takes a `planId` and no
27
+ payload, so a change cannot be made that JAM has not first read the issue for,
28
+ checked against the configured project, and described. Before writing it
29
+ re-reads the issue and refuses if it moved; after writing it reads again and
30
+ refuses to report success unless the intended result is actually there. An
31
+ ambiguous failure is reported as uncertain rather than retried — retrying a
32
+ write that may have landed is how one comment becomes two.
33
+
34
+ Three operations: `comment.add` (plain text, converted to ADF here),
35
+ `field.update` (summary, priority, labels, components), and `status.transition`
36
+ (matched against the transitions Jira currently offers, never a guessed id).
37
+
38
+ ## Evidence boundary
39
+
40
+ Every result carries a `meta` block, and nothing is ever truncated silently.
41
+
42
+ `meta.complete` means **JAM finished the Jira retrieval with no known loss**. It
43
+ is not a statement about the project: not readiness, not "unblocked", not "the
44
+ whole story". What JAM did not look at is named in the same block —
45
+ `evidenceScope` and `limitations` call out the repository, external sources, and
46
+ dependencies that live outside Jira. `provenance` and `source` say where the
47
+ records came from.
48
+
49
+ So an empty comment thread on a complete read is a complete read *of Jira*, not
50
+ proof that nothing was agreed. If the issue points at an external canonical
51
+ source, that source is what settles the question.
52
+
53
+ ## Running it
54
+
55
+ Don't install this package to use JAM. Your coding agent launches
56
+ [`@jam-mcp/launcher`](https://www.npmjs.com/package/@jam-mcp/launcher), which
57
+ reads your `~/.jam/config.yaml`, decides which JAM build this machine should
58
+ run, and dispatches here. Naming the server directly instead pins one machine
59
+ to one build and bypasses that choice and it is what keeps a committed
60
+ `.mcp.json` free of machine-specific paths.
61
+
62
+ The same binary carries the CLI, and every command below is reachable through
63
+ the launcher:
64
+
65
+ ```text
66
+ serve Run the MCP server over stdio
67
+ doctor Diagnose config, credentials and Jira connectivity
68
+ setup Wire up JAM and verify (personal by default; --shared for the team)
69
+ auth login Store Jira credentials in this user's OS secret store
70
+ runtime Show or change which JAM build this machine runs
71
+ ```
72
+
73
+ Written out, that is `npx --yes @jam-mcp/launcher@1.1.0 doctor`, or just `jam
74
+ doctor` if you took the launcher's optional global install. Starting from
75
+ nothing — no install, no runtime chosen yet — use
76
+ `npx --yes @jam-mcp/bootstrap@1.1.0 init` instead.
77
+
78
+ Credentials come from the process environment or this user's OS secret store —
79
+ never from a repository file — and never appear in logs, telemetry, or tool
80
+ results. `stdout` is reserved for the MCP protocol and for JSON output;
81
+ diagnostics go to `stderr`.
82
+
83
+ ## More
84
+
85
+ - [Repository README](https://github.com/colosair/jam#readme)
86
+ - [JAM design of record](https://github.com/colosair/jam/blob/main/docs/architecture/jira-agent-mcp-design.md)
@@ -2,9 +2,18 @@ import { JamError } from "../../domain/errors.js";
2
2
  import type { CredentialPort } from "../../ports/credentials.port.js";
3
3
  export type JiraRequest = {
4
4
  path: string;
5
- method?: "GET" | "POST";
5
+ method?: "GET" | "POST" | "PUT";
6
6
  query?: Record<string, string | number | undefined>;
7
7
  body?: unknown;
8
+ /**
9
+ * Whether a transient failure may be retried.
10
+ *
11
+ * Reads say yes and get the retry loop below. Writes say no: a request that
12
+ * timed out may already have been applied, so resending it is how one
13
+ * comment becomes two. The write path resolves that ambiguity by reading the
14
+ * issue back, never by trying again.
15
+ */
16
+ retry?: boolean;
8
17
  };
9
18
  export type JiraResponse<T> = {
10
19
  data: T;
@@ -67,7 +67,7 @@ export class JiraClient {
67
67
  // ponytail: fixed 2 retries on transient statuses; add backoff tuning if
68
68
  // `complete` searches start tripping Jira's rate limiter in practice.
69
69
  const transient = res.status === 429 || res.status >= 500;
70
- if (transient && attempt < MAX_RETRIES) {
70
+ if (req.retry !== false && transient && attempt < MAX_RETRIES) {
71
71
  lastError = error;
72
72
  await sleep(retryDelayMs(res.headers.get("retry-after"), attempt));
73
73
  continue;
@@ -1,12 +1,41 @@
1
+ import type { JiraTransition } from "../../domain/write.js";
2
+ import type { CredentialPort } from "../../ports/credentials.port.js";
1
3
  import type { JiraWritePort } from "../../ports/jira-write.port.js";
2
4
  /**
3
- * Deliberate stub. The first release is read-only; the existing Atlassian MCP
4
- * remains the write path. The port exists so adding writes later is an adapter
5
- * swap rather than an application-layer change.
5
+ * Jira Cloud REST v3, mutating half.
6
+ *
7
+ * Every request here sets `retry: false`. The read adapter's retry loop is
8
+ * correct for reads and dangerous here: a request that times out after Jira
9
+ * accepted it is indistinguishable from one Jira never saw, and resending it
10
+ * duplicates the change. The application layer resolves that by reading the
11
+ * issue back - see applyWritePlan.
12
+ *
13
+ * This adapter also never reads its own work. Confirmation is a direct issue
14
+ * GET through the read port, which keeps "what JAM did" and "what Jira shows"
15
+ * two separate observations rather than one hopeful one.
6
16
  */
7
- export declare class UnsupportedJiraWriteAdapter implements JiraWritePort {
8
- updateIssue(): Promise<void>;
9
- addComment(): Promise<{
17
+ export declare class JiraCloudWriteAdapter implements JiraWritePort {
18
+ private readonly client;
19
+ constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
20
+ updateIssue(key: string, fields: Record<string, unknown>): Promise<void>;
21
+ /**
22
+ * Add a comment, converting plain text to ADF here rather than accepting ADF.
23
+ *
24
+ * Jira's comment body is a document tree, and letting an agent supply one
25
+ * would mean accepting arbitrary structure - panels, mentions, embedded
26
+ * content - through a field that reads like "text". The conversion is narrow
27
+ * on purpose: paragraphs, and nothing else.
28
+ */
29
+ addComment(key: string, body: string): Promise<{
10
30
  id: string;
11
31
  }>;
32
+ getTransitions(key: string): Promise<JiraTransition[]>;
33
+ transitionIssue(key: string, transitionId: string): Promise<void>;
12
34
  }
35
+ /**
36
+ * Plain text to the narrowest ADF that represents it.
37
+ *
38
+ * Blank lines separate paragraphs; everything else is literal. No markdown is
39
+ * interpreted, so a comment containing `*` or `#` says what it says.
40
+ */
41
+ export declare function textToAdf(text: string): unknown;
@@ -1,17 +1,97 @@
1
1
  import { JamError } from "../../domain/errors.js";
2
+ import { JiraClient } from "./jira-client.js";
2
3
  /**
3
- * Deliberate stub. The first release is read-only; the existing Atlassian MCP
4
- * remains the write path. The port exists so adding writes later is an adapter
5
- * swap rather than an application-layer change.
4
+ * Jira Cloud REST v3, mutating half.
5
+ *
6
+ * Every request here sets `retry: false`. The read adapter's retry loop is
7
+ * correct for reads and dangerous here: a request that times out after Jira
8
+ * accepted it is indistinguishable from one Jira never saw, and resending it
9
+ * duplicates the change. The application layer resolves that by reading the
10
+ * issue back - see applyWritePlan.
11
+ *
12
+ * This adapter also never reads its own work. Confirmation is a direct issue
13
+ * GET through the read port, which keeps "what JAM did" and "what Jira shows"
14
+ * two separate observations rather than one hopeful one.
6
15
  */
7
- export class UnsupportedJiraWriteAdapter {
8
- async updateIssue() {
9
- throw notSupported();
16
+ export class JiraCloudWriteAdapter {
17
+ client;
18
+ constructor(credentials, fetchImpl) {
19
+ this.client = fetchImpl
20
+ ? new JiraClient(credentials, fetchImpl)
21
+ : new JiraClient(credentials);
10
22
  }
11
- async addComment() {
12
- throw notSupported();
23
+ async updateIssue(key, fields) {
24
+ await this.client.request({
25
+ path: `rest/api/3/issue/${encodeURIComponent(key)}`,
26
+ method: "PUT",
27
+ body: { fields },
28
+ retry: false,
29
+ });
30
+ }
31
+ /**
32
+ * Add a comment, converting plain text to ADF here rather than accepting ADF.
33
+ *
34
+ * Jira's comment body is a document tree, and letting an agent supply one
35
+ * would mean accepting arbitrary structure - panels, mentions, embedded
36
+ * content - through a field that reads like "text". The conversion is narrow
37
+ * on purpose: paragraphs, and nothing else.
38
+ */
39
+ async addComment(key, body) {
40
+ const { data } = await this.client.request({
41
+ path: `rest/api/3/issue/${encodeURIComponent(key)}/comment`,
42
+ method: "POST",
43
+ body: { body: textToAdf(body) },
44
+ retry: false,
45
+ });
46
+ if (!data?.id) {
47
+ // Jira accepted it but told us nothing identifying. Treat that as
48
+ // unconfirmed rather than inventing an id: the caller's direct read is
49
+ // what decides whether the comment exists.
50
+ throw new JamError("JAM_WRITE_UNCERTAIN", `Jira accepted a comment on ${key} but returned no comment id, so JAM cannot confirm which comment it created.`, { issueKey: key });
51
+ }
52
+ return { id: data.id };
53
+ }
54
+ async getTransitions(key) {
55
+ const { data } = await this.client.request({
56
+ path: `rest/api/3/issue/${encodeURIComponent(key)}/transitions`,
57
+ // A read, but on the write path: its answer decides a mutation, so a
58
+ // retried-and-stale transition list would be worse than a failure.
59
+ retry: false,
60
+ });
61
+ return (data.transitions ?? [])
62
+ .filter((t) => typeof t.id === "string")
63
+ .map((t) => ({
64
+ id: t.id,
65
+ name: t.name ?? t.id,
66
+ to: t.to?.name ?? t.name ?? "",
67
+ }));
68
+ }
69
+ async transitionIssue(key, transitionId) {
70
+ await this.client.request({
71
+ path: `rest/api/3/issue/${encodeURIComponent(key)}/transitions`,
72
+ method: "POST",
73
+ body: { transition: { id: transitionId } },
74
+ retry: false,
75
+ });
13
76
  }
14
77
  }
15
- function notSupported() {
16
- return new JamError("CONFIG_INVALID", "JAM is read-only in this release. Use the Atlassian MCP for Jira writes.");
78
+ /**
79
+ * Plain text to the narrowest ADF that represents it.
80
+ *
81
+ * Blank lines separate paragraphs; everything else is literal. No markdown is
82
+ * interpreted, so a comment containing `*` or `#` says what it says.
83
+ */
84
+ export function textToAdf(text) {
85
+ const paragraphs = text
86
+ .split(/\n{2,}/)
87
+ .map((block) => block.trim())
88
+ .filter(Boolean);
89
+ return {
90
+ type: "doc",
91
+ version: 1,
92
+ content: (paragraphs.length > 0 ? paragraphs : [text]).map((block) => ({
93
+ type: "paragraph",
94
+ content: [{ type: "text", text: block }],
95
+ })),
96
+ };
17
97
  }
@@ -0,0 +1,25 @@
1
+ import type { JamDeps } from "../deps.js";
2
+ import type { WriteApplyReceipt } from "../domain/write.js";
3
+ export type ApplyWriteRequest = {
4
+ planId: string;
5
+ };
6
+ /**
7
+ * Execute a plan JAM made, then go and look at what happened.
8
+ *
9
+ * Three things happen in order, and none of them can be skipped:
10
+ *
11
+ * 1. Re-read the issue and compare its revision to the plan's. A plan that
12
+ * was valid is not the same as a plan that is still valid.
13
+ * 2. Send the mutation the plan recorded. The caller never supplies it, so
14
+ * there is no path from "an agent wants to write X" to "JAM writes X"
15
+ * that does not pass through planning.
16
+ * 3. Read the issue again and check the intended result is actually there.
17
+ * Jira accepting a request is not evidence that the issue changed - a
18
+ * transition can be accepted and land somewhere else, a field update can
19
+ * be silently dropped by a screen configuration.
20
+ *
21
+ * A write that cannot be confirmed is never reported as success. Depending on
22
+ * why, that is JAM_WRITE_VERIFICATION_FAILED (Jira answered, and shows
23
+ * something else) or JAM_WRITE_UNCERTAIN (we do not know whether it landed).
24
+ */
25
+ export declare function applyWritePlan(deps: JamDeps, request: ApplyWriteRequest): Promise<WriteApplyReceipt>;
@@ -0,0 +1,151 @@
1
+ import { JamError, toJamError } from "../domain/errors.js";
2
+ import { readModeAfterWrite } from "../policy/consistency-policy.js";
3
+ import { assertUnchanged } from "../policy/write-policy.js";
4
+ import { readIssue } from "./plan-write.js";
5
+ /**
6
+ * Execute a plan JAM made, then go and look at what happened.
7
+ *
8
+ * Three things happen in order, and none of them can be skipped:
9
+ *
10
+ * 1. Re-read the issue and compare its revision to the plan's. A plan that
11
+ * was valid is not the same as a plan that is still valid.
12
+ * 2. Send the mutation the plan recorded. The caller never supplies it, so
13
+ * there is no path from "an agent wants to write X" to "JAM writes X"
14
+ * that does not pass through planning.
15
+ * 3. Read the issue again and check the intended result is actually there.
16
+ * Jira accepting a request is not evidence that the issue changed - a
17
+ * transition can be accepted and land somewhere else, a field update can
18
+ * be silently dropped by a screen configuration.
19
+ *
20
+ * A write that cannot be confirmed is never reported as success. Depending on
21
+ * why, that is JAM_WRITE_VERIFICATION_FAILED (Jira answered, and shows
22
+ * something else) or JAM_WRITE_UNCERTAIN (we do not know whether it landed).
23
+ */
24
+ export async function applyWritePlan(deps, request) {
25
+ const plan = deps.writePlans.take(request.planId);
26
+ // Post-write reads are direct by policy; so is this pre-write one. Both
27
+ // decide a mutation, and a search result can lag behind the issue it names.
28
+ if (readModeAfterWrite() !== "direct") {
29
+ throw new JamError("CONFIG_INVALID", "Write confirmation must use a direct issue read.");
30
+ }
31
+ const current = await readIssue(deps, plan.issueKey);
32
+ assertUnchanged(plan.issueKey, plan.baseUpdated, current.updated);
33
+ const outcome = await mutate(deps, plan);
34
+ const after = await verify(deps, plan);
35
+ deps.writePlans.consume(plan.planId);
36
+ return {
37
+ status: "applied",
38
+ issue: plan.issueKey,
39
+ operation: plan.operation,
40
+ before: plan.before,
41
+ after,
42
+ verified: true,
43
+ ...(outcome.commentId ? { commentId: outcome.commentId } : {}),
44
+ };
45
+ }
46
+ /**
47
+ * Send the mutation, once.
48
+ *
49
+ * There is no retry here and there must not be one. A request that fails
50
+ * ambiguously - a timeout, a dropped connection - may already have been
51
+ * applied, and resending it turns one comment into two or replays a
52
+ * transition. So an ambiguous failure is converted into JAM_WRITE_UNCERTAIN
53
+ * and handed back with what to do about it: look, do not retry.
54
+ */
55
+ async function mutate(deps, plan) {
56
+ try {
57
+ switch (plan.mutation.kind) {
58
+ case "comment": {
59
+ const { id } = await deps.jiraWrite.addComment(plan.issueKey, plan.mutation.text);
60
+ return { commentId: id };
61
+ }
62
+ case "fields":
63
+ await deps.jiraWrite.updateIssue(plan.issueKey, plan.mutation.fields);
64
+ return {};
65
+ case "transition":
66
+ await deps.jiraWrite.transitionIssue(plan.issueKey, plan.mutation.transitionId);
67
+ return {};
68
+ }
69
+ }
70
+ catch (err) {
71
+ const jamError = toJamError(err);
72
+ if (!isAmbiguous(jamError))
73
+ throw jamError;
74
+ throw new JamError("JAM_WRITE_UNCERTAIN", `JAM could not tell whether the ${plan.operation} on ${plan.issueKey} was applied: ${jamError.message} Read the issue to find out - do not retry this write, which could apply it twice.`, { issueKey: plan.issueKey, operation: plan.operation, cause: jamError.code });
75
+ }
76
+ }
77
+ /**
78
+ * A failure that says nothing about whether Jira acted.
79
+ *
80
+ * A 403 or a 400 is a decision Jira made and did not act on. A dropped
81
+ * connection or a 5xx is not: the request may have been processed before the
82
+ * answer went missing.
83
+ */
84
+ function isAmbiguous(err) {
85
+ return err.code === "JIRA_UNAVAILABLE" || err.code === "RATE_LIMITED";
86
+ }
87
+ /**
88
+ * Confirm by reading, and say what was expected when the reading disagrees.
89
+ *
90
+ * Comments are confirmed by the comment appearing, not by a count: another
91
+ * writer could have added one in between, and a count would accept theirs as
92
+ * ours.
93
+ */
94
+ async function verify(deps, plan) {
95
+ const issue = await readIssue(deps, plan.issueKey);
96
+ if (plan.mutation.kind === "comment") {
97
+ const { comments } = await deps.jira.getIssues({
98
+ keys: [plan.issueKey],
99
+ fields: ["summary", "status", "comment", "updated"],
100
+ }).then((r) => ({ comments: r.issues[0]?.comments ?? [] }));
101
+ const wanted = plan.mutation.text.trim();
102
+ const found = comments.some((c) => c.body.trim() === wanted);
103
+ if (!found) {
104
+ throw verificationFailed(plan, { commentAdded: wanted }, { comments: comments.length });
105
+ }
106
+ return { comments: comments.length, commentAdded: wanted };
107
+ }
108
+ const observed = observedFor(plan, issue);
109
+ for (const [field, expected] of Object.entries(plan.intendedAfter)) {
110
+ if (!sameValue(observed[field], expected)) {
111
+ throw verificationFailed(plan, plan.intendedAfter, observed);
112
+ }
113
+ }
114
+ return observed;
115
+ }
116
+ function observedFor(plan, issue) {
117
+ const observed = {};
118
+ for (const field of Object.keys(plan.intendedAfter)) {
119
+ switch (field) {
120
+ case "status":
121
+ observed[field] = issue.status;
122
+ break;
123
+ case "summary":
124
+ observed[field] = issue.summary;
125
+ break;
126
+ case "priority":
127
+ observed[field] = issue.priority;
128
+ break;
129
+ case "labels":
130
+ observed[field] = issue.labels;
131
+ break;
132
+ case "components":
133
+ observed[field] = issue.components;
134
+ break;
135
+ default:
136
+ observed[field] = undefined;
137
+ }
138
+ }
139
+ return observed;
140
+ }
141
+ function sameValue(observed, expected) {
142
+ if (Array.isArray(expected) || Array.isArray(observed)) {
143
+ const a = Array.isArray(observed) ? [...observed].map(String).sort() : [];
144
+ const b = Array.isArray(expected) ? [...expected].map(String).sort() : [];
145
+ return a.length === b.length && a.every((value, i) => value === b[i]);
146
+ }
147
+ return observed === expected;
148
+ }
149
+ function verificationFailed(plan, expected, observed) {
150
+ return new JamError("JAM_WRITE_VERIFICATION_FAILED", `Jira accepted the ${plan.operation} on ${plan.issueKey}, but a direct read does not show the intended result. The issue may have been changed by something else, or a workflow rule may have altered the outcome.`, { issueKey: plan.issueKey, operation: plan.operation, expected, observed });
151
+ }
@@ -0,0 +1,32 @@
1
+ import type { JamDeps } from "../deps.js";
2
+ import type { FullIssueContext } from "../domain/context.js";
3
+ import type { WritePlan, WritePlanReceipt } from "../domain/write.js";
4
+ export type PlanWriteRequest = {
5
+ key: string;
6
+ operation: string;
7
+ input: Record<string, unknown>;
8
+ };
9
+ /**
10
+ * Work out whether a requested change is currently possible, and describe it.
11
+ *
12
+ * Reads only. Nothing here mutates Jira, and that is the whole point of the
13
+ * step: the agent gets to see what the issue looks like now, what JAM would
14
+ * do to it, and what a direct read will have to show before JAM will call it
15
+ * done - all before anything has happened.
16
+ *
17
+ * The order matters. Scope and operation are checked before any Jira call, so
18
+ * an out-of-scope key costs nothing and comes back as a JAM refusal rather
19
+ * than a 404. Everything after that is derived from the issue as Jira reports
20
+ * it right now, never from what the caller asserted about it.
21
+ */
22
+ export declare function planWrite(deps: JamDeps, request: PlanWriteRequest): Promise<{
23
+ plan: WritePlan;
24
+ receipt: WritePlanReceipt;
25
+ }>;
26
+ /**
27
+ * The issue as Jira has it, read directly by key.
28
+ *
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.
31
+ */
32
+ export declare function readIssue(deps: JamDeps, issueKey: string): Promise<FullIssueContext>;