@jam-mcp/server 1.2.0 → 1.3.1
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 +90 -86
- package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.d.ts +39 -0
- package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.js +94 -0
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.d.ts +25 -0
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.js +84 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.js +20 -1
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +9 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.js +16 -0
- package/dist/application/apply-create-issue.js +1 -1
- package/dist/application/apply-write.js +86 -3
- package/dist/application/plan-write.d.ts +20 -2
- package/dist/application/plan-write.js +166 -18
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/bootstrap/setup-plan.d.ts +11 -0
- package/dist/bootstrap/setup-plan.js +10 -1
- package/dist/cli-entry.js +33 -33
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +35 -1
- package/dist/deps.d.ts +18 -0
- package/dist/deps.js +12 -0
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +14 -0
- package/dist/domain/write.d.ts +136 -3
- package/dist/domain/write.js +12 -0
- package/dist/index.js +0 -0
- package/dist/mcp/tools/jira-write-apply.tool.js +14 -14
- package/dist/mcp/tools/jira-write-plan.tool.js +40 -20
- package/dist/policy/assignee-policy.d.ts +60 -0
- package/dist/policy/assignee-policy.js +103 -0
- package/dist/policy/custom-field-policy.d.ts +93 -0
- package/dist/policy/custom-field-policy.js +230 -0
- package/dist/ports/jira-assignee-resolution.port.d.ts +51 -0
- package/dist/ports/jira-assignee-resolution.port.js +1 -0
- package/dist/ports/jira-edit-metadata.port.d.ts +22 -0
- package/dist/ports/jira-edit-metadata.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +21 -0
- package/dist/ports/jira-write.port.d.ts +8 -0
- 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,86 +1,90 @@
|
|
|
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
|
-
|
|
35
|
-
`field.update` (summary, priority, labels, components),
|
|
36
|
-
(matched against the transitions Jira currently offers, never a guessed id)
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 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
|
+
Five operations: `comment.add` (plain text, converted to ADF here),
|
|
35
|
+
`field.update` (summary, priority, labels, components), `status.transition`
|
|
36
|
+
(matched against the transitions Jira currently offers, never a guessed id),
|
|
37
|
+
`assignee.update` (resolved to an account by exact display name or accountId,
|
|
38
|
+
never a substring match), and `issue.create` (planned against the project's own
|
|
39
|
+
create schema, so an unavailable issue type is a refusal here rather than a
|
|
40
|
+
Jira 400 later).
|
|
41
|
+
|
|
42
|
+
## Evidence boundary
|
|
43
|
+
|
|
44
|
+
Every result carries a `meta` block, and nothing is ever truncated silently.
|
|
45
|
+
|
|
46
|
+
`meta.complete` means **JAM finished the Jira retrieval with no known loss**. It
|
|
47
|
+
is not a statement about the project: not readiness, not "unblocked", not "the
|
|
48
|
+
whole story". What JAM did not look at is named in the same block —
|
|
49
|
+
`evidenceScope` and `limitations` call out the repository, external sources, and
|
|
50
|
+
dependencies that live outside Jira. `provenance` and `source` say where the
|
|
51
|
+
records came from.
|
|
52
|
+
|
|
53
|
+
So an empty comment thread on a complete read is a complete read *of Jira*, not
|
|
54
|
+
proof that nothing was agreed. If the issue points at an external canonical
|
|
55
|
+
source, that source is what settles the question.
|
|
56
|
+
|
|
57
|
+
## Running it
|
|
58
|
+
|
|
59
|
+
Don't install this package to use JAM. Your coding agent launches
|
|
60
|
+
[`@jam-mcp/launcher`](https://www.npmjs.com/package/@jam-mcp/launcher), which
|
|
61
|
+
reads your `~/.jam/config.yaml`, decides which JAM build this machine should
|
|
62
|
+
run, and dispatches here. Naming the server directly instead pins one machine
|
|
63
|
+
to one build and bypasses that choice — and it is what keeps a committed
|
|
64
|
+
`.mcp.json` free of machine-specific paths.
|
|
65
|
+
|
|
66
|
+
The same binary carries the CLI, and every command below is reachable through
|
|
67
|
+
the launcher:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
serve Run the MCP server over stdio
|
|
71
|
+
doctor Diagnose config, credentials and Jira connectivity
|
|
72
|
+
setup Wire up JAM and verify (personal by default; --shared for the team)
|
|
73
|
+
auth login Store Jira credentials in this user's OS secret store
|
|
74
|
+
runtime Show or change which JAM build this machine runs
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Written out, that is `npx --yes @jam-mcp/launcher@1.3.1 doctor`, or just `jam
|
|
78
|
+
doctor` if you took the launcher's optional global install. Starting from
|
|
79
|
+
nothing — no install, no runtime chosen yet — use
|
|
80
|
+
`npx --yes @jam-mcp/bootstrap@1.3.1 init` instead.
|
|
81
|
+
|
|
82
|
+
Credentials come from the process environment or this user's OS secret store —
|
|
83
|
+
never from a repository file — and never appear in logs, telemetry, or tool
|
|
84
|
+
results. `stdout` is reserved for the MCP protocol and for JSON output;
|
|
85
|
+
diagnostics go to `stderr`.
|
|
86
|
+
|
|
87
|
+
## More
|
|
88
|
+
|
|
89
|
+
- [Repository README](https://github.com/colosair/jam#readme)
|
|
90
|
+
- [JAM design of record](https://github.com/colosair/jam/blob/main/docs/architecture/jira-agent-mcp-design.md)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AssigneeCandidate } from "../../domain/write.js";
|
|
2
|
+
import type { CredentialPort } from "../../ports/credentials.port.js";
|
|
3
|
+
import type { JiraAssigneeResolutionPort } from "../../ports/jira-assignee-resolution.port.js";
|
|
4
|
+
/**
|
|
5
|
+
* Jira Cloud REST v3 user directory, read-only.
|
|
6
|
+
*
|
|
7
|
+
* Two endpoints, two questions:
|
|
8
|
+
*
|
|
9
|
+
* - `GET /rest/api/3/user/search?query=` - who might this name mean. Jira
|
|
10
|
+
* matches display name and, where privacy settings allow it, email. The
|
|
11
|
+
* answers are candidates.
|
|
12
|
+
* - `GET /rest/api/3/user/assignable/search?issueKey=&accountId=` - may this
|
|
13
|
+
* exact account hold this exact issue. Jira answers with the account when
|
|
14
|
+
* it may and with nothing when it may not, which is the assignability check
|
|
15
|
+
* without JAM having to interpret a permission model it does not own.
|
|
16
|
+
*
|
|
17
|
+
* `retry: false` on both. Their answers decide a mutation, so a
|
|
18
|
+
* retried-and-stale answer is worse than a failure.
|
|
19
|
+
*
|
|
20
|
+
* Email is deliberately not part of the identity JAM works with. Jira's
|
|
21
|
+
* privacy settings routinely blank it - most users on a real site come back
|
|
22
|
+
* with `emailAddress: ""` - so resolving on it would work for some people and
|
|
23
|
+
* silently fail for others on the same site.
|
|
24
|
+
*/
|
|
25
|
+
export declare class JiraCloudAssigneeResolutionAdapter implements JiraAssigneeResolutionPort {
|
|
26
|
+
private readonly client;
|
|
27
|
+
constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
|
|
28
|
+
searchUsers(query: string): Promise<AssigneeCandidate[]>;
|
|
29
|
+
/**
|
|
30
|
+
* `GET /rest/api/3/user?accountId=` - the exact lookup, not a search.
|
|
31
|
+
*
|
|
32
|
+
* Jira answers 404 when no such account exists or this token cannot see it.
|
|
33
|
+
* Both mean the same thing to a caller who wanted to assign it, so both
|
|
34
|
+
* become `undefined` rather than an error: "there is nobody to assign" is an
|
|
35
|
+
* answer, and the policy layer is where it turns into a refusal.
|
|
36
|
+
*/
|
|
37
|
+
getUserByAccountId(accountId: string): Promise<AssigneeCandidate | undefined>;
|
|
38
|
+
isAssignable(issueKey: string, accountId: string): Promise<boolean>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { JamError } from "../../domain/errors.js";
|
|
2
|
+
import { JiraClient } from "./jira-client.js";
|
|
3
|
+
/** How many candidates are worth reporting back to a human. */
|
|
4
|
+
const SEARCH_LIMIT = 20;
|
|
5
|
+
/**
|
|
6
|
+
* Jira Cloud REST v3 user directory, read-only.
|
|
7
|
+
*
|
|
8
|
+
* Two endpoints, two questions:
|
|
9
|
+
*
|
|
10
|
+
* - `GET /rest/api/3/user/search?query=` - who might this name mean. Jira
|
|
11
|
+
* matches display name and, where privacy settings allow it, email. The
|
|
12
|
+
* answers are candidates.
|
|
13
|
+
* - `GET /rest/api/3/user/assignable/search?issueKey=&accountId=` - may this
|
|
14
|
+
* exact account hold this exact issue. Jira answers with the account when
|
|
15
|
+
* it may and with nothing when it may not, which is the assignability check
|
|
16
|
+
* without JAM having to interpret a permission model it does not own.
|
|
17
|
+
*
|
|
18
|
+
* `retry: false` on both. Their answers decide a mutation, so a
|
|
19
|
+
* retried-and-stale answer is worse than a failure.
|
|
20
|
+
*
|
|
21
|
+
* Email is deliberately not part of the identity JAM works with. Jira's
|
|
22
|
+
* privacy settings routinely blank it - most users on a real site come back
|
|
23
|
+
* with `emailAddress: ""` - so resolving on it would work for some people and
|
|
24
|
+
* silently fail for others on the same site.
|
|
25
|
+
*/
|
|
26
|
+
export class JiraCloudAssigneeResolutionAdapter {
|
|
27
|
+
client;
|
|
28
|
+
constructor(credentials, fetchImpl) {
|
|
29
|
+
this.client = fetchImpl ? new JiraClient(credentials, fetchImpl) : new JiraClient(credentials);
|
|
30
|
+
}
|
|
31
|
+
async searchUsers(query) {
|
|
32
|
+
const { data } = await this.client.request({
|
|
33
|
+
path: "rest/api/3/user/search",
|
|
34
|
+
query: { query, maxResults: SEARCH_LIMIT },
|
|
35
|
+
retry: false,
|
|
36
|
+
});
|
|
37
|
+
return toCandidates(data);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `GET /rest/api/3/user?accountId=` - the exact lookup, not a search.
|
|
41
|
+
*
|
|
42
|
+
* Jira answers 404 when no such account exists or this token cannot see it.
|
|
43
|
+
* Both mean the same thing to a caller who wanted to assign it, so both
|
|
44
|
+
* become `undefined` rather than an error: "there is nobody to assign" is an
|
|
45
|
+
* answer, and the policy layer is where it turns into a refusal.
|
|
46
|
+
*/
|
|
47
|
+
async getUserByAccountId(accountId) {
|
|
48
|
+
try {
|
|
49
|
+
const { data } = await this.client.request({
|
|
50
|
+
path: "rest/api/3/user",
|
|
51
|
+
query: { accountId },
|
|
52
|
+
retry: false,
|
|
53
|
+
});
|
|
54
|
+
return toCandidates([data])[0];
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err instanceof JamError && err.code === "ISSUE_NOT_FOUND")
|
|
58
|
+
return undefined;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async isAssignable(issueKey, accountId) {
|
|
63
|
+
const { data } = await this.client.request({
|
|
64
|
+
path: "rest/api/3/user/assignable/search",
|
|
65
|
+
query: { issueKey, accountId, maxResults: 1 },
|
|
66
|
+
retry: false,
|
|
67
|
+
});
|
|
68
|
+
// Jira answers with the account when it is assignable and with an empty
|
|
69
|
+
// list when it is not. Matching the id back is belt and braces: an answer
|
|
70
|
+
// about somebody else is not an answer to the question that was asked.
|
|
71
|
+
return toCandidates(data).some((u) => u.accountId === accountId);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Users JAM can work with, from whatever Jira sent.
|
|
76
|
+
*
|
|
77
|
+
* An entry with no accountId is dropped: accountId is the identity, and an
|
|
78
|
+
* entry without one cannot be assigned, verified, or told apart from another.
|
|
79
|
+
* App and customer accounts are dropped too - they are not people a human
|
|
80
|
+
* meant to name, and offering one as a candidate invites assigning an issue to
|
|
81
|
+
* an integration.
|
|
82
|
+
*/
|
|
83
|
+
function toCandidates(raw) {
|
|
84
|
+
if (!Array.isArray(raw))
|
|
85
|
+
return [];
|
|
86
|
+
return raw
|
|
87
|
+
.filter((u) => typeof u?.accountId === "string" &&
|
|
88
|
+
(u.accountType === undefined || u.accountType === "atlassian"))
|
|
89
|
+
.map((u) => ({
|
|
90
|
+
accountId: u.accountId,
|
|
91
|
+
displayName: typeof u.displayName === "string" ? u.displayName : u.accountId,
|
|
92
|
+
active: u.active !== false,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { EditFieldMetadata } from "../../domain/write.js";
|
|
2
|
+
import type { CredentialPort } from "../../ports/credentials.port.js";
|
|
3
|
+
import type { JiraEditMetadataPort } from "../../ports/jira-edit-metadata.port.js";
|
|
4
|
+
/**
|
|
5
|
+
* Jira Cloud REST v3 edit metadata for one issue.
|
|
6
|
+
*
|
|
7
|
+
* `GET /rest/api/3/issue/{key}/editmeta`, `retry: false`. Its answer decides
|
|
8
|
+
* whether a mutation may proceed and what shape it takes, so a
|
|
9
|
+
* retried-and-stale answer is worse than a failure - the same argument that
|
|
10
|
+
* keeps `getTransitions`, the create metadata calls and the assignability
|
|
11
|
+
* check on the non-retrying side.
|
|
12
|
+
*
|
|
13
|
+
* Jira keys the response by field id, and describes each field with a `schema`
|
|
14
|
+
* and a list of `operations`. Both travel, because both are what the decision
|
|
15
|
+
* is made in; the rest of the document does not.
|
|
16
|
+
*
|
|
17
|
+
* Anything JAM cannot read is dropped rather than half-understood. A field
|
|
18
|
+
* that survives here with the wrong shape would be a field JAM claims to
|
|
19
|
+
* understand well enough to write.
|
|
20
|
+
*/
|
|
21
|
+
export declare class JiraCloudEditMetadataAdapter implements JiraEditMetadataPort {
|
|
22
|
+
private readonly client;
|
|
23
|
+
constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
|
|
24
|
+
getEditableFields(issueKey: string): Promise<EditFieldMetadata[]>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { JiraClient } from "./jira-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Jira Cloud REST v3 edit metadata for one issue.
|
|
4
|
+
*
|
|
5
|
+
* `GET /rest/api/3/issue/{key}/editmeta`, `retry: false`. Its answer decides
|
|
6
|
+
* whether a mutation may proceed and what shape it takes, so a
|
|
7
|
+
* retried-and-stale answer is worse than a failure - the same argument that
|
|
8
|
+
* keeps `getTransitions`, the create metadata calls and the assignability
|
|
9
|
+
* check on the non-retrying side.
|
|
10
|
+
*
|
|
11
|
+
* Jira keys the response by field id, and describes each field with a `schema`
|
|
12
|
+
* and a list of `operations`. Both travel, because both are what the decision
|
|
13
|
+
* is made in; the rest of the document does not.
|
|
14
|
+
*
|
|
15
|
+
* Anything JAM cannot read is dropped rather than half-understood. A field
|
|
16
|
+
* that survives here with the wrong shape would be a field JAM claims to
|
|
17
|
+
* understand well enough to write.
|
|
18
|
+
*/
|
|
19
|
+
export class JiraCloudEditMetadataAdapter {
|
|
20
|
+
client;
|
|
21
|
+
constructor(credentials, fetchImpl) {
|
|
22
|
+
this.client = fetchImpl ? new JiraClient(credentials, fetchImpl) : new JiraClient(credentials);
|
|
23
|
+
}
|
|
24
|
+
async getEditableFields(issueKey) {
|
|
25
|
+
const { data } = await this.client.request({
|
|
26
|
+
path: `rest/api/3/issue/${encodeURIComponent(issueKey)}/editmeta`,
|
|
27
|
+
retry: false,
|
|
28
|
+
});
|
|
29
|
+
const fields = data?.fields;
|
|
30
|
+
if (!fields || typeof fields !== "object")
|
|
31
|
+
return [];
|
|
32
|
+
return Object.entries(fields)
|
|
33
|
+
.map(([id, raw]) => toEditField(id, raw))
|
|
34
|
+
.filter((f) => f !== undefined);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function toEditField(id, raw) {
|
|
38
|
+
if (!raw || typeof raw !== "object")
|
|
39
|
+
return undefined;
|
|
40
|
+
// A field with no schema type is a field JAM cannot classify, and an
|
|
41
|
+
// unclassifiable field is one it must not decide it can write.
|
|
42
|
+
const type = typeof raw.schema?.type === "string" ? raw.schema.type : undefined;
|
|
43
|
+
if (!type)
|
|
44
|
+
return undefined;
|
|
45
|
+
const allowed = toOptions(raw.allowedValues);
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
name: typeof raw.name === "string" ? raw.name : id,
|
|
49
|
+
required: raw.required === true,
|
|
50
|
+
operations: Array.isArray(raw.operations)
|
|
51
|
+
? raw.operations.filter((op) => typeof op === "string")
|
|
52
|
+
: [],
|
|
53
|
+
schema: {
|
|
54
|
+
type,
|
|
55
|
+
...(typeof raw.schema?.items === "string" ? { items: raw.schema.items } : {}),
|
|
56
|
+
...(typeof raw.schema?.custom === "string" ? { custom: raw.schema.custom } : {}),
|
|
57
|
+
...(typeof raw.schema?.customId === "number" ? { customId: raw.schema.customId } : {}),
|
|
58
|
+
},
|
|
59
|
+
...(allowed ? { allowedValues: allowed } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The options Jira offers, when it constrains the field at all.
|
|
64
|
+
*
|
|
65
|
+
* Absent and empty mean different things and stay apart: absent is "Jira did
|
|
66
|
+
* not constrain this", empty is "Jira constrains it and offers nothing". The
|
|
67
|
+
* first permits a free value, the second permits none.
|
|
68
|
+
*
|
|
69
|
+
* Jira labels an option `value` on a select and `name` on some other pickers.
|
|
70
|
+
* Both are read; an option with neither an id nor a label is dropped, because
|
|
71
|
+
* it can be neither chosen nor recognised afterwards.
|
|
72
|
+
*/
|
|
73
|
+
function toOptions(raw) {
|
|
74
|
+
if (!Array.isArray(raw))
|
|
75
|
+
return undefined;
|
|
76
|
+
return raw
|
|
77
|
+
.map((entry) => {
|
|
78
|
+
const o = entry;
|
|
79
|
+
const id = typeof o?.id === "string" ? o.id : typeof o?.id === "number" ? String(o.id) : undefined;
|
|
80
|
+
const label = typeof o?.value === "string" ? o.value : typeof o?.name === "string" ? o.name : undefined;
|
|
81
|
+
return id && label ? { id, label } : undefined;
|
|
82
|
+
})
|
|
83
|
+
.filter((o) => o !== undefined);
|
|
84
|
+
}
|
|
@@ -42,7 +42,26 @@ export class JiraCloudReadAdapter {
|
|
|
42
42
|
});
|
|
43
43
|
if (!data?.key)
|
|
44
44
|
return { responseBytes: bytes };
|
|
45
|
-
|
|
45
|
+
// Read straight off the raw payload rather than through the mapper: the
|
|
46
|
+
// mapper's job is the shape the read tools see, and this identity is only
|
|
47
|
+
// for the write plane. Raw DTOs still stop here.
|
|
48
|
+
const raw = data.fields;
|
|
49
|
+
const assignee = raw?.["assignee"];
|
|
50
|
+
const accountId = typeof assignee?.accountId === "string" ? assignee.accountId : undefined;
|
|
51
|
+
// Only the ids that were asked for, and only when some were: a caller that
|
|
52
|
+
// did not request a custom field gets no entry rather than an empty object
|
|
53
|
+
// it has to tell apart from a field that is genuinely unset.
|
|
54
|
+
const customFieldValues = {};
|
|
55
|
+
for (const field of req.fields) {
|
|
56
|
+
if (field.startsWith("customfield_"))
|
|
57
|
+
customFieldValues[field] = raw?.[field] ?? null;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
issue: mapIssueWithMeta(data, this.config).issue,
|
|
61
|
+
...(accountId ? { assigneeAccountId: accountId } : {}),
|
|
62
|
+
...(Object.keys(customFieldValues).length > 0 ? { customFieldValues } : {}),
|
|
63
|
+
responseBytes: bytes,
|
|
64
|
+
};
|
|
46
65
|
}
|
|
47
66
|
async getIssues(req) {
|
|
48
67
|
const issues = [];
|
|
@@ -43,5 +43,14 @@ export declare class JiraCloudWriteAdapter implements JiraWritePort {
|
|
|
43
43
|
id: string;
|
|
44
44
|
}>;
|
|
45
45
|
getTransitions(key: string): Promise<JiraTransition[]>;
|
|
46
|
+
/**
|
|
47
|
+
* `PUT /rest/api/3/issue/{key}/assignee` with an accountId.
|
|
48
|
+
*
|
|
49
|
+
* The dedicated assignment endpoint rather than a field update: assignment
|
|
50
|
+
* has its own permission and its own Jira semantics, and routing it through
|
|
51
|
+
* the generic field PUT would put it behind the field whitelist, where it
|
|
52
|
+
* does not belong.
|
|
53
|
+
*/
|
|
54
|
+
assignIssue(key: string, accountId: string): Promise<void>;
|
|
46
55
|
transitionIssue(key: string, transitionId: string): Promise<void>;
|
|
47
56
|
}
|
|
@@ -91,6 +91,22 @@ export class JiraCloudWriteAdapter {
|
|
|
91
91
|
to: t.to?.name ?? t.name ?? "",
|
|
92
92
|
}));
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* `PUT /rest/api/3/issue/{key}/assignee` with an accountId.
|
|
96
|
+
*
|
|
97
|
+
* The dedicated assignment endpoint rather than a field update: assignment
|
|
98
|
+
* has its own permission and its own Jira semantics, and routing it through
|
|
99
|
+
* the generic field PUT would put it behind the field whitelist, where it
|
|
100
|
+
* does not belong.
|
|
101
|
+
*/
|
|
102
|
+
async assignIssue(key, accountId) {
|
|
103
|
+
await this.client.request({
|
|
104
|
+
path: `rest/api/3/issue/${encodeURIComponent(key)}/assignee`,
|
|
105
|
+
method: "PUT",
|
|
106
|
+
body: { accountId },
|
|
107
|
+
retry: false,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
94
110
|
async transitionIssue(key, transitionId) {
|
|
95
111
|
await this.client.request({
|
|
96
112
|
path: `rest/api/3/issue/${encodeURIComponent(key)}/transitions`,
|
|
@@ -127,7 +127,7 @@ async function verify(deps, plan, issueKey) {
|
|
|
127
127
|
if (createdProject !== plan.projectKey) {
|
|
128
128
|
throw verificationFailed(plan, issueKey, { project: plan.projectKey }, { project: createdProject ?? issueKey });
|
|
129
129
|
}
|
|
130
|
-
const issue = await readIssue(deps, issueKey);
|
|
130
|
+
const { issue } = await readIssue(deps, issueKey);
|
|
131
131
|
const observed = {};
|
|
132
132
|
for (const field of Object.keys(plan.intendedAfter)) {
|
|
133
133
|
observed[field] = observedValue(issue, field);
|