@jam-mcp/server 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- 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-read.adapter.js +10 -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 +38 -2
- package/dist/application/plan-write.d.ts +6 -1
- package/dist/application/plan-write.js +63 -17
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/deps.d.ts +9 -0
- package/dist/deps.js +6 -0
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +8 -0
- package/dist/domain/write.d.ts +40 -3
- package/dist/domain/write.js +1 -0
- package/dist/mcp/tools/jira-write-plan.tool.js +10 -2
- package/dist/policy/assignee-policy.d.ts +60 -0
- package/dist/policy/assignee-policy.js +103 -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-read.port.d.ts +11 -0
- package/dist/ports/jira-write.port.d.ts +8 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -70,10 +70,10 @@ auth login Store Jira credentials in this user's OS secret store
|
|
|
70
70
|
runtime Show or change which JAM build this machine runs
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
Written out, that is `npx --yes @jam-mcp/launcher@1.
|
|
73
|
+
Written out, that is `npx --yes @jam-mcp/launcher@1.3.0 doctor`, or just `jam
|
|
74
74
|
doctor` if you took the launcher's optional global install. Starting from
|
|
75
75
|
nothing — no install, no runtime chosen yet — use
|
|
76
|
-
`npx --yes @jam-mcp/bootstrap@1.
|
|
76
|
+
`npx --yes @jam-mcp/bootstrap@1.3.0 init` instead.
|
|
77
77
|
|
|
78
78
|
Credentials come from the process environment or this user's OS secret store —
|
|
79
79
|
never from a repository file — and never appear in logs, telemetry, or tool
|
|
@@ -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
|
+
}
|
|
@@ -42,7 +42,16 @@ 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 assignee = data.fields?.assignee;
|
|
49
|
+
const accountId = typeof assignee?.accountId === "string" ? assignee.accountId : undefined;
|
|
50
|
+
return {
|
|
51
|
+
issue: mapIssueWithMeta(data, this.config).issue,
|
|
52
|
+
...(accountId ? { assigneeAccountId: accountId } : {}),
|
|
53
|
+
responseBytes: bytes,
|
|
54
|
+
};
|
|
46
55
|
}
|
|
47
56
|
async getIssues(req) {
|
|
48
57
|
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);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { JamError, toJamError } from "../domain/errors.js";
|
|
2
2
|
import { readModeAfterWrite } from "../policy/consistency-policy.js";
|
|
3
|
+
import { assertAssignable } from "../policy/assignee-policy.js";
|
|
3
4
|
import { assertUnchanged } from "../policy/write-policy.js";
|
|
4
5
|
import { applyCreateIssue } from "./apply-create-issue.js";
|
|
5
6
|
import { readIssue } from "./plan-write.js";
|
|
@@ -35,7 +36,12 @@ export async function applyWritePlan(deps, request) {
|
|
|
35
36
|
if (plan.kind === "create-issue")
|
|
36
37
|
return applyCreateIssue(deps, plan);
|
|
37
38
|
const current = await readIssue(deps, plan.issueKey);
|
|
38
|
-
assertUnchanged(plan.issueKey, plan.baseUpdated, current.updated);
|
|
39
|
+
assertUnchanged(plan.issueKey, plan.baseUpdated, current.issue.updated);
|
|
40
|
+
// Whatever the plan depends on that the revision check cannot see, checked
|
|
41
|
+
// again here. For an assignment that is the target's permission to hold this
|
|
42
|
+
// issue: it can be revoked between planning and applying, and a plan that
|
|
43
|
+
// was valid is not the same as a plan that is still valid.
|
|
44
|
+
await revalidate(deps, plan);
|
|
39
45
|
const outcome = await mutate(deps, plan);
|
|
40
46
|
const after = await verify(deps, plan);
|
|
41
47
|
deps.writePlans.consume(plan.planId);
|
|
@@ -49,6 +55,18 @@ export async function applyWritePlan(deps, request) {
|
|
|
49
55
|
...(outcome.commentId ? { commentId: outcome.commentId } : {}),
|
|
50
56
|
};
|
|
51
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Re-derive the premises the revision check does not cover.
|
|
60
|
+
*
|
|
61
|
+
* Only `assignee.update` has any: the rest are fully described by the issue's
|
|
62
|
+
* own state, which `assertUnchanged` already compared.
|
|
63
|
+
*/
|
|
64
|
+
async function revalidate(deps, plan) {
|
|
65
|
+
if (plan.mutation.kind !== "assignee")
|
|
66
|
+
return;
|
|
67
|
+
const target = plan.intendedAfter["assignee"];
|
|
68
|
+
assertAssignable(plan.issueKey, target, await deps.jiraAssignees.isAssignable(plan.issueKey, plan.mutation.accountId));
|
|
69
|
+
}
|
|
52
70
|
/**
|
|
53
71
|
* Send the mutation, once.
|
|
54
72
|
*
|
|
@@ -71,6 +89,9 @@ async function mutate(deps, plan) {
|
|
|
71
89
|
case "transition":
|
|
72
90
|
await deps.jiraWrite.transitionIssue(plan.issueKey, plan.mutation.transitionId);
|
|
73
91
|
return {};
|
|
92
|
+
case "assignee":
|
|
93
|
+
await deps.jiraWrite.assignIssue(plan.issueKey, plan.mutation.accountId);
|
|
94
|
+
return {};
|
|
74
95
|
case "create":
|
|
75
96
|
// Unreachable: a create plan is routed to applyCreateIssue above. The
|
|
76
97
|
// case exists so adding a mutation kind is a compile error here rather
|
|
@@ -103,7 +124,22 @@ function isAmbiguous(err) {
|
|
|
103
124
|
* ours.
|
|
104
125
|
*/
|
|
105
126
|
async function verify(deps, plan) {
|
|
106
|
-
const
|
|
127
|
+
const snapshot = await readIssue(deps, plan.issueKey);
|
|
128
|
+
const issue = snapshot.issue;
|
|
129
|
+
if (plan.mutation.kind === "assignee") {
|
|
130
|
+
// On the accountId, never on the display name. Two people can share a
|
|
131
|
+
// name, so a name comparison would accept the wrong person's assignment as
|
|
132
|
+
// proof of the right one's - which is the entire reason resolution went to
|
|
133
|
+
// the trouble of producing an identity.
|
|
134
|
+
const expected = plan.intendedAfter["assignee"];
|
|
135
|
+
const observed = snapshot.assigneeAccountId
|
|
136
|
+
? { accountId: snapshot.assigneeAccountId, displayName: issue.assignee ?? "" }
|
|
137
|
+
: null;
|
|
138
|
+
if (snapshot.assigneeAccountId !== expected.accountId) {
|
|
139
|
+
throw verificationFailed(plan, { assignee: expected }, { assignee: observed });
|
|
140
|
+
}
|
|
141
|
+
return { assignee: { accountId: expected.accountId, displayName: issue.assignee ?? expected.displayName } };
|
|
142
|
+
}
|
|
107
143
|
if (plan.mutation.kind === "comment") {
|
|
108
144
|
// Direct issue GET again, not the bulk endpoint: this is post-write
|
|
109
145
|
// confirmation, and ConsistencyPolicy makes no exception for the read that
|
|
@@ -36,4 +36,9 @@ export declare function planWrite(deps: JamDeps, request: PlanWriteRequest): Pro
|
|
|
36
36
|
* The one read every write goes through - the pre-write conflict check, the
|
|
37
37
|
* post-write confirmation, and the post-create confirmation.
|
|
38
38
|
*/
|
|
39
|
-
export
|
|
39
|
+
export type IssueSnapshot = {
|
|
40
|
+
issue: FullIssueContext;
|
|
41
|
+
/** Identity of the current assignee, which `issue.assignee` cannot supply. */
|
|
42
|
+
assigneeAccountId?: string;
|
|
43
|
+
};
|
|
44
|
+
export declare function readIssue(deps: JamDeps, issueKey: string): Promise<IssueSnapshot>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { JamError } from "../domain/errors.js";
|
|
2
2
|
import { assertExistingIssueOperation, assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
|
|
3
|
+
import { assertAssignable, assertNotAlreadyAssigned, exactMatches, resolveAssignee, } from "../policy/assignee-policy.js";
|
|
3
4
|
import { planCreateIssue } from "./plan-create-issue.js";
|
|
4
5
|
/**
|
|
5
6
|
* Work out whether a requested change is currently possible, and describe it.
|
|
@@ -30,8 +31,9 @@ export async function planWrite(deps, request) {
|
|
|
30
31
|
// does not write should get that answer, not a round trip and then that
|
|
31
32
|
// answer.
|
|
32
33
|
const input = validateInput(operation, request.input);
|
|
33
|
-
const
|
|
34
|
-
const
|
|
34
|
+
const snapshot = await readIssue(deps, issueKey);
|
|
35
|
+
const issue = snapshot.issue;
|
|
36
|
+
const { before, intendedAfter, mutation, transition, baseAssigneeAccountId } = await describe(deps, operation, issueKey, snapshot, input);
|
|
35
37
|
const createdAt = new Date();
|
|
36
38
|
const plan = deps.writePlans.create({
|
|
37
39
|
kind: "existing-issue",
|
|
@@ -44,6 +46,7 @@ export async function planWrite(deps, request) {
|
|
|
44
46
|
createdAt: createdAt.toISOString(),
|
|
45
47
|
expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
|
|
46
48
|
...(transition ? { transition } : {}),
|
|
49
|
+
...(baseAssigneeAccountId ? { baseAssigneeAccountId } : {}),
|
|
47
50
|
mutation,
|
|
48
51
|
});
|
|
49
52
|
return {
|
|
@@ -60,20 +63,8 @@ export async function planWrite(deps, request) {
|
|
|
60
63
|
},
|
|
61
64
|
};
|
|
62
65
|
}
|
|
63
|
-
/**
|
|
64
|
-
* The issue as Jira has it, read directly by key.
|
|
65
|
-
*
|
|
66
|
-
* `getIssue`, not `getIssues`: ConsistencyPolicy requires a direct issue GET
|
|
67
|
-
* for anything that decides or confirms a write, and the bulk endpoint is not
|
|
68
|
-
* one. A JQL result can lag behind the issue it describes; a bulk fetch is
|
|
69
|
-
* free to answer from a different path than the single-issue endpoint. Neither
|
|
70
|
-
* difference matters for ordinary reads, and both matter here.
|
|
71
|
-
*
|
|
72
|
-
* The one read every write goes through - the pre-write conflict check, the
|
|
73
|
-
* post-write confirmation, and the post-create confirmation.
|
|
74
|
-
*/
|
|
75
66
|
export async function readIssue(deps, issueKey) {
|
|
76
|
-
const { issue: found } = await deps.jira.getIssue({
|
|
67
|
+
const { issue: found, assigneeAccountId } = await deps.jira.getIssue({
|
|
77
68
|
key: issueKey,
|
|
78
69
|
// `issuetype` and `description` are here for creation's verification step,
|
|
79
70
|
// which has to confirm the issue Jira made is the one that was asked for.
|
|
@@ -85,6 +76,7 @@ export async function readIssue(deps, issueKey) {
|
|
|
85
76
|
"status",
|
|
86
77
|
"issuetype",
|
|
87
78
|
"description",
|
|
79
|
+
"assignee",
|
|
88
80
|
"priority",
|
|
89
81
|
"labels",
|
|
90
82
|
"components",
|
|
@@ -94,7 +86,7 @@ export async function readIssue(deps, issueKey) {
|
|
|
94
86
|
if (!found) {
|
|
95
87
|
throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
|
|
96
88
|
}
|
|
97
|
-
return found;
|
|
89
|
+
return { issue: found, ...(assigneeAccountId ? { assigneeAccountId } : {}) };
|
|
98
90
|
}
|
|
99
91
|
/**
|
|
100
92
|
* The issue an existing-issue operation names, or a refusal that says why.
|
|
@@ -136,9 +128,17 @@ function validateInput(operation, raw) {
|
|
|
136
128
|
}
|
|
137
129
|
return { status: status.trim() };
|
|
138
130
|
}
|
|
131
|
+
case "assignee.update": {
|
|
132
|
+
const assignee = raw.assignee;
|
|
133
|
+
if (typeof assignee !== "string" || assignee.trim().length === 0) {
|
|
134
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "assignee.update needs non-empty `input.assignee` - a display name, or an accountId.", { operation });
|
|
135
|
+
}
|
|
136
|
+
return { assignee: assignee.trim() };
|
|
137
|
+
}
|
|
139
138
|
}
|
|
140
139
|
}
|
|
141
|
-
async function describe(deps, operation, issueKey,
|
|
140
|
+
async function describe(deps, operation, issueKey, snapshot, input) {
|
|
141
|
+
const issue = snapshot.issue;
|
|
142
142
|
switch (operation) {
|
|
143
143
|
case "comment.add": {
|
|
144
144
|
const { text } = input;
|
|
@@ -178,8 +178,54 @@ async function describe(deps, operation, issueKey, issue, input) {
|
|
|
178
178
|
transition,
|
|
179
179
|
};
|
|
180
180
|
}
|
|
181
|
+
case "assignee.update": {
|
|
182
|
+
const { assignee: requested } = input;
|
|
183
|
+
// Ask Jira who this is, and decide from what it says. The requested
|
|
184
|
+
// string never reaches a mutation: what gets written is the accountId
|
|
185
|
+
// that resolution settled on, and resolution refuses rather than picks
|
|
186
|
+
// when the answer is not one person.
|
|
187
|
+
const target = resolveAssignee(requested, await findCandidates(deps, requested));
|
|
188
|
+
// Two independent refusals, in the order that costs least. Already-set
|
|
189
|
+
// needs no Jira call; assignability does.
|
|
190
|
+
assertNotAlreadyAssigned(issueKey, snapshot.assigneeAccountId, target);
|
|
191
|
+
assertAssignable(issueKey, target, await deps.jiraAssignees.isAssignable(issueKey, target.accountId));
|
|
192
|
+
return {
|
|
193
|
+
before: {
|
|
194
|
+
assignee: snapshot.assigneeAccountId
|
|
195
|
+
? { accountId: snapshot.assigneeAccountId, displayName: issue.assignee ?? "" }
|
|
196
|
+
: null,
|
|
197
|
+
},
|
|
198
|
+
intendedAfter: { assignee: target },
|
|
199
|
+
mutation: { kind: "assignee", accountId: target.accountId },
|
|
200
|
+
...(snapshot.assigneeAccountId
|
|
201
|
+
? { baseAssigneeAccountId: snapshot.assigneeAccountId }
|
|
202
|
+
: {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
181
205
|
}
|
|
182
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Who Jira thinks this string could be.
|
|
209
|
+
*
|
|
210
|
+
* The search first, because it answers both halves of the contract most of the
|
|
211
|
+
* time - Jira's user search currently matches an accountId as readily as a
|
|
212
|
+
* name. "Currently" is the problem: that is a property of a substring search
|
|
213
|
+
* rather than a promise, and the contract says an accountId identifies a
|
|
214
|
+
* person. So when the search settles nothing, the exact lookup is asked before
|
|
215
|
+
* giving up.
|
|
216
|
+
*
|
|
217
|
+
* Ordered this way because it costs nothing on the paths that work. The extra
|
|
218
|
+
* request happens only where resolution was about to fail anyway, and the
|
|
219
|
+
* string is never inspected to guess whether it looks like an accountId - Jira
|
|
220
|
+
* is asked, and Jira answers.
|
|
221
|
+
*/
|
|
222
|
+
async function findCandidates(deps, requested) {
|
|
223
|
+
const candidates = await deps.jiraAssignees.searchUsers(requested);
|
|
224
|
+
if (exactMatches(requested, candidates).length > 0)
|
|
225
|
+
return candidates;
|
|
226
|
+
const byId = await deps.jiraAssignees.getUserByAccountId(requested);
|
|
227
|
+
return byId ? [byId] : candidates;
|
|
228
|
+
}
|
|
183
229
|
function currentValue(issue, field) {
|
|
184
230
|
switch (field) {
|
|
185
231
|
case "summary":
|
|
@@ -13,7 +13,7 @@ import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
|
|
|
13
13
|
export { LAUNCHER_PACKAGE_SPEC };
|
|
14
14
|
export declare const JAM_MCP_ENTRY: {
|
|
15
15
|
readonly command: "npx";
|
|
16
|
-
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.3.0", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Recognise wiring from before the launcher existed: a hard-coded path to one
|
package/dist/deps.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ProjectConfig } from "./config/schema.js";
|
|
|
4
4
|
import type { CachePort } from "./ports/cache.port.js";
|
|
5
5
|
import type { CredentialPort } from "./ports/credentials.port.js";
|
|
6
6
|
import type { JiraReadPort } from "./ports/jira-read.port.js";
|
|
7
|
+
import type { JiraAssigneeResolutionPort } from "./ports/jira-assignee-resolution.port.js";
|
|
7
8
|
import type { JiraCreateMetadataPort } from "./ports/jira-create-metadata.port.js";
|
|
8
9
|
import type { JiraWritePort } from "./ports/jira-write.port.js";
|
|
9
10
|
import { WritePlanStore } from "./application/write-plan-store.js";
|
|
@@ -29,6 +30,12 @@ export type JamDeps = {
|
|
|
29
30
|
* completeness semantics would mean nothing for it.
|
|
30
31
|
*/
|
|
31
32
|
jiraCreateMetadata: JiraCreateMetadataPort;
|
|
33
|
+
/**
|
|
34
|
+
* Who a name refers to, and who may hold an issue. A fourth port for the
|
|
35
|
+
* same reason as the third: it reads a directory rather than an issue, and
|
|
36
|
+
* it mutates nothing.
|
|
37
|
+
*/
|
|
38
|
+
jiraAssignees: JiraAssigneeResolutionPort;
|
|
32
39
|
/**
|
|
33
40
|
* Plans awaiting apply. Lives for the life of this server process - see
|
|
34
41
|
* WritePlanStore for why it is not persisted.
|
|
@@ -46,6 +53,8 @@ export type BuildDepsOptions = {
|
|
|
46
53
|
jiraWrite?: JiraWritePort;
|
|
47
54
|
/** Injected by tests so create metadata comes from a fixture, not a site. */
|
|
48
55
|
jiraCreateMetadata?: JiraCreateMetadataPort;
|
|
56
|
+
/** Injected by tests so user resolution never reaches a real directory. */
|
|
57
|
+
jiraAssignees?: JiraAssigneeResolutionPort;
|
|
49
58
|
/** Injected by tests to bypass the real process/registry credential lookup. */
|
|
50
59
|
credentials?: CredentialPort;
|
|
51
60
|
/**
|
package/dist/deps.js
CHANGED
|
@@ -38,6 +38,11 @@ export async function buildDeps(options = {}) {
|
|
|
38
38
|
const { JiraCloudCreateMetadataAdapter } = await import("./adapters/jira-cloud/jira-create-metadata.adapter.js");
|
|
39
39
|
jiraCreateMetadata = new JiraCloudCreateMetadataAdapter(credentials);
|
|
40
40
|
}
|
|
41
|
+
let jiraAssignees = options.jiraAssignees;
|
|
42
|
+
if (!jiraAssignees) {
|
|
43
|
+
const { JiraCloudAssigneeResolutionAdapter } = await import("./adapters/jira-cloud/jira-assignee-resolution.adapter.js");
|
|
44
|
+
jiraAssignees = new JiraCloudAssigneeResolutionAdapter(credentials);
|
|
45
|
+
}
|
|
41
46
|
return {
|
|
42
47
|
config: resolved.config,
|
|
43
48
|
configPath: resolved.configPath,
|
|
@@ -45,6 +50,7 @@ export async function buildDeps(options = {}) {
|
|
|
45
50
|
jira,
|
|
46
51
|
jiraWrite,
|
|
47
52
|
jiraCreateMetadata,
|
|
53
|
+
jiraAssignees,
|
|
48
54
|
writePlans: new WritePlanStore(),
|
|
49
55
|
cache: new NoopCache(),
|
|
50
56
|
telemetry,
|
package/dist/domain/errors.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* is mapped onto one of these codes so the agent (and `jam doctor`) can reason
|
|
6
6
|
* about failures without parsing vendor-specific payloads.
|
|
7
7
|
*/
|
|
8
|
-
export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
|
|
8
|
+
export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_ASSIGNEE_NOT_FOUND", "JAM_WRITE_ASSIGNEE_AMBIGUOUS", "JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", "JAM_WRITE_ASSIGNEE_ALREADY_SET", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
|
|
9
9
|
export type JamErrorCode = (typeof JAM_ERROR_CODES)[number];
|
|
10
10
|
export type JamErrorPayload = {
|
|
11
11
|
error: {
|
package/dist/domain/errors.js
CHANGED
|
@@ -34,6 +34,14 @@ export const JAM_ERROR_CODES = [
|
|
|
34
34
|
"JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED",
|
|
35
35
|
"JAM_WRITE_VALUE_NOT_ALLOWED",
|
|
36
36
|
"JAM_WRITE_SCHEMA_CHANGED",
|
|
37
|
+
// Assignment. A name is not an identity, and Jira decides who may hold an
|
|
38
|
+
// issue - so "nobody by that name", "several people by that name", "that
|
|
39
|
+
// person may not hold this issue" and "they already do" are four different
|
|
40
|
+
// things for a caller to do next, and none of them is "try again".
|
|
41
|
+
"JAM_WRITE_ASSIGNEE_NOT_FOUND",
|
|
42
|
+
"JAM_WRITE_ASSIGNEE_AMBIGUOUS",
|
|
43
|
+
"JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE",
|
|
44
|
+
"JAM_WRITE_ASSIGNEE_ALREADY_SET",
|
|
37
45
|
"JAM_WRITE_PLAN_NOT_FOUND",
|
|
38
46
|
"JAM_WRITE_PLAN_EXPIRED",
|
|
39
47
|
"JAM_WRITE_CONFLICT",
|
package/dist/domain/write.d.ts
CHANGED
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* layer: these name an issue, creation names a project; these compare a
|
|
21
21
|
* revision to detect a conflict, creation has no revision to compare.
|
|
22
22
|
*/
|
|
23
|
-
export declare const EXISTING_ISSUE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition"];
|
|
23
|
+
export declare const EXISTING_ISSUE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "assignee.update"];
|
|
24
24
|
/** The operations the public MCP surface accepts. Nothing else is reachable. */
|
|
25
|
-
export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "issue.create"];
|
|
25
|
+
export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "assignee.update", "issue.create"];
|
|
26
26
|
export type ExistingIssueOperation = (typeof EXISTING_ISSUE_OPERATIONS)[number];
|
|
27
27
|
export type WriteOperation = (typeof WRITE_OPERATIONS)[number];
|
|
28
28
|
/**
|
|
@@ -48,6 +48,32 @@ export type FieldUpdateInput = {
|
|
|
48
48
|
export type StatusTransitionInput = {
|
|
49
49
|
status: string;
|
|
50
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* Who to assign an issue to, as a person would say it.
|
|
53
|
+
*
|
|
54
|
+
* A display name, or an accountId if the caller already has one. Either way it
|
|
55
|
+
* is a selector, not an identifier: nothing here is ever sent to Jira. It is
|
|
56
|
+
* resolved against Jira's own user directory first, and what gets written is
|
|
57
|
+
* the accountId that resolution produced.
|
|
58
|
+
*/
|
|
59
|
+
export type AssigneeUpdateInput = {
|
|
60
|
+
assignee: string;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* A Jira user as JAM identifies them.
|
|
64
|
+
*
|
|
65
|
+
* `accountId` is the identity; `displayName` is for the human reading the
|
|
66
|
+
* receipt. They are not interchangeable - two people can share a display name,
|
|
67
|
+
* which is precisely why an assignment is verified on the accountId.
|
|
68
|
+
*/
|
|
69
|
+
export type AssigneeRef = {
|
|
70
|
+
accountId: string;
|
|
71
|
+
displayName: string;
|
|
72
|
+
};
|
|
73
|
+
/** A user Jira offered in answer to a search. */
|
|
74
|
+
export type AssigneeCandidate = AssigneeRef & {
|
|
75
|
+
active: boolean;
|
|
76
|
+
};
|
|
51
77
|
/**
|
|
52
78
|
* Fields `issue.create` may set.
|
|
53
79
|
*
|
|
@@ -67,7 +93,7 @@ export type CreateIssueInput = {
|
|
|
67
93
|
labels?: string[];
|
|
68
94
|
components?: string[];
|
|
69
95
|
};
|
|
70
|
-
export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput | CreateIssueInput;
|
|
96
|
+
export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput | AssigneeUpdateInput | CreateIssueInput;
|
|
71
97
|
/** An issue type as Jira offers it for one project, right now. */
|
|
72
98
|
export type CreateIssueType = {
|
|
73
99
|
id: string;
|
|
@@ -154,6 +180,14 @@ export type ExistingIssueWritePlan = WritePlanCommon & {
|
|
|
154
180
|
* from a status name.
|
|
155
181
|
*/
|
|
156
182
|
transition?: JiraTransition;
|
|
183
|
+
/**
|
|
184
|
+
* Who the issue was assigned to when the plan was made, by identity.
|
|
185
|
+
*
|
|
186
|
+
* Present only for `assignee.update`, and separate from `before` because
|
|
187
|
+
* `before` is what a receipt shows a human while this is what apply compares.
|
|
188
|
+
* `undefined` means the issue was unassigned.
|
|
189
|
+
*/
|
|
190
|
+
baseAssigneeAccountId?: string;
|
|
157
191
|
};
|
|
158
192
|
/**
|
|
159
193
|
* A plan to create an issue that does not exist yet.
|
|
@@ -185,6 +219,9 @@ export type WriteMutation = {
|
|
|
185
219
|
} | {
|
|
186
220
|
kind: "transition";
|
|
187
221
|
transitionId: string;
|
|
222
|
+
} | {
|
|
223
|
+
kind: "assignee";
|
|
224
|
+
accountId: string;
|
|
188
225
|
} | {
|
|
189
226
|
kind: "create";
|
|
190
227
|
fields: Record<string, unknown>;
|
package/dist/domain/write.js
CHANGED
|
@@ -24,6 +24,7 @@ export const EXISTING_ISSUE_OPERATIONS = [
|
|
|
24
24
|
"comment.add",
|
|
25
25
|
"field.update",
|
|
26
26
|
"status.transition",
|
|
27
|
+
"assignee.update",
|
|
27
28
|
];
|
|
28
29
|
/** The operations the public MCP surface accepts. Nothing else is reachable. */
|
|
29
30
|
export const WRITE_OPERATIONS = [...EXISTING_ISSUE_OPERATIONS, "issue.create"];
|
|
@@ -10,12 +10,15 @@ Operations on an issue that already exists - these need \`key\`:
|
|
|
10
10
|
- comment.add input: { "text": "..." } plain text; JAM converts it, do not send ADF
|
|
11
11
|
- field.update input: { "summary"?, "priority"?, "labels"?, "components"? }
|
|
12
12
|
- status.transition input: { "status": "Done" } JAM asks Jira which transitions exist and matches yours
|
|
13
|
+
- assignee.update input: { "assignee": "..." } a display name or an accountId; JAM resolves it against Jira's own directory
|
|
13
14
|
|
|
14
15
|
Creating an issue - no \`key\`, because there is no issue yet:
|
|
15
16
|
- issue.create input: { "issueType": "Task", "summary": "...", "description"?, "priority"?, "labels"?, "components"? }
|
|
16
17
|
|
|
17
18
|
issue.create goes into the project this workspace is bound to; the project is not a parameter. Planning reads Jira's create schema for that project first, so an issue type Jira does not offer, a priority or component outside its allowed values, and a project whose create screen requires a field JAM cannot set are all refused here rather than attempted. \`description\` is plain text, like a comment. Not settable in this version: assignee, reporter, parent, custom fields, attachments.
|
|
18
19
|
|
|
20
|
+
assignee.update never sends the name you pass. JAM searches Jira's user directory, and assigns only when exactly one user matches your string exactly - an exact display name (case-insensitive) or an accountId. A partial match is Jira reporting a similarity, not identifying a person, so several matches or none come back as a refusal with the candidates attached: name one exactly, or pass their accountId. JAM also checks Jira offers that person as an assignee for this issue, before planning and again before writing, and confirms the result by accountId rather than by name. Unassigning, and setting an assignee while creating, are not in this version.
|
|
21
|
+
|
|
19
22
|
Writes are limited to the Jira project this workspace is bound to; a key from another project is refused rather than attempted.
|
|
20
23
|
|
|
21
24
|
The plan records what the issue looked like when it was made, and expires. If the issue changes in the meantime, jira_write_apply refuses with JAM_WRITE_CONFLICT - re-plan against the new state rather than forcing the old one through.
|
|
@@ -34,7 +37,7 @@ export function registerJiraWritePlan(server, deps) {
|
|
|
34
37
|
.string()
|
|
35
38
|
.min(1)
|
|
36
39
|
.optional()
|
|
37
|
-
.describe('Issue key, e.g. "PROJECT-123". Required for
|
|
40
|
+
.describe('Issue key, e.g. "PROJECT-123". Required for every operation that changes an existing issue; omit for issue.create, which has no issue yet. Must be in the configured project.'),
|
|
38
41
|
operation: z
|
|
39
42
|
.enum(WRITE_OPERATIONS)
|
|
40
43
|
.describe(`What to do: ${WRITE_OPERATIONS.join(", ")}.`),
|
|
@@ -52,6 +55,11 @@ export function registerJiraWritePlan(server, deps) {
|
|
|
52
55
|
.min(1)
|
|
53
56
|
.optional()
|
|
54
57
|
.describe("status.transition: the status to move to, e.g. \"Done\"."),
|
|
58
|
+
assignee: z
|
|
59
|
+
.string()
|
|
60
|
+
.min(1)
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("assignee.update: who to assign, as an exact display name or an accountId. Not settable through field.update."),
|
|
55
63
|
issueType: z
|
|
56
64
|
.string()
|
|
57
65
|
.min(1)
|
|
@@ -69,7 +77,7 @@ export function registerJiraWritePlan(server, deps) {
|
|
|
69
77
|
.optional()
|
|
70
78
|
.describe("Component names. Replaces the whole component set."),
|
|
71
79
|
})
|
|
72
|
-
.describe(`Operation input. field.update accepts only ${WRITABLE_FIELDS.join(", ")}; issue.create accepts only ${CREATABLE_FIELDS.join(", ")}. Custom fields
|
|
80
|
+
.describe(`Operation input. field.update accepts only ${WRITABLE_FIELDS.join(", ")}; issue.create accepts only ${CREATABLE_FIELDS.join(", ")}. Custom fields are not writable by either, and the assignee is changed through assignee.update rather than through field.update.`),
|
|
73
81
|
},
|
|
74
82
|
// Planning reads Jira and decides; it never mutates. Hosts are free to
|
|
75
83
|
// run it without asking, which is what keeps the two-step shape cheap.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AssigneeCandidate, AssigneeRef } from "../domain/write.js";
|
|
2
|
+
/**
|
|
3
|
+
* Turning a name into a person, without ever guessing which person.
|
|
4
|
+
*
|
|
5
|
+
* Jira's user search is a substring search: "min" finds Min Kim and Minho
|
|
6
|
+
* Park, and it returns them in whatever order it likes. An agent handing JAM a
|
|
7
|
+
* name is describing an intent, not identifying an account - so the search is
|
|
8
|
+
* how candidates are found, and never how one of them is chosen.
|
|
9
|
+
*
|
|
10
|
+
* The rule is that JAM assigns only when the answer is unambiguous on its own
|
|
11
|
+
* terms: one exact identity. Everything else comes back as a refusal carrying
|
|
12
|
+
* the candidates, so the next move is to name one of them precisely rather
|
|
13
|
+
* than to hope the same query resolves differently.
|
|
14
|
+
*
|
|
15
|
+
* This costs an agent a round trip on ambiguity. The alternative costs someone
|
|
16
|
+
* an issue assigned to the wrong colleague, discovered later.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Which candidate the caller meant, if exactly one is certain.
|
|
20
|
+
*
|
|
21
|
+
* In order:
|
|
22
|
+
*
|
|
23
|
+
* 1. An exact accountId. The caller already had an identity; nothing to guess.
|
|
24
|
+
* 2. Exactly one candidate whose display name matches exactly, ignoring case
|
|
25
|
+
* and surrounding space. "task" and "Task" are the same intent, and an
|
|
26
|
+
* agent cannot learn a directory's casing before asking.
|
|
27
|
+
*
|
|
28
|
+
* Nothing else resolves. A single substring hit is still a substring hit: it
|
|
29
|
+
* is Jira saying "this contains what you typed", not "this is who you meant".
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveAssignee(requested: string, candidates: AssigneeCandidate[]): AssigneeRef;
|
|
32
|
+
/**
|
|
33
|
+
* Refuse an assignment Jira would not permit, before asking it to.
|
|
34
|
+
*
|
|
35
|
+
* Assignability is a permission question with a per-project answer, and JAM
|
|
36
|
+
* does not model Jira's permission scheme - it asks. Called at plan time so a
|
|
37
|
+
* refusal is a JAM decision rather than a 400, and again immediately before
|
|
38
|
+
* the write, because a permission that held when the plan was made is not the
|
|
39
|
+
* same as one that still holds.
|
|
40
|
+
*/
|
|
41
|
+
export declare function assertAssignable(issueKey: string, target: AssigneeRef, assignable: boolean): void;
|
|
42
|
+
/**
|
|
43
|
+
* Refuse an assignment that would change nothing.
|
|
44
|
+
*
|
|
45
|
+
* Not an error in Jira's eyes, and not harmful - but a write JAM reports as
|
|
46
|
+
* applied should be a write that happened. Saying so plainly is more useful
|
|
47
|
+
* than a receipt claiming to have changed something that already was.
|
|
48
|
+
*/
|
|
49
|
+
export declare function assertNotAlreadyAssigned(issueKey: string, current: string | undefined, target: AssigneeRef): void;
|
|
50
|
+
/**
|
|
51
|
+
* The candidates this string identifies exactly, if any.
|
|
52
|
+
*
|
|
53
|
+
* Exported so the caller can tell "the search settled it" from "the search did
|
|
54
|
+
* not" without reimplementing the rule - a second copy of what counts as exact
|
|
55
|
+
* is a second answer waiting to disagree with this one.
|
|
56
|
+
*
|
|
57
|
+
* An accountId match wins outright: it is an identity, and a display name that
|
|
58
|
+
* happens to equal somebody's account id is not a reason to consider them.
|
|
59
|
+
*/
|
|
60
|
+
export declare function exactMatches(requested: string, candidates: AssigneeCandidate[]): AssigneeCandidate[];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { JamError } from "../domain/errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Turning a name into a person, without ever guessing which person.
|
|
4
|
+
*
|
|
5
|
+
* Jira's user search is a substring search: "min" finds Min Kim and Minho
|
|
6
|
+
* Park, and it returns them in whatever order it likes. An agent handing JAM a
|
|
7
|
+
* name is describing an intent, not identifying an account - so the search is
|
|
8
|
+
* how candidates are found, and never how one of them is chosen.
|
|
9
|
+
*
|
|
10
|
+
* The rule is that JAM assigns only when the answer is unambiguous on its own
|
|
11
|
+
* terms: one exact identity. Everything else comes back as a refusal carrying
|
|
12
|
+
* the candidates, so the next move is to name one of them precisely rather
|
|
13
|
+
* than to hope the same query resolves differently.
|
|
14
|
+
*
|
|
15
|
+
* This costs an agent a round trip on ambiguity. The alternative costs someone
|
|
16
|
+
* an issue assigned to the wrong colleague, discovered later.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Which candidate the caller meant, if exactly one is certain.
|
|
20
|
+
*
|
|
21
|
+
* In order:
|
|
22
|
+
*
|
|
23
|
+
* 1. An exact accountId. The caller already had an identity; nothing to guess.
|
|
24
|
+
* 2. Exactly one candidate whose display name matches exactly, ignoring case
|
|
25
|
+
* and surrounding space. "task" and "Task" are the same intent, and an
|
|
26
|
+
* agent cannot learn a directory's casing before asking.
|
|
27
|
+
*
|
|
28
|
+
* Nothing else resolves. A single substring hit is still a substring hit: it
|
|
29
|
+
* is Jira saying "this contains what you typed", not "this is who you meant".
|
|
30
|
+
*/
|
|
31
|
+
export function resolveAssignee(requested, candidates) {
|
|
32
|
+
const wanted = requested.trim();
|
|
33
|
+
if (wanted.length === 0) {
|
|
34
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "assignee.update needs a non-empty `input.assignee`.", { operation: "assignee.update" });
|
|
35
|
+
}
|
|
36
|
+
const exact = exactMatches(wanted, candidates);
|
|
37
|
+
if (exact.length === 0) {
|
|
38
|
+
throw new JamError("JAM_WRITE_ASSIGNEE_NOT_FOUND", candidates.length === 0
|
|
39
|
+
? `Jira has no user matching "${requested}".`
|
|
40
|
+
: `No Jira user is exactly "${requested}". JAM assigns only on an exact display name or an accountId, because a partial match is Jira reporting a similarity rather than identifying a person. Name one of the candidates exactly, or pass their accountId.`, { requested, candidates: describe(candidates) });
|
|
41
|
+
}
|
|
42
|
+
if (exact.length > 1) {
|
|
43
|
+
// Two people really can share a display name. Picking either would be a
|
|
44
|
+
// coin toss whose result is somebody's issue.
|
|
45
|
+
throw new JamError("JAM_WRITE_ASSIGNEE_AMBIGUOUS", `"${requested}" matches ${exact.length} Jira users exactly. Pass the accountId of the one you mean.`, { requested, candidates: describe(exact) });
|
|
46
|
+
}
|
|
47
|
+
const match = exact[0];
|
|
48
|
+
if (!match.active) {
|
|
49
|
+
throw new JamError("JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", `${match.displayName} is a deactivated Jira account, so this issue cannot be assigned to them.`, { requested, accountId: match.accountId, reason: "INACTIVE" });
|
|
50
|
+
}
|
|
51
|
+
return { accountId: match.accountId, displayName: match.displayName };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Refuse an assignment Jira would not permit, before asking it to.
|
|
55
|
+
*
|
|
56
|
+
* Assignability is a permission question with a per-project answer, and JAM
|
|
57
|
+
* does not model Jira's permission scheme - it asks. Called at plan time so a
|
|
58
|
+
* refusal is a JAM decision rather than a 400, and again immediately before
|
|
59
|
+
* the write, because a permission that held when the plan was made is not the
|
|
60
|
+
* same as one that still holds.
|
|
61
|
+
*/
|
|
62
|
+
export function assertAssignable(issueKey, target, assignable) {
|
|
63
|
+
if (assignable)
|
|
64
|
+
return;
|
|
65
|
+
throw new JamError("JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", `Jira does not offer ${target.displayName} as an assignee for ${issueKey}. They may lack the assignable-user permission in this project, or have lost it since this plan was made.`, { issueKey, accountId: target.accountId, displayName: target.displayName });
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Refuse an assignment that would change nothing.
|
|
69
|
+
*
|
|
70
|
+
* Not an error in Jira's eyes, and not harmful - but a write JAM reports as
|
|
71
|
+
* applied should be a write that happened. Saying so plainly is more useful
|
|
72
|
+
* than a receipt claiming to have changed something that already was.
|
|
73
|
+
*/
|
|
74
|
+
export function assertNotAlreadyAssigned(issueKey, current, target) {
|
|
75
|
+
if (current !== target.accountId)
|
|
76
|
+
return;
|
|
77
|
+
throw new JamError("JAM_WRITE_ASSIGNEE_ALREADY_SET", `${issueKey} is already assigned to ${target.displayName}. Nothing to change.`, { issueKey, accountId: target.accountId, displayName: target.displayName });
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The candidates this string identifies exactly, if any.
|
|
81
|
+
*
|
|
82
|
+
* Exported so the caller can tell "the search settled it" from "the search did
|
|
83
|
+
* not" without reimplementing the rule - a second copy of what counts as exact
|
|
84
|
+
* is a second answer waiting to disagree with this one.
|
|
85
|
+
*
|
|
86
|
+
* An accountId match wins outright: it is an identity, and a display name that
|
|
87
|
+
* happens to equal somebody's account id is not a reason to consider them.
|
|
88
|
+
*/
|
|
89
|
+
export function exactMatches(requested, candidates) {
|
|
90
|
+
const wanted = requested.trim();
|
|
91
|
+
const byAccountId = candidates.filter((c) => c.accountId === wanted);
|
|
92
|
+
if (byAccountId.length > 0)
|
|
93
|
+
return byAccountId;
|
|
94
|
+
return candidates.filter((c) => c.displayName.trim().toLowerCase() === wanted.toLowerCase());
|
|
95
|
+
}
|
|
96
|
+
/** Candidates as an agent can act on them: a name to repeat, and an id to be sure. */
|
|
97
|
+
function describe(candidates) {
|
|
98
|
+
return candidates.map((c) => ({
|
|
99
|
+
accountId: c.accountId,
|
|
100
|
+
displayName: c.displayName,
|
|
101
|
+
active: c.active,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AssigneeCandidate } from "../domain/write.js";
|
|
2
|
+
/**
|
|
3
|
+
* Who Jira thinks a name refers to, and whether they can hold this issue.
|
|
4
|
+
*
|
|
5
|
+
* A third read-shaped port, for the same reason `JiraCreateMetadataPort` is
|
|
6
|
+
* one: nothing here mutates, so it does not belong behind the write port's
|
|
7
|
+
* no-retry contract, and it answers a question about a directory rather than
|
|
8
|
+
* about an issue, so the read port's completeness semantics would mean nothing
|
|
9
|
+
* for it.
|
|
10
|
+
*
|
|
11
|
+
* The two calls are deliberately separate questions. Searching answers "who
|
|
12
|
+
* did the caller mean", and it is allowed to be fuzzy - Jira matches on
|
|
13
|
+
* substrings, and a partial match is a suggestion to show a human. Checking
|
|
14
|
+
* assignability answers "may this exact person hold this exact issue", and it
|
|
15
|
+
* is not fuzzy at all: it takes an accountId that resolution has already
|
|
16
|
+
* settled on. Collapsing them would let a substring match decide a mutation.
|
|
17
|
+
*
|
|
18
|
+
* Neither call retries. Their answers decide a mutation, and a retried answer
|
|
19
|
+
* is a possibly-stale one - the same argument that keeps `getTransitions` and
|
|
20
|
+
* the create metadata calls on the non-retrying side.
|
|
21
|
+
*/
|
|
22
|
+
export interface JiraAssigneeResolutionPort {
|
|
23
|
+
/**
|
|
24
|
+
* Users matching a query, as Jira's own directory reports them.
|
|
25
|
+
*
|
|
26
|
+
* Fuzzy by nature. What comes back is candidates, never a decision - see
|
|
27
|
+
* `resolveAssignee` for what JAM will and will not do with them.
|
|
28
|
+
*/
|
|
29
|
+
searchUsers(query: string): Promise<AssigneeCandidate[]>;
|
|
30
|
+
/**
|
|
31
|
+
* One user, looked up by identity rather than found by searching.
|
|
32
|
+
*
|
|
33
|
+
* JAM's contract says `assignee` may be an accountId, and a contract about
|
|
34
|
+
* identity has to be met by an identity lookup. Jira's user search does
|
|
35
|
+
* currently return a user when the query happens to be their accountId, but
|
|
36
|
+
* that is a property of a substring search, not a promise - relying on it
|
|
37
|
+
* means the accountId half of the contract holds by coincidence.
|
|
38
|
+
*
|
|
39
|
+
* Absent means Jira has no such account, or this token cannot see it. Both
|
|
40
|
+
* are "you cannot assign this", which is the caller's answer either way.
|
|
41
|
+
*/
|
|
42
|
+
getUserByAccountId(accountId: string): Promise<AssigneeCandidate | undefined>;
|
|
43
|
+
/**
|
|
44
|
+
* Whether this exact account may be assigned this exact issue, right now.
|
|
45
|
+
*
|
|
46
|
+
* Asked by accountId, so it is an identity question rather than a name one.
|
|
47
|
+
* Asked again immediately before the write, because a permission that held
|
|
48
|
+
* when the plan was made is not the same as one that still holds.
|
|
49
|
+
*/
|
|
50
|
+
isAssignable(issueKey: string, accountId: string): Promise<boolean>;
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -27,6 +27,17 @@ export type GetIssueRequest = {
|
|
|
27
27
|
export type GetIssueResult = {
|
|
28
28
|
/** Absent when Jira has no such issue, or this account cannot see it. */
|
|
29
29
|
issue?: FullIssueContext;
|
|
30
|
+
/**
|
|
31
|
+
* Who the issue is assigned to, by identity rather than by name.
|
|
32
|
+
*
|
|
33
|
+
* Here rather than on the issue because only the write plane needs it. Two
|
|
34
|
+
* people can share a display name, so `assignee` cannot settle whether an
|
|
35
|
+
* assignment landed on the right person - and the read tools have no use for
|
|
36
|
+
* an accountId, so their payload does not grow one.
|
|
37
|
+
*
|
|
38
|
+
* Absent when the issue is unassigned, or when the field was not requested.
|
|
39
|
+
*/
|
|
40
|
+
assigneeAccountId?: string;
|
|
30
41
|
responseBytes: number;
|
|
31
42
|
};
|
|
32
43
|
export type GetIssuesResult = {
|
|
@@ -32,4 +32,12 @@ export interface JiraWritePort {
|
|
|
32
32
|
/** Transitions Jira offers for this issue right now, for this account. */
|
|
33
33
|
getTransitions(key: string): Promise<JiraTransition[]>;
|
|
34
34
|
transitionIssue(key: string, transitionId: string): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Assign an issue to one account.
|
|
37
|
+
*
|
|
38
|
+
* By accountId, never by name: Jira Cloud identifies users by account, and a
|
|
39
|
+
* display name is a label two people can share. Which account it is was
|
|
40
|
+
* settled during planning.
|
|
41
|
+
*/
|
|
42
|
+
assignIssue(key: string, accountId: string): Promise<void>;
|
|
35
43
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jam-mcp/server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "JAM (Jira Agent MCP) - agent-facing Jira access layer: MCP server, setup core, and CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jira",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"test:watch": "vitest"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@jam-mcp/launcher": "1.
|
|
44
|
+
"@jam-mcp/launcher": "1.3.0",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
46
|
"yaml": "^2.9.0",
|
|
47
47
|
"zod": "^4.4.3"
|