@jam-mcp/server 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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-create-metadata.adapter.d.ts +27 -0
- package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
- package/dist/adapters/jira-cloud/jira-read.adapter.js +17 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +13 -7
- package/dist/adapters/jira-cloud/jira-write.adapter.js +25 -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 +18 -3
- 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 +10 -3
- package/dist/application/plan-write.js +54 -11
- 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 +11 -0
- package/dist/deps.js +6 -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 +9 -0
- package/dist/domain/write.d.ts +128 -15
- package/dist/domain/write.js +33 -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 +34 -6
- 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-create-metadata.port.d.ts +25 -0
- package/dist/ports/jira-create-metadata.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +23 -0
- package/dist/ports/jira-write.port.d.ts +9 -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.2.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.2.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,27 @@
|
|
|
1
|
+
import type { CreateFieldMetadata, CreateIssueType } from "../../domain/write.js";
|
|
2
|
+
import type { CredentialPort } from "../../ports/credentials.port.js";
|
|
3
|
+
import type { JiraCreateMetadataPort } from "../../ports/jira-create-metadata.port.js";
|
|
4
|
+
/**
|
|
5
|
+
* Jira Cloud REST v3 create metadata, per project and per issue type.
|
|
6
|
+
*
|
|
7
|
+
* The two-endpoint form, not the aggregate `createmeta?expand=` one: the
|
|
8
|
+
* aggregate endpoint is deprecated on Jira Cloud and returns every issue type's
|
|
9
|
+
* every field in one document, which is both larger and less precise than the
|
|
10
|
+
* question being asked. Planning wants one project's issue types, and then one
|
|
11
|
+
* issue type's fields.
|
|
12
|
+
*
|
|
13
|
+
* `retry: false` throughout. These answers decide whether a create is possible
|
|
14
|
+
* and what it will contain, so a retried-and-stale answer is worse than a
|
|
15
|
+
* failure - the same reason `getTransitions` does not retry.
|
|
16
|
+
*
|
|
17
|
+
* Both mappers are defensive about shape. Jira omits fields it considers
|
|
18
|
+
* irrelevant and different deployments populate different ones, so anything
|
|
19
|
+
* unrecognised is dropped rather than guessed at: an entry JAM cannot read is
|
|
20
|
+
* an entry JAM must not claim to have understood.
|
|
21
|
+
*/
|
|
22
|
+
export declare class JiraCloudCreateMetadataAdapter implements JiraCreateMetadataPort {
|
|
23
|
+
private readonly client;
|
|
24
|
+
constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
|
|
25
|
+
getIssueTypes(projectKey: string): Promise<CreateIssueType[]>;
|
|
26
|
+
getCreateFields(projectKey: string, issueTypeId: string): Promise<CreateFieldMetadata[]>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { JiraClient } from "./jira-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Jira Cloud REST v3 create metadata, per project and per issue type.
|
|
4
|
+
*
|
|
5
|
+
* The two-endpoint form, not the aggregate `createmeta?expand=` one: the
|
|
6
|
+
* aggregate endpoint is deprecated on Jira Cloud and returns every issue type's
|
|
7
|
+
* every field in one document, which is both larger and less precise than the
|
|
8
|
+
* question being asked. Planning wants one project's issue types, and then one
|
|
9
|
+
* issue type's fields.
|
|
10
|
+
*
|
|
11
|
+
* `retry: false` throughout. These answers decide whether a create is possible
|
|
12
|
+
* and what it will contain, so a retried-and-stale answer is worse than a
|
|
13
|
+
* failure - the same reason `getTransitions` does not retry.
|
|
14
|
+
*
|
|
15
|
+
* Both mappers are defensive about shape. Jira omits fields it considers
|
|
16
|
+
* irrelevant and different deployments populate different ones, so anything
|
|
17
|
+
* unrecognised is dropped rather than guessed at: an entry JAM cannot read is
|
|
18
|
+
* an entry JAM must not claim to have understood.
|
|
19
|
+
*/
|
|
20
|
+
export class JiraCloudCreateMetadataAdapter {
|
|
21
|
+
client;
|
|
22
|
+
constructor(credentials, fetchImpl) {
|
|
23
|
+
this.client = fetchImpl ? new JiraClient(credentials, fetchImpl) : new JiraClient(credentials);
|
|
24
|
+
}
|
|
25
|
+
async getIssueTypes(projectKey) {
|
|
26
|
+
const { data } = await this.client.request({
|
|
27
|
+
path: `rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`,
|
|
28
|
+
retry: false,
|
|
29
|
+
});
|
|
30
|
+
return (data.issueTypes ?? [])
|
|
31
|
+
.filter((t) => typeof t.id === "string" && typeof t.name === "string")
|
|
32
|
+
.map((t) => ({ id: t.id, name: t.name, subtask: t.subtask === true }));
|
|
33
|
+
}
|
|
34
|
+
async getCreateFields(projectKey, issueTypeId) {
|
|
35
|
+
const { data } = await this.client.request({
|
|
36
|
+
path: `rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes/${encodeURIComponent(issueTypeId)}`,
|
|
37
|
+
retry: false,
|
|
38
|
+
});
|
|
39
|
+
return (data.fields ?? [])
|
|
40
|
+
.map(toFieldMetadata)
|
|
41
|
+
.filter((f) => f !== undefined);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function toFieldMetadata(raw) {
|
|
45
|
+
// Jira has called this `fieldId` and `key` in different responses. Without
|
|
46
|
+
// one of them the entry cannot be matched to anything, so it is dropped -
|
|
47
|
+
// and if it was required, the required-field gate will refuse the plan
|
|
48
|
+
// because JAM cannot show it was supplied.
|
|
49
|
+
const id = typeof raw.fieldId === "string" ? raw.fieldId : typeof raw.key === "string" ? raw.key : undefined;
|
|
50
|
+
if (!id)
|
|
51
|
+
return undefined;
|
|
52
|
+
const allowed = mapAllowedValues(raw.allowedValues);
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
name: typeof raw.name === "string" ? raw.name : id,
|
|
56
|
+
required: raw.required === true,
|
|
57
|
+
hasDefaultValue: raw.hasDefaultValue === true,
|
|
58
|
+
...(allowed ? { allowedValues: allowed } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Allowed values, when Jira constrains the field at all.
|
|
63
|
+
*
|
|
64
|
+
* Undefined and empty mean different things and are kept apart: undefined is
|
|
65
|
+
* "Jira did not constrain this", empty is "Jira constrains it and offers
|
|
66
|
+
* nothing". The first permits a free value, the second permits none.
|
|
67
|
+
*/
|
|
68
|
+
function mapAllowedValues(raw) {
|
|
69
|
+
if (!Array.isArray(raw))
|
|
70
|
+
return undefined;
|
|
71
|
+
return raw.map((entry) => {
|
|
72
|
+
const id = typeof entry?.id === "string" ? entry.id : undefined;
|
|
73
|
+
// Components and priorities use `name`; some option fields use `value`.
|
|
74
|
+
const name = typeof entry?.name === "string"
|
|
75
|
+
? entry.name
|
|
76
|
+
: typeof entry?.value === "string"
|
|
77
|
+
? entry.value
|
|
78
|
+
: undefined;
|
|
79
|
+
return { ...(id ? { id } : {}), ...(name ? { name } : {}) };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { ProjectConfig } from "../../config/schema.js";
|
|
2
2
|
import type { CredentialPort } from "../../ports/credentials.port.js";
|
|
3
|
-
import type { CurrentUser, GetCommentsRequest, GetCommentsResult, GetIssuesRequest, GetIssuesResult, JiraReadPort, ListProjectsResult, SearchPageRequest, SearchPageResult } from "../../ports/jira-read.port.js";
|
|
3
|
+
import type { CurrentUser, GetCommentsRequest, GetCommentsResult, GetIssueRequest, GetIssueResult, GetIssuesRequest, GetIssuesResult, JiraReadPort, ListProjectsResult, SearchPageRequest, SearchPageResult } from "../../ports/jira-read.port.js";
|
|
4
4
|
export declare class JiraCloudReadAdapter implements JiraReadPort {
|
|
5
5
|
private readonly config;
|
|
6
6
|
private readonly client;
|
|
7
7
|
constructor(credentials: CredentialPort, config: ProjectConfig, fetchImpl?: typeof fetch);
|
|
8
8
|
searchPage(req: SearchPageRequest): Promise<SearchPageResult>;
|
|
9
|
+
/**
|
|
10
|
+
* `GET /rest/api/3/issue/{key}` - the single-issue endpoint, not bulkfetch.
|
|
11
|
+
*
|
|
12
|
+
* This is what ConsistencyPolicy means by a direct issue read, and the write
|
|
13
|
+
* plane is the only caller. A 404 is an answer, not a failure: the issue is
|
|
14
|
+
* not there, or not visible to this account, and the caller decides which of
|
|
15
|
+
* those matters.
|
|
16
|
+
*/
|
|
17
|
+
getIssue(req: GetIssueRequest): Promise<GetIssueResult>;
|
|
9
18
|
getIssues(req: GetIssuesRequest): Promise<GetIssuesResult>;
|
|
10
19
|
getComments(req: GetCommentsRequest): Promise<GetCommentsResult>;
|
|
11
20
|
listProjects(): Promise<ListProjectsResult>;
|
|
@@ -27,6 +27,23 @@ export class JiraCloudReadAdapter {
|
|
|
27
27
|
result.nextPageToken = data.nextPageToken;
|
|
28
28
|
return result;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* `GET /rest/api/3/issue/{key}` - the single-issue endpoint, not bulkfetch.
|
|
32
|
+
*
|
|
33
|
+
* This is what ConsistencyPolicy means by a direct issue read, and the write
|
|
34
|
+
* plane is the only caller. A 404 is an answer, not a failure: the issue is
|
|
35
|
+
* not there, or not visible to this account, and the caller decides which of
|
|
36
|
+
* those matters.
|
|
37
|
+
*/
|
|
38
|
+
async getIssue(req) {
|
|
39
|
+
const { data, bytes } = await this.client.request({
|
|
40
|
+
path: `rest/api/3/issue/${encodeURIComponent(req.key)}`,
|
|
41
|
+
query: { fields: req.fields.join(",") },
|
|
42
|
+
});
|
|
43
|
+
if (!data?.key)
|
|
44
|
+
return { responseBytes: bytes };
|
|
45
|
+
return { issue: mapIssueWithMeta(data, this.config).issue, responseBytes: bytes };
|
|
46
|
+
}
|
|
30
47
|
async getIssues(req) {
|
|
31
48
|
const issues = [];
|
|
32
49
|
const commentTotals = {};
|
|
@@ -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.
|
|
@@ -32,10 +45,3 @@ export declare class JiraCloudWriteAdapter implements JiraWritePort {
|
|
|
32
45
|
getTransitions(key: string): Promise<JiraTransition[]>;
|
|
33
46
|
transitionIssue(key: string, transitionId: string): Promise<void>;
|
|
34
47
|
}
|
|
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)}`,
|
|
@@ -75,23 +100,3 @@ export class JiraCloudWriteAdapter {
|
|
|
75
100
|
});
|
|
76
101
|
}
|
|
77
102
|
}
|
|
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>;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { JamError, toJamError } from "../domain/errors.js";
|
|
2
|
+
import { canonicalizePlainText } from "../domain/adf.js";
|
|
3
|
+
import { assertSchemaUnchanged } from "../policy/create-policy.js";
|
|
4
|
+
import { projectKeyOf } from "../policy/write-policy.js";
|
|
5
|
+
import { readIssue } from "./plan-write.js";
|
|
6
|
+
/**
|
|
7
|
+
* Create the issue a plan describes, then go and look at what was created.
|
|
8
|
+
*
|
|
9
|
+
* The same three-step shape as every other apply, with one substitution.
|
|
10
|
+
* Updating an existing issue re-reads that issue and compares its revision;
|
|
11
|
+
* there is no issue to re-read here, so what gets checked instead is the
|
|
12
|
+
* premise the plan was built on - the project's create schema. That is
|
|
13
|
+
* creation's concurrency boundary.
|
|
14
|
+
*
|
|
15
|
+
* 1. Re-derive the schema and check the plan's premises still hold. If the
|
|
16
|
+
* issue type went away, or a required field JAM cannot fill appeared, or a
|
|
17
|
+
* resolved value is no longer offered, nothing is sent.
|
|
18
|
+
* 2. POST the create exactly once. No retry, ever - see below.
|
|
19
|
+
* 3. Read the new issue by the key Jira returned, and check it says what the
|
|
20
|
+
* plan intended. A 201 with a key is Jira accepting a request, not
|
|
21
|
+
* evidence that the issue exists as described.
|
|
22
|
+
*/
|
|
23
|
+
export async function applyCreateIssue(deps, plan) {
|
|
24
|
+
if (plan.mutation.kind !== "create") {
|
|
25
|
+
throw new JamError("CONFIG_INVALID", "A create-issue plan must carry a create mutation.");
|
|
26
|
+
}
|
|
27
|
+
await revalidateSchema(deps, plan);
|
|
28
|
+
const created = await create(deps, plan);
|
|
29
|
+
const after = await verify(deps, plan, created.key);
|
|
30
|
+
deps.writePlans.consume(plan.planId);
|
|
31
|
+
return {
|
|
32
|
+
status: "applied",
|
|
33
|
+
issue: created.key,
|
|
34
|
+
operation: plan.operation,
|
|
35
|
+
before: plan.before,
|
|
36
|
+
after,
|
|
37
|
+
verified: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Ask Jira for the create schema again, and check the plan still stands.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately not a comparison of the two metadata documents. The question is
|
|
44
|
+
* not "is the schema identical" - on an active project it rarely is - but "are
|
|
45
|
+
* this plan's premises still true". See assertSchemaUnchanged.
|
|
46
|
+
*/
|
|
47
|
+
async function revalidateSchema(deps, plan) {
|
|
48
|
+
const { projectKey, schemaRequirements } = plan;
|
|
49
|
+
const issueTypes = await deps.jiraCreateMetadata.getIssueTypes(projectKey);
|
|
50
|
+
const fields = await deps.jiraCreateMetadata.getCreateFields(projectKey, schemaRequirements.issueTypeId);
|
|
51
|
+
assertSchemaUnchanged(schemaRequirements, issueTypes, fields, intendedInput(plan));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The create request as an input again, for re-running the required-field gate.
|
|
55
|
+
*
|
|
56
|
+
* Derived from `intendedAfter` rather than kept as a second copy of the
|
|
57
|
+
* request: one record of what this plan intends, read two ways.
|
|
58
|
+
*/
|
|
59
|
+
function intendedInput(plan) {
|
|
60
|
+
const after = plan.intendedAfter;
|
|
61
|
+
return {
|
|
62
|
+
issueType: plan.schemaRequirements.issueTypeName,
|
|
63
|
+
summary: String(after["summary"] ?? ""),
|
|
64
|
+
...(after["description"] !== undefined ? { description: String(after["description"]) } : {}),
|
|
65
|
+
...(after["priority"] !== undefined ? { priority: String(after["priority"]) } : {}),
|
|
66
|
+
...(Array.isArray(after["labels"]) ? { labels: after["labels"] } : {}),
|
|
67
|
+
...(Array.isArray(after["components"]) ? { components: after["components"] } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Send the create, once.
|
|
72
|
+
*
|
|
73
|
+
* There is no retry here and there must not be one. This is the sharpest
|
|
74
|
+
* version of the rule the whole write plane follows: a create that fails
|
|
75
|
+
* ambiguously may already have produced an issue, and resending it produces a
|
|
76
|
+
* second one - on someone's board, in someone's sprint, with a key nobody is
|
|
77
|
+
* holding. So an ambiguous failure becomes JAM_WRITE_UNCERTAIN and says what
|
|
78
|
+
* to do about it: look in the project, do not send it again.
|
|
79
|
+
*/
|
|
80
|
+
async function create(deps, plan) {
|
|
81
|
+
if (plan.mutation.kind !== "create") {
|
|
82
|
+
throw new JamError("CONFIG_INVALID", "A create-issue plan must carry a create mutation.");
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return await deps.jiraWrite.createIssue(plan.mutation.fields);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
const jamError = toJamError(err);
|
|
89
|
+
if (!isAmbiguous(jamError))
|
|
90
|
+
throw jamError;
|
|
91
|
+
throw new JamError("JAM_WRITE_UNCERTAIN", `JAM could not tell whether the issue was created in ${plan.projectKey}: ${jamError.message} Look in the project to find out - do not retry this write, which could create a second issue.`, { project: plan.projectKey, operation: plan.operation, cause: jamError.code });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A failure that says nothing about whether Jira acted.
|
|
96
|
+
*
|
|
97
|
+
* A 403 or a 400 is a decision Jira made and did not act on. A dropped
|
|
98
|
+
* connection or a 5xx is not: the request may have been processed before the
|
|
99
|
+
* answer went missing. JAM_WRITE_UNCERTAIN raised inside the adapter - Jira
|
|
100
|
+
* accepted a create but named no issue - is ambiguous by construction and
|
|
101
|
+
* passes through unchanged.
|
|
102
|
+
*/
|
|
103
|
+
function isAmbiguous(err) {
|
|
104
|
+
return (err.code === "JIRA_UNAVAILABLE" ||
|
|
105
|
+
err.code === "RATE_LIMITED" ||
|
|
106
|
+
err.code === "JAM_WRITE_UNCERTAIN");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Read the created issue and check it is the one the plan described.
|
|
110
|
+
*
|
|
111
|
+
* Every field in `intendedAfter` is checked, because `intendedAfter` is also
|
|
112
|
+
* what the plan receipt promised `verification.expects` would show. A field
|
|
113
|
+
* the receipt names and the check skips is worse than one it never named: it
|
|
114
|
+
* reports `verified: true` about something nobody looked at.
|
|
115
|
+
*
|
|
116
|
+
* Only the fields the create contract lets a caller ask for are in there.
|
|
117
|
+
* Jira fills in a great deal on its own - reporter, created, status, whatever
|
|
118
|
+
* a project's automation adds - and none of that was requested, so requiring
|
|
119
|
+
* it to match something would be inventing an expectation nobody stated.
|
|
120
|
+
*/
|
|
121
|
+
async function verify(deps, plan, issueKey) {
|
|
122
|
+
// Where the issue landed is part of what was intended. The workspace binding
|
|
123
|
+
// is the whole of JAM's write scope, so a key from another project coming
|
|
124
|
+
// back from a create is the one outcome that must never be reported as the
|
|
125
|
+
// create that was planned.
|
|
126
|
+
const createdProject = projectKeyOf(issueKey);
|
|
127
|
+
if (createdProject !== plan.projectKey) {
|
|
128
|
+
throw verificationFailed(plan, issueKey, { project: plan.projectKey }, { project: createdProject ?? issueKey });
|
|
129
|
+
}
|
|
130
|
+
const issue = await readIssue(deps, issueKey);
|
|
131
|
+
const observed = {};
|
|
132
|
+
for (const field of Object.keys(plan.intendedAfter)) {
|
|
133
|
+
observed[field] = observedValue(issue, field);
|
|
134
|
+
}
|
|
135
|
+
for (const [field, expected] of Object.entries(plan.intendedAfter)) {
|
|
136
|
+
if (!sameValue(observed[field], expected)) {
|
|
137
|
+
throw verificationFailed(plan, issueKey, plan.intendedAfter, observed);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return observed;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The created issue's value for one requested field.
|
|
144
|
+
*
|
|
145
|
+
* The description is canonicalized on the way out, the same way the plan
|
|
146
|
+
* canonicalized what was asked for. Jira stores a document and renders it back
|
|
147
|
+
* as text, so the two are never byte-identical - and a comparison that failed
|
|
148
|
+
* on Jira's own formatting would be reporting a problem that is not there.
|
|
149
|
+
*/
|
|
150
|
+
function observedValue(issue, field) {
|
|
151
|
+
switch (field) {
|
|
152
|
+
case "issueType":
|
|
153
|
+
return issue.issueType;
|
|
154
|
+
case "summary":
|
|
155
|
+
return issue.summary;
|
|
156
|
+
case "description":
|
|
157
|
+
return issue.description === undefined
|
|
158
|
+
? undefined
|
|
159
|
+
: canonicalizePlainText(issue.description);
|
|
160
|
+
case "priority":
|
|
161
|
+
return issue.priority;
|
|
162
|
+
case "labels":
|
|
163
|
+
return issue.labels;
|
|
164
|
+
case "components":
|
|
165
|
+
return issue.components;
|
|
166
|
+
default:
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The issue exists. Say so, and say not to make another one.
|
|
172
|
+
*
|
|
173
|
+
* This is the failure an agent is most likely to answer by trying again, and
|
|
174
|
+
* trying again is the one thing that turns a wrong issue into two wrong
|
|
175
|
+
* issues.
|
|
176
|
+
*/
|
|
177
|
+
function verificationFailed(plan, issueKey, expected, observed) {
|
|
178
|
+
return new JamError("JAM_WRITE_VERIFICATION_FAILED", `Jira created ${issueKey}, but a direct read does not show what the plan intended. A workflow rule or a project automation may have altered the issue as it was created. The issue exists - look at it and fix it there, or delete it. Do not create another one.`, { issueKey, operation: plan.operation, expected, observed });
|
|
179
|
+
}
|
|
180
|
+
function sameValue(observed, expected) {
|
|
181
|
+
if (Array.isArray(expected) || Array.isArray(observed)) {
|
|
182
|
+
const a = Array.isArray(observed) ? [...observed].map(String).sort() : [];
|
|
183
|
+
const b = Array.isArray(expected) ? [...expected].map(String).sort() : [];
|
|
184
|
+
return a.length === b.length && a.every((value, i) => value === b[i]);
|
|
185
|
+
}
|
|
186
|
+
return observed === expected;
|
|
187
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { JamError, toJamError } from "../domain/errors.js";
|
|
2
2
|
import { readModeAfterWrite } from "../policy/consistency-policy.js";
|
|
3
3
|
import { assertUnchanged } from "../policy/write-policy.js";
|
|
4
|
+
import { applyCreateIssue } from "./apply-create-issue.js";
|
|
4
5
|
import { readIssue } from "./plan-write.js";
|
|
5
6
|
/**
|
|
6
7
|
* Execute a plan JAM made, then go and look at what happened.
|
|
@@ -28,6 +29,11 @@ export async function applyWritePlan(deps, request) {
|
|
|
28
29
|
if (readModeAfterWrite() !== "direct") {
|
|
29
30
|
throw new JamError("CONFIG_INVALID", "Write confirmation must use a direct issue read.");
|
|
30
31
|
}
|
|
32
|
+
// Creation follows the same three steps with a different first one: there is
|
|
33
|
+
// no issue to re-read, so what gets re-checked is the create schema the plan
|
|
34
|
+
// was built on. Both paths still end in a direct read of a real issue.
|
|
35
|
+
if (plan.kind === "create-issue")
|
|
36
|
+
return applyCreateIssue(deps, plan);
|
|
31
37
|
const current = await readIssue(deps, plan.issueKey);
|
|
32
38
|
assertUnchanged(plan.issueKey, plan.baseUpdated, current.updated);
|
|
33
39
|
const outcome = await mutate(deps, plan);
|
|
@@ -65,6 +71,11 @@ async function mutate(deps, plan) {
|
|
|
65
71
|
case "transition":
|
|
66
72
|
await deps.jiraWrite.transitionIssue(plan.issueKey, plan.mutation.transitionId);
|
|
67
73
|
return {};
|
|
74
|
+
case "create":
|
|
75
|
+
// Unreachable: a create plan is routed to applyCreateIssue above. The
|
|
76
|
+
// case exists so adding a mutation kind is a compile error here rather
|
|
77
|
+
// than a silent fall-through that writes nothing and reports success.
|
|
78
|
+
throw new JamError("CONFIG_INVALID", "A create mutation cannot be applied through the existing-issue path.");
|
|
68
79
|
}
|
|
69
80
|
}
|
|
70
81
|
catch (err) {
|
|
@@ -94,10 +105,14 @@ function isAmbiguous(err) {
|
|
|
94
105
|
async function verify(deps, plan) {
|
|
95
106
|
const issue = await readIssue(deps, plan.issueKey);
|
|
96
107
|
if (plan.mutation.kind === "comment") {
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
// Direct issue GET again, not the bulk endpoint: this is post-write
|
|
109
|
+
// confirmation, and ConsistencyPolicy makes no exception for the read that
|
|
110
|
+
// happens to want the comment field.
|
|
111
|
+
const { issue: withComments } = await deps.jira.getIssue({
|
|
112
|
+
key: plan.issueKey,
|
|
99
113
|
fields: ["summary", "status", "comment", "updated"],
|
|
100
|
-
})
|
|
114
|
+
});
|
|
115
|
+
const comments = withComments?.comments ?? [];
|
|
101
116
|
const wanted = plan.mutation.text.trim();
|
|
102
117
|
const found = comments.some((c) => c.body.trim() === wanted);
|
|
103
118
|
if (!found) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { JamDeps } from "../deps.js";
|
|
2
|
+
import type { CreateIssueInput, CreateIssueWritePlan, WritePlanReceipt } from "../domain/write.js";
|
|
3
|
+
export type PlanCreateIssueRequest = {
|
|
4
|
+
input: Record<string, unknown>;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Work out whether an issue can be created here, and describe the one JAM
|
|
8
|
+
* would create.
|
|
9
|
+
*
|
|
10
|
+
* Reads only - the create metadata endpoints answer questions about a
|
|
11
|
+
* project's configuration and change nothing. The order is the point:
|
|
12
|
+
*
|
|
13
|
+
* 1. Validate the request against JAM's own contract. Anything refusable
|
|
14
|
+
* without asking Jira is refused before a round trip is spent on it.
|
|
15
|
+
* 2. Ask Jira which issue types this account can create here, and resolve the
|
|
16
|
+
* requested one against that list. An id is never derived from a name.
|
|
17
|
+
* 3. Ask Jira what that issue type's create screen requires, and refuse now if
|
|
18
|
+
* it requires something JAM cannot express. A create JAM knows Jira will
|
|
19
|
+
* reject is not sent.
|
|
20
|
+
* 4. Resolve every constrained value against Jira's own allowed list.
|
|
21
|
+
*
|
|
22
|
+
* What comes out is a plan that records not just the intended issue but the
|
|
23
|
+
* premises it rests on, so apply can check they still hold.
|
|
24
|
+
*
|
|
25
|
+
* The target project is never a parameter. It is the project this workspace is
|
|
26
|
+
* bound to, which is what the user consented to when they set JAM up; taking
|
|
27
|
+
* it from the caller would make the binding advisory.
|
|
28
|
+
*/
|
|
29
|
+
export declare function planCreateIssue(deps: JamDeps, request: PlanCreateIssueRequest): Promise<{
|
|
30
|
+
plan: CreateIssueWritePlan;
|
|
31
|
+
receipt: WritePlanReceipt;
|
|
32
|
+
}>;
|
|
33
|
+
/** The project this workspace is bound to, or a refusal that says so. */
|
|
34
|
+
export declare function configuredProject(deps: JamDeps): string;
|
|
35
|
+
/**
|
|
36
|
+
* Check the request against the create contract, and normalize it.
|
|
37
|
+
*
|
|
38
|
+
* Pure: no Jira, no state. `key` is not accepted here at all - there is no
|
|
39
|
+
* issue to name - and neither is `project`, which comes from the binding.
|
|
40
|
+
* Anything outside CREATABLE_FIELDS is rejected rather than ignored: silently
|
|
41
|
+
* dropping a field an agent asked for would create an issue that is not the
|
|
42
|
+
* one it described.
|
|
43
|
+
*/
|
|
44
|
+
export declare function validateCreateInput(raw: Record<string, unknown>): CreateIssueInput;
|