@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.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/credentials/windows-user-env.d.ts +3 -1
  3. package/dist/adapters/credentials/windows-user-env.js +20 -1
  4. package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.d.ts +39 -0
  5. package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.js +94 -0
  6. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.d.ts +27 -0
  7. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
  8. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
  9. package/dist/adapters/jira-cloud/jira-read.adapter.js +26 -0
  10. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +22 -7
  11. package/dist/adapters/jira-cloud/jira-write.adapter.js +41 -20
  12. package/dist/application/apply-create-issue.d.ts +20 -0
  13. package/dist/application/apply-create-issue.js +187 -0
  14. package/dist/application/apply-write.js +56 -5
  15. package/dist/application/plan-create-issue.d.ts +44 -0
  16. package/dist/application/plan-create-issue.js +188 -0
  17. package/dist/application/plan-write.d.ts +16 -4
  18. package/dist/application/plan-write.js +107 -18
  19. package/dist/application/write-plan-store.d.ts +2 -2
  20. package/dist/application/write-plan-store.js +13 -1
  21. package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
  22. package/dist/cli/auth.d.ts +6 -0
  23. package/dist/cli/auth.js +2 -1
  24. package/dist/deps.d.ts +20 -0
  25. package/dist/deps.js +12 -0
  26. package/dist/domain/adf.d.ts +35 -0
  27. package/dist/domain/adf.js +65 -0
  28. package/dist/domain/errors.d.ts +1 -1
  29. package/dist/domain/errors.js +17 -0
  30. package/dist/domain/write.d.ts +165 -15
  31. package/dist/domain/write.js +34 -1
  32. package/dist/mcp/create-server.d.ts +4 -0
  33. package/dist/mcp/create-server.js +5 -0
  34. package/dist/mcp/tools/jira-write-plan.tool.js +42 -6
  35. package/dist/policy/assignee-policy.d.ts +60 -0
  36. package/dist/policy/assignee-policy.js +103 -0
  37. package/dist/policy/consistency-policy.d.ts +10 -4
  38. package/dist/policy/create-policy.d.ts +86 -0
  39. package/dist/policy/create-policy.js +182 -0
  40. package/dist/policy/write-policy.d.ts +10 -1
  41. package/dist/policy/write-policy.js +15 -1
  42. package/dist/ports/jira-assignee-resolution.port.d.ts +51 -0
  43. package/dist/ports/jira-assignee-resolution.port.js +1 -0
  44. package/dist/ports/jira-create-metadata.port.d.ts +25 -0
  45. package/dist/ports/jira-create-metadata.port.js +1 -0
  46. package/dist/ports/jira-read.port.d.ts +34 -0
  47. package/dist/ports/jira-write.port.d.ts +17 -0
  48. 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.1.0 doctor`, or just `jam
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.1.0 init` instead.
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
- constructor(queryFn?: RegQueryFn);
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
- constructor(queryFn = defaultRegQuery) {
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>;