@jam-mcp/server 1.1.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/credentials/windows-user-env.d.ts +3 -1
- package/dist/adapters/credentials/windows-user-env.js +20 -1
- 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-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 +26 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +22 -7
- package/dist/adapters/jira-cloud/jira-write.adapter.js +41 -20
- package/dist/application/apply-create-issue.d.ts +20 -0
- package/dist/application/apply-create-issue.js +187 -0
- package/dist/application/apply-write.js +56 -5
- 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 +16 -4
- package/dist/application/plan-write.js +107 -18
- package/dist/application/write-plan-store.d.ts +2 -2
- package/dist/application/write-plan-store.js +13 -1
- 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/deps.d.ts +20 -0
- package/dist/deps.js +12 -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 +17 -0
- package/dist/domain/write.d.ts +165 -15
- package/dist/domain/write.js +34 -1
- package/dist/mcp/create-server.d.ts +4 -0
- package/dist/mcp/create-server.js +5 -0
- package/dist/mcp/tools/jira-write-plan.tool.js +42 -6
- package/dist/policy/assignee-policy.d.ts +60 -0
- package/dist/policy/assignee-policy.js +103 -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 +10 -1
- package/dist/policy/write-policy.js +15 -1
- 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-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 +34 -0
- package/dist/ports/jira-write.port.d.ts +17 -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
|
|
@@ -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
|
|
@@ -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,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,32 @@ 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
|
+
// 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
|
+
};
|
|
55
|
+
}
|
|
30
56
|
async getIssues(req) {
|
|
31
57
|
const issues = [];
|
|
32
58
|
const commentTotals = {};
|
|
@@ -17,6 +17,19 @@ import type { JiraWritePort } from "../../ports/jira-write.port.js";
|
|
|
17
17
|
export declare class JiraCloudWriteAdapter implements JiraWritePort {
|
|
18
18
|
private readonly client;
|
|
19
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<{
|
|
30
|
+
id: string;
|
|
31
|
+
key: string;
|
|
32
|
+
}>;
|
|
20
33
|
updateIssue(key: string, fields: Record<string, unknown>): Promise<void>;
|
|
21
34
|
/**
|
|
22
35
|
* Add a comment, converting plain text to ADF here rather than accepting ADF.
|
|
@@ -30,12 +43,14 @@ export declare class JiraCloudWriteAdapter implements JiraWritePort {
|
|
|
30
43
|
id: string;
|
|
31
44
|
}>;
|
|
32
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>;
|
|
33
55
|
transitionIssue(key: string, transitionId: string): Promise<void>;
|
|
34
56
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Plain text to the narrowest ADF that represents it.
|
|
37
|
-
*
|
|
38
|
-
* Blank lines separate paragraphs; everything else is literal. No markdown is
|
|
39
|
-
* interpreted, so a comment containing `*` or `#` says what it says.
|
|
40
|
-
*/
|
|
41
|
-
export declare function textToAdf(text: string): unknown;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { textToAdf } from "../../domain/adf.js";
|
|
1
2
|
import { JamError } from "../../domain/errors.js";
|
|
2
3
|
import { JiraClient } from "./jira-client.js";
|
|
3
4
|
/**
|
|
@@ -20,6 +21,30 @@ export class JiraCloudWriteAdapter {
|
|
|
20
21
|
? new JiraClient(credentials, fetchImpl)
|
|
21
22
|
: new JiraClient(credentials);
|
|
22
23
|
}
|
|
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
|
+
}
|
|
23
48
|
async updateIssue(key, fields) {
|
|
24
49
|
await this.client.request({
|
|
25
50
|
path: `rest/api/3/issue/${encodeURIComponent(key)}`,
|
|
@@ -66,6 +91,22 @@ export class JiraCloudWriteAdapter {
|
|
|
66
91
|
to: t.to?.name ?? t.name ?? "",
|
|
67
92
|
}));
|
|
68
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
|
+
}
|
|
69
110
|
async transitionIssue(key, transitionId) {
|
|
70
111
|
await this.client.request({
|
|
71
112
|
path: `rest/api/3/issue/${encodeURIComponent(key)}/transitions`,
|
|
@@ -75,23 +116,3 @@ export class JiraCloudWriteAdapter {
|
|
|
75
116
|
});
|
|
76
117
|
}
|
|
77
118
|
}
|
|
78
|
-
/**
|
|
79
|
-
* Plain text to the narrowest ADF that represents it.
|
|
80
|
-
*
|
|
81
|
-
* Blank lines separate paragraphs; everything else is literal. No markdown is
|
|
82
|
-
* interpreted, so a comment containing `*` or `#` says what it says.
|
|
83
|
-
*/
|
|
84
|
-
export function textToAdf(text) {
|
|
85
|
-
const paragraphs = text
|
|
86
|
-
.split(/\n{2,}/)
|
|
87
|
-
.map((block) => block.trim())
|
|
88
|
-
.filter(Boolean);
|
|
89
|
-
return {
|
|
90
|
-
type: "doc",
|
|
91
|
-
version: 1,
|
|
92
|
-
content: (paragraphs.length > 0 ? paragraphs : [text]).map((block) => ({
|
|
93
|
-
type: "paragraph",
|
|
94
|
-
content: [{ type: "text", text: block }],
|
|
95
|
-
})),
|
|
96
|
-
};
|
|
97
|
-
}
|
|
@@ -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>;
|