@jam-mcp/server 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +86 -72
- package/dist/adapters/credentials/windows-user-env.d.ts +3 -1
- package/dist/adapters/credentials/windows-user-env.js +20 -1
- package/dist/adapters/jira-cloud/jira-client.d.ts +10 -1
- package/dist/adapters/jira-cloud/jira-client.js +1 -1
- package/dist/adapters/jira-cloud/jira-create-metadata.adapter.d.ts +27 -0
- package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
- package/dist/adapters/jira-cloud/jira-read.adapter.js +17 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +41 -6
- package/dist/adapters/jira-cloud/jira-write.adapter.js +96 -11
- package/dist/application/apply-create-issue.d.ts +20 -0
- package/dist/application/apply-create-issue.js +187 -0
- package/dist/application/apply-write.d.ts +25 -0
- package/dist/application/apply-write.js +166 -0
- package/dist/application/plan-create-issue.d.ts +44 -0
- package/dist/application/plan-create-issue.js +188 -0
- package/dist/application/plan-write.d.ts +39 -0
- package/dist/application/plan-write.js +210 -0
- package/dist/application/write-plan-store.d.ts +42 -0
- package/dist/application/write-plan-store.js +81 -0
- package/dist/bootstrap/boot-health-gate.js +2 -2
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/cli/auth.d.ts +6 -0
- package/dist/cli/auth.js +2 -1
- package/dist/cli-entry.js +33 -33
- package/dist/deps.d.ts +26 -0
- package/dist/deps.js +14 -0
- package/dist/domain/adf.d.ts +35 -0
- package/dist/domain/adf.js +65 -0
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +22 -0
- package/dist/domain/write.d.ts +230 -0
- package/dist/domain/write.js +65 -0
- package/dist/mcp/create-server.d.ts +12 -3
- package/dist/mcp/create-server.js +35 -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 +85 -0
- package/dist/policy/consistency-policy.d.ts +10 -4
- package/dist/policy/create-policy.d.ts +86 -0
- package/dist/policy/create-policy.js +182 -0
- package/dist/policy/write-policy.d.ts +69 -0
- package/dist/policy/write-policy.js +128 -0
- package/dist/ports/jira-create-metadata.port.d.ts +25 -0
- package/dist/ports/jira-create-metadata.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +23 -0
- package/dist/ports/jira-write.port.d.ts +27 -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,72 +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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.2.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.2.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)
|
|
@@ -15,6 +15,8 @@ export type RegQueryFn = (name: string) => string | undefined;
|
|
|
15
15
|
*/
|
|
16
16
|
export declare class WindowsUserEnvCredentialSource implements CredentialValueSource {
|
|
17
17
|
private readonly queryFn;
|
|
18
|
-
|
|
18
|
+
private readonly env;
|
|
19
|
+
constructor(queryFn?: RegQueryFn, env?: NodeJS.ProcessEnv);
|
|
19
20
|
read(): RawCredentialValues;
|
|
20
21
|
}
|
|
22
|
+
export declare function userEnvDisabled(env?: NodeJS.ProcessEnv): boolean;
|
|
@@ -15,12 +15,16 @@ import { CREDENTIAL_ENV_KEYS } from "./process-env.js";
|
|
|
15
15
|
*/
|
|
16
16
|
export class WindowsUserEnvCredentialSource {
|
|
17
17
|
queryFn;
|
|
18
|
-
|
|
18
|
+
env;
|
|
19
|
+
constructor(queryFn = defaultRegQuery, env = process.env) {
|
|
19
20
|
this.queryFn = queryFn;
|
|
21
|
+
this.env = env;
|
|
20
22
|
}
|
|
21
23
|
read() {
|
|
22
24
|
if (process.platform !== "win32")
|
|
23
25
|
return {};
|
|
26
|
+
if (userEnvDisabled(this.env))
|
|
27
|
+
return {};
|
|
24
28
|
const out = {};
|
|
25
29
|
for (const key of CREDENTIAL_ENV_KEYS) {
|
|
26
30
|
const value = this.queryFn(key)?.trim();
|
|
@@ -30,6 +34,21 @@ export class WindowsUserEnvCredentialSource {
|
|
|
30
34
|
return out;
|
|
31
35
|
}
|
|
32
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Escape hatch for isolated test sandboxes, matching JAM_DISABLE_SECRET_STORE.
|
|
39
|
+
*
|
|
40
|
+
* HKCU\Environment is per-user, not per-HOME, so repointing HOME does not make
|
|
41
|
+
* a sandbox credential-free on Windows: a developer who ran `setx JIRA_API_TOKEN`
|
|
42
|
+
* once has credentials that every process of theirs can see. Without this, a
|
|
43
|
+
* hermetic test would pass or fail depending on whose machine ran it, and
|
|
44
|
+
* "zero HOME" would be mistaken for "zero credentials".
|
|
45
|
+
*
|
|
46
|
+
* Not a user-facing feature. Production never sets it.
|
|
47
|
+
*/
|
|
48
|
+
const DISABLE_ENV = "JAM_DISABLE_USER_ENV";
|
|
49
|
+
export function userEnvDisabled(env = process.env) {
|
|
50
|
+
return Boolean(env[DISABLE_ENV]);
|
|
51
|
+
}
|
|
33
52
|
const VALUE_LINE = /^\s*\S+\s+REG_(?:SZ|EXPAND_SZ)\s+(.*)$/;
|
|
34
53
|
// Built from a char code rather than a literal backslash escape - a lone
|
|
35
54
|
// backslash before a letter isn't a recognized JS escape and silently
|
|
@@ -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;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CreateFieldMetadata, CreateIssueType } from "../../domain/write.js";
|
|
2
|
+
import type { CredentialPort } from "../../ports/credentials.port.js";
|
|
3
|
+
import type { JiraCreateMetadataPort } from "../../ports/jira-create-metadata.port.js";
|
|
4
|
+
/**
|
|
5
|
+
* Jira Cloud REST v3 create metadata, per project and per issue type.
|
|
6
|
+
*
|
|
7
|
+
* The two-endpoint form, not the aggregate `createmeta?expand=` one: the
|
|
8
|
+
* aggregate endpoint is deprecated on Jira Cloud and returns every issue type's
|
|
9
|
+
* every field in one document, which is both larger and less precise than the
|
|
10
|
+
* question being asked. Planning wants one project's issue types, and then one
|
|
11
|
+
* issue type's fields.
|
|
12
|
+
*
|
|
13
|
+
* `retry: false` throughout. These answers decide whether a create is possible
|
|
14
|
+
* and what it will contain, so a retried-and-stale answer is worse than a
|
|
15
|
+
* failure - the same reason `getTransitions` does not retry.
|
|
16
|
+
*
|
|
17
|
+
* Both mappers are defensive about shape. Jira omits fields it considers
|
|
18
|
+
* irrelevant and different deployments populate different ones, so anything
|
|
19
|
+
* unrecognised is dropped rather than guessed at: an entry JAM cannot read is
|
|
20
|
+
* an entry JAM must not claim to have understood.
|
|
21
|
+
*/
|
|
22
|
+
export declare class JiraCloudCreateMetadataAdapter implements JiraCreateMetadataPort {
|
|
23
|
+
private readonly client;
|
|
24
|
+
constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
|
|
25
|
+
getIssueTypes(projectKey: string): Promise<CreateIssueType[]>;
|
|
26
|
+
getCreateFields(projectKey: string, issueTypeId: string): Promise<CreateFieldMetadata[]>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { JiraClient } from "./jira-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Jira Cloud REST v3 create metadata, per project and per issue type.
|
|
4
|
+
*
|
|
5
|
+
* The two-endpoint form, not the aggregate `createmeta?expand=` one: the
|
|
6
|
+
* aggregate endpoint is deprecated on Jira Cloud and returns every issue type's
|
|
7
|
+
* every field in one document, which is both larger and less precise than the
|
|
8
|
+
* question being asked. Planning wants one project's issue types, and then one
|
|
9
|
+
* issue type's fields.
|
|
10
|
+
*
|
|
11
|
+
* `retry: false` throughout. These answers decide whether a create is possible
|
|
12
|
+
* and what it will contain, so a retried-and-stale answer is worse than a
|
|
13
|
+
* failure - the same reason `getTransitions` does not retry.
|
|
14
|
+
*
|
|
15
|
+
* Both mappers are defensive about shape. Jira omits fields it considers
|
|
16
|
+
* irrelevant and different deployments populate different ones, so anything
|
|
17
|
+
* unrecognised is dropped rather than guessed at: an entry JAM cannot read is
|
|
18
|
+
* an entry JAM must not claim to have understood.
|
|
19
|
+
*/
|
|
20
|
+
export class JiraCloudCreateMetadataAdapter {
|
|
21
|
+
client;
|
|
22
|
+
constructor(credentials, fetchImpl) {
|
|
23
|
+
this.client = fetchImpl ? new JiraClient(credentials, fetchImpl) : new JiraClient(credentials);
|
|
24
|
+
}
|
|
25
|
+
async getIssueTypes(projectKey) {
|
|
26
|
+
const { data } = await this.client.request({
|
|
27
|
+
path: `rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`,
|
|
28
|
+
retry: false,
|
|
29
|
+
});
|
|
30
|
+
return (data.issueTypes ?? [])
|
|
31
|
+
.filter((t) => typeof t.id === "string" && typeof t.name === "string")
|
|
32
|
+
.map((t) => ({ id: t.id, name: t.name, subtask: t.subtask === true }));
|
|
33
|
+
}
|
|
34
|
+
async getCreateFields(projectKey, issueTypeId) {
|
|
35
|
+
const { data } = await this.client.request({
|
|
36
|
+
path: `rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes/${encodeURIComponent(issueTypeId)}`,
|
|
37
|
+
retry: false,
|
|
38
|
+
});
|
|
39
|
+
return (data.fields ?? [])
|
|
40
|
+
.map(toFieldMetadata)
|
|
41
|
+
.filter((f) => f !== undefined);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function toFieldMetadata(raw) {
|
|
45
|
+
// Jira has called this `fieldId` and `key` in different responses. Without
|
|
46
|
+
// one of them the entry cannot be matched to anything, so it is dropped -
|
|
47
|
+
// and if it was required, the required-field gate will refuse the plan
|
|
48
|
+
// because JAM cannot show it was supplied.
|
|
49
|
+
const id = typeof raw.fieldId === "string" ? raw.fieldId : typeof raw.key === "string" ? raw.key : undefined;
|
|
50
|
+
if (!id)
|
|
51
|
+
return undefined;
|
|
52
|
+
const allowed = mapAllowedValues(raw.allowedValues);
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
name: typeof raw.name === "string" ? raw.name : id,
|
|
56
|
+
required: raw.required === true,
|
|
57
|
+
hasDefaultValue: raw.hasDefaultValue === true,
|
|
58
|
+
...(allowed ? { allowedValues: allowed } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Allowed values, when Jira constrains the field at all.
|
|
63
|
+
*
|
|
64
|
+
* Undefined and empty mean different things and are kept apart: undefined is
|
|
65
|
+
* "Jira did not constrain this", empty is "Jira constrains it and offers
|
|
66
|
+
* nothing". The first permits a free value, the second permits none.
|
|
67
|
+
*/
|
|
68
|
+
function mapAllowedValues(raw) {
|
|
69
|
+
if (!Array.isArray(raw))
|
|
70
|
+
return undefined;
|
|
71
|
+
return raw.map((entry) => {
|
|
72
|
+
const id = typeof entry?.id === "string" ? entry.id : undefined;
|
|
73
|
+
// Components and priorities use `name`; some option fields use `value`.
|
|
74
|
+
const name = typeof entry?.name === "string"
|
|
75
|
+
? entry.name
|
|
76
|
+
: typeof entry?.value === "string"
|
|
77
|
+
? entry.value
|
|
78
|
+
: undefined;
|
|
79
|
+
return { ...(id ? { id } : {}), ...(name ? { name } : {}) };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { ProjectConfig } from "../../config/schema.js";
|
|
2
2
|
import type { CredentialPort } from "../../ports/credentials.port.js";
|
|
3
|
-
import type { CurrentUser, GetCommentsRequest, GetCommentsResult, GetIssuesRequest, GetIssuesResult, JiraReadPort, ListProjectsResult, SearchPageRequest, SearchPageResult } from "../../ports/jira-read.port.js";
|
|
3
|
+
import type { CurrentUser, GetCommentsRequest, GetCommentsResult, GetIssueRequest, GetIssueResult, GetIssuesRequest, GetIssuesResult, JiraReadPort, ListProjectsResult, SearchPageRequest, SearchPageResult } from "../../ports/jira-read.port.js";
|
|
4
4
|
export declare class JiraCloudReadAdapter implements JiraReadPort {
|
|
5
5
|
private readonly config;
|
|
6
6
|
private readonly client;
|
|
7
7
|
constructor(credentials: CredentialPort, config: ProjectConfig, fetchImpl?: typeof fetch);
|
|
8
8
|
searchPage(req: SearchPageRequest): Promise<SearchPageResult>;
|
|
9
|
+
/**
|
|
10
|
+
* `GET /rest/api/3/issue/{key}` - the single-issue endpoint, not bulkfetch.
|
|
11
|
+
*
|
|
12
|
+
* This is what ConsistencyPolicy means by a direct issue read, and the write
|
|
13
|
+
* plane is the only caller. A 404 is an answer, not a failure: the issue is
|
|
14
|
+
* not there, or not visible to this account, and the caller decides which of
|
|
15
|
+
* those matters.
|
|
16
|
+
*/
|
|
17
|
+
getIssue(req: GetIssueRequest): Promise<GetIssueResult>;
|
|
9
18
|
getIssues(req: GetIssuesRequest): Promise<GetIssuesResult>;
|
|
10
19
|
getComments(req: GetCommentsRequest): Promise<GetCommentsResult>;
|
|
11
20
|
listProjects(): Promise<ListProjectsResult>;
|
|
@@ -27,6 +27,23 @@ export class JiraCloudReadAdapter {
|
|
|
27
27
|
result.nextPageToken = data.nextPageToken;
|
|
28
28
|
return result;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* `GET /rest/api/3/issue/{key}` - the single-issue endpoint, not bulkfetch.
|
|
32
|
+
*
|
|
33
|
+
* This is what ConsistencyPolicy means by a direct issue read, and the write
|
|
34
|
+
* plane is the only caller. A 404 is an answer, not a failure: the issue is
|
|
35
|
+
* not there, or not visible to this account, and the caller decides which of
|
|
36
|
+
* those matters.
|
|
37
|
+
*/
|
|
38
|
+
async getIssue(req) {
|
|
39
|
+
const { data, bytes } = await this.client.request({
|
|
40
|
+
path: `rest/api/3/issue/${encodeURIComponent(req.key)}`,
|
|
41
|
+
query: { fields: req.fields.join(",") },
|
|
42
|
+
});
|
|
43
|
+
if (!data?.key)
|
|
44
|
+
return { responseBytes: bytes };
|
|
45
|
+
return { issue: mapIssueWithMeta(data, this.config).issue, responseBytes: bytes };
|
|
46
|
+
}
|
|
30
47
|
async getIssues(req) {
|
|
31
48
|
const issues = [];
|
|
32
49
|
const commentTotals = {};
|
|
@@ -1,12 +1,47 @@
|
|
|
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
|
+
/**
|
|
21
|
+
* Create one issue, once.
|
|
22
|
+
*
|
|
23
|
+
* `retry: false` matters more here than anywhere else behind this port. A
|
|
24
|
+
* retried update converges; a retried create leaves two issues, and the
|
|
25
|
+
* second one has a different key nobody is holding. An ambiguous failure is
|
|
26
|
+
* handed to the application layer as JAM_WRITE_UNCERTAIN and resolved by
|
|
27
|
+
* looking, never by sending it again.
|
|
28
|
+
*/
|
|
29
|
+
createIssue(fields: Record<string, unknown>): Promise<{
|
|
10
30
|
id: string;
|
|
31
|
+
key: string;
|
|
11
32
|
}>;
|
|
33
|
+
updateIssue(key: string, fields: Record<string, unknown>): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Add a comment, converting plain text to ADF here rather than accepting ADF.
|
|
36
|
+
*
|
|
37
|
+
* Jira's comment body is a document tree, and letting an agent supply one
|
|
38
|
+
* would mean accepting arbitrary structure - panels, mentions, embedded
|
|
39
|
+
* content - through a field that reads like "text". The conversion is narrow
|
|
40
|
+
* on purpose: paragraphs, and nothing else.
|
|
41
|
+
*/
|
|
42
|
+
addComment(key: string, body: string): Promise<{
|
|
43
|
+
id: string;
|
|
44
|
+
}>;
|
|
45
|
+
getTransitions(key: string): Promise<JiraTransition[]>;
|
|
46
|
+
transitionIssue(key: string, transitionId: string): Promise<void>;
|
|
12
47
|
}
|
|
@@ -1,17 +1,102 @@
|
|
|
1
|
+
import { textToAdf } from "../../domain/adf.js";
|
|
1
2
|
import { JamError } from "../../domain/errors.js";
|
|
3
|
+
import { JiraClient } from "./jira-client.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 class
|
|
8
|
-
|
|
9
|
-
|
|
17
|
+
export class JiraCloudWriteAdapter {
|
|
18
|
+
client;
|
|
19
|
+
constructor(credentials, fetchImpl) {
|
|
20
|
+
this.client = fetchImpl
|
|
21
|
+
? new JiraClient(credentials, fetchImpl)
|
|
22
|
+
: new JiraClient(credentials);
|
|
10
23
|
}
|
|
11
|
-
|
|
12
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Create one issue, once.
|
|
26
|
+
*
|
|
27
|
+
* `retry: false` matters more here than anywhere else behind this port. A
|
|
28
|
+
* retried update converges; a retried create leaves two issues, and the
|
|
29
|
+
* second one has a different key nobody is holding. An ambiguous failure is
|
|
30
|
+
* handed to the application layer as JAM_WRITE_UNCERTAIN and resolved by
|
|
31
|
+
* looking, never by sending it again.
|
|
32
|
+
*/
|
|
33
|
+
async createIssue(fields) {
|
|
34
|
+
const { data } = await this.client.request({
|
|
35
|
+
path: "rest/api/3/issue",
|
|
36
|
+
method: "POST",
|
|
37
|
+
body: { fields },
|
|
38
|
+
retry: false,
|
|
39
|
+
});
|
|
40
|
+
if (!data?.key || !data.id) {
|
|
41
|
+
// Jira took the request and told us nothing identifying, so an issue may
|
|
42
|
+
// now exist that JAM cannot name. That is exactly the uncertain case:
|
|
43
|
+
// report it, do not retry, and let a person look.
|
|
44
|
+
throw new JamError("JAM_WRITE_UNCERTAIN", "Jira accepted a create but returned no issue key, so JAM cannot tell which issue it made - or whether it made one. Look in the project before trying again: retrying could create a second issue.", { project: fields["project"]?.key });
|
|
45
|
+
}
|
|
46
|
+
return { id: data.id, key: data.key };
|
|
47
|
+
}
|
|
48
|
+
async updateIssue(key, fields) {
|
|
49
|
+
await this.client.request({
|
|
50
|
+
path: `rest/api/3/issue/${encodeURIComponent(key)}`,
|
|
51
|
+
method: "PUT",
|
|
52
|
+
body: { fields },
|
|
53
|
+
retry: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Add a comment, converting plain text to ADF here rather than accepting ADF.
|
|
58
|
+
*
|
|
59
|
+
* Jira's comment body is a document tree, and letting an agent supply one
|
|
60
|
+
* would mean accepting arbitrary structure - panels, mentions, embedded
|
|
61
|
+
* content - through a field that reads like "text". The conversion is narrow
|
|
62
|
+
* on purpose: paragraphs, and nothing else.
|
|
63
|
+
*/
|
|
64
|
+
async addComment(key, body) {
|
|
65
|
+
const { data } = await this.client.request({
|
|
66
|
+
path: `rest/api/3/issue/${encodeURIComponent(key)}/comment`,
|
|
67
|
+
method: "POST",
|
|
68
|
+
body: { body: textToAdf(body) },
|
|
69
|
+
retry: false,
|
|
70
|
+
});
|
|
71
|
+
if (!data?.id) {
|
|
72
|
+
// Jira accepted it but told us nothing identifying. Treat that as
|
|
73
|
+
// unconfirmed rather than inventing an id: the caller's direct read is
|
|
74
|
+
// what decides whether the comment exists.
|
|
75
|
+
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 });
|
|
76
|
+
}
|
|
77
|
+
return { id: data.id };
|
|
78
|
+
}
|
|
79
|
+
async getTransitions(key) {
|
|
80
|
+
const { data } = await this.client.request({
|
|
81
|
+
path: `rest/api/3/issue/${encodeURIComponent(key)}/transitions`,
|
|
82
|
+
// A read, but on the write path: its answer decides a mutation, so a
|
|
83
|
+
// retried-and-stale transition list would be worse than a failure.
|
|
84
|
+
retry: false,
|
|
85
|
+
});
|
|
86
|
+
return (data.transitions ?? [])
|
|
87
|
+
.filter((t) => typeof t.id === "string")
|
|
88
|
+
.map((t) => ({
|
|
89
|
+
id: t.id,
|
|
90
|
+
name: t.name ?? t.id,
|
|
91
|
+
to: t.to?.name ?? t.name ?? "",
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
async transitionIssue(key, transitionId) {
|
|
95
|
+
await this.client.request({
|
|
96
|
+
path: `rest/api/3/issue/${encodeURIComponent(key)}/transitions`,
|
|
97
|
+
method: "POST",
|
|
98
|
+
body: { transition: { id: transitionId } },
|
|
99
|
+
retry: false,
|
|
100
|
+
});
|
|
13
101
|
}
|
|
14
|
-
}
|
|
15
|
-
function notSupported() {
|
|
16
|
-
return new JamError("CONFIG_INVALID", "JAM is read-only in this release. Use the Atlassian MCP for Jira writes.");
|
|
17
102
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { JamDeps } from "../deps.js";
|
|
2
|
+
import type { CreateIssueWritePlan, WriteApplyReceipt } from "../domain/write.js";
|
|
3
|
+
/**
|
|
4
|
+
* Create the issue a plan describes, then go and look at what was created.
|
|
5
|
+
*
|
|
6
|
+
* The same three-step shape as every other apply, with one substitution.
|
|
7
|
+
* Updating an existing issue re-reads that issue and compares its revision;
|
|
8
|
+
* there is no issue to re-read here, so what gets checked instead is the
|
|
9
|
+
* premise the plan was built on - the project's create schema. That is
|
|
10
|
+
* creation's concurrency boundary.
|
|
11
|
+
*
|
|
12
|
+
* 1. Re-derive the schema and check the plan's premises still hold. If the
|
|
13
|
+
* issue type went away, or a required field JAM cannot fill appeared, or a
|
|
14
|
+
* resolved value is no longer offered, nothing is sent.
|
|
15
|
+
* 2. POST the create exactly once. No retry, ever - see below.
|
|
16
|
+
* 3. Read the new issue by the key Jira returned, and check it says what the
|
|
17
|
+
* plan intended. A 201 with a key is Jira accepting a request, not
|
|
18
|
+
* evidence that the issue exists as described.
|
|
19
|
+
*/
|
|
20
|
+
export declare function applyCreateIssue(deps: JamDeps, plan: CreateIssueWritePlan): Promise<WriteApplyReceipt>;
|