@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.
- package/LICENSE +21 -21
- package/README.md +86 -65
- package/dist/adapters/jira-cloud/jira-client.d.ts +10 -1
- package/dist/adapters/jira-cloud/jira-client.js +1 -1
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +35 -6
- package/dist/adapters/jira-cloud/jira-write.adapter.js +90 -10
- package/dist/application/apply-write.d.ts +25 -0
- package/dist/application/apply-write.js +151 -0
- package/dist/application/plan-write.d.ts +32 -0
- package/dist/application/plan-write.js +167 -0
- package/dist/application/write-plan-store.d.ts +42 -0
- package/dist/application/write-plan-store.js +69 -0
- package/dist/bootstrap/boot-health-gate.js +2 -2
- package/dist/bootstrap/mcp-config-merger.d.ts +8 -7
- package/dist/bootstrap/mcp-config-merger.js +7 -7
- package/dist/bootstrap/setup-plan.d.ts +8 -0
- package/dist/bootstrap/setup-plan.js +3 -2
- package/dist/cli/setup-wizard.js +1 -1
- package/dist/cli-entry.js +33 -33
- package/dist/deps.d.ts +15 -0
- package/dist/deps.js +8 -0
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +13 -0
- package/dist/domain/write.d.ts +117 -0
- package/dist/domain/write.js +33 -0
- package/dist/mcp/create-server.d.ts +8 -3
- package/dist/mcp/create-server.js +30 -6
- package/dist/mcp/tools/jira-write-apply.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-write-apply.tool.js +33 -0
- package/dist/mcp/tools/jira-write-plan.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-write-plan.tool.js +57 -0
- package/dist/policy/write-policy.d.ts +60 -0
- package/dist/policy/write-policy.js +114 -0
- package/dist/ports/jira-write.port.d.ts +18 -4
- 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
|
|
11
|
-
|
|
12
|
-
The external contract is
|
|
13
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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>;
|