@jam-mcp/server 1.3.1 → 1.4.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.
@@ -1,230 +0,0 @@
1
- import { JamError } from "../domain/errors.js";
2
- /**
3
- * Which configured field this selector names.
4
- *
5
- * The id is the identity; the name is an alias for people. Resolution is exact
6
- * on either - no substring, no fuzz - because the alternative is an agent's
7
- * approximate word choosing which field on somebody's board gets rewritten.
8
- *
9
- * Only `writable: true` entries are candidates, including for the refusal
10
- * message: naming a read-only field as an alternative would suggest it is one
11
- * selector away from being written.
12
- */
13
- export function resolveWritableField(config, requested) {
14
- const wanted = requested.trim();
15
- if (wanted.length === 0) {
16
- throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "custom-field.update needs a non-empty `input.field`.", { operation: "custom-field.update" });
17
- }
18
- const writable = config.customFields.filter((f) => f.writable);
19
- const match = writable.find((f) => f.id.toLowerCase() === wanted.toLowerCase()) ??
20
- writable.find((f) => f.name.trim().toLowerCase() === wanted.toLowerCase());
21
- if (!match) {
22
- throw new JamError("JAM_WRITE_FIELD_NOT_ALLOWED", writable.length === 0
23
- ? `No custom field in this project is writable. A team opts one in by adding \`writable: true\` to its entry in .jira-agent/project.yaml; being readable does not make a field writable.`
24
- : `"${requested}" is not a writable custom field in this project. JAM writes only the exact ids a team has opted in.`, {
25
- requested,
26
- writableCustomFields: writable.map((f) => ({ id: f.id, name: f.name })),
27
- });
28
- }
29
- return { id: match.id, name: match.name };
30
- }
31
- /**
32
- * The field as Jira currently offers it on this issue, or a refusal.
33
- *
34
- * Absent from the edit metadata and present-but-not-settable are different
35
- * situations with the same answer for the caller, so they share a code and
36
- * differ in the detail: one means the field is not on this screen, the other
37
- * that Jira will not let this account set it.
38
- */
39
- export function assertEditable(issueKey, field, metadata) {
40
- const found = metadata.find((f) => f.id === field.id);
41
- if (!found) {
42
- throw new JamError("JAM_WRITE_CUSTOM_FIELD_NOT_EDITABLE", `Jira does not offer ${field.name} (${field.id}) on ${issueKey}'s edit screen for this account. The field may not apply to this project or issue type, or this account may not be able to edit it.`, { issueKey, fieldId: field.id, fieldName: field.name, reason: "NOT_ON_EDIT_SCREEN" });
43
- }
44
- if (!found.operations.includes("set")) {
45
- throw new JamError("JAM_WRITE_CUSTOM_FIELD_NOT_EDITABLE", `Jira lists ${field.name} (${field.id}) on ${issueKey} but does not offer "set" for it${found.operations.length > 0 ? ` - only ${found.operations.join(", ")}` : ""}. JAM only sets a value; it does not add to or remove from one.`, {
46
- issueKey,
47
- fieldId: field.id,
48
- fieldName: field.name,
49
- operations: found.operations,
50
- reason: "SET_NOT_OFFERED",
51
- });
52
- }
53
- return found;
54
- }
55
- /**
56
- * Which of the four families this field belongs to, if any.
57
- *
58
- * Classified from Jira's own `schema`, which is the vocabulary Jira answers
59
- * in. The implementation key (`schema.custom`) deliberately does not decide
60
- * it: there are hundreds of them, they are app-specific, and a field's wire
61
- * shape follows its type rather than its plugin.
62
- *
63
- * Anything unclassified is refused. Posting an unknown type to see what
64
- * happens would use a Jira 400 as schema discovery, and on the occasions it
65
- * did not 400 it would write something nobody described.
66
- */
67
- export function classifyKind(field) {
68
- const { type, items } = field.schema;
69
- if (type === "string" && !items)
70
- return "text";
71
- if (type === "number" && !items)
72
- return "number";
73
- if (type === "option" && !items)
74
- return "single-option";
75
- if (type === "array" && items === "option")
76
- return "multi-option";
77
- throw new JamError("JAM_WRITE_CUSTOM_FIELD_TYPE_UNSUPPORTED", `${field.name} (${field.id}) is a ${describeType(field)} field, and JAM does not know how to write one safely yet. Supported: single-line text, number, single-select and multi-select.`, {
78
- fieldId: field.id,
79
- fieldName: field.name,
80
- schema: field.schema,
81
- supported: ["text", "number", "single-option", "multi-option"],
82
- });
83
- }
84
- function describeType(field) {
85
- const { type, items } = field.schema;
86
- return items ? `${type} of ${items}` : type;
87
- }
88
- /**
89
- * The value, checked against the family and turned into what Jira expects.
90
- *
91
- * Types are never coerced. `"5"` is not `5`: a caller that meant a number can
92
- * say so, and silently converting would make JAM's idea of the value differ
93
- * from the caller's in exactly the cases where it matters.
94
- *
95
- * Nothing here clears a field. Empty strings, empty arrays and null are
96
- * refused rather than treated as "unset" - removing a value is a different
97
- * intent from setting one, and it is not in this version.
98
- */
99
- export function resolveCustomFieldValue(field, kind, input) {
100
- const { value } = input;
101
- const named = { id: field.id, name: field.name };
102
- switch (kind) {
103
- case "text": {
104
- if (typeof value !== "string")
105
- throw wrongType(field, kind, value);
106
- const text = value.trim();
107
- if (text.length === 0)
108
- throw refuseClear(field);
109
- return { jiraValue: text, view: { ...named, value: text } };
110
- }
111
- case "number": {
112
- if (typeof value !== "number" || !Number.isFinite(value))
113
- throw wrongType(field, kind, value);
114
- return { jiraValue: value, view: { ...named, value } };
115
- }
116
- case "single-option": {
117
- if (typeof value !== "string")
118
- throw wrongType(field, kind, value);
119
- const option = resolveOption(field, value);
120
- // Jira takes the option by id. The label is what a person reads, and two
121
- // options could carry the same one.
122
- return {
123
- jiraValue: { id: option.id },
124
- view: { ...named, value: option },
125
- resolvedOptions: [option],
126
- };
127
- }
128
- case "multi-option": {
129
- if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
130
- throw wrongType(field, kind, value);
131
- }
132
- if (value.length === 0)
133
- throw refuseClear(field);
134
- const seen = new Set();
135
- for (const raw of value) {
136
- const key = raw.trim().toLowerCase();
137
- if (seen.has(key)) {
138
- throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `"${raw}" appears more than once in the value for ${field.name}. JAM does not quietly drop the repeat - say each option once.`, { fieldId: field.id, repeated: raw });
139
- }
140
- seen.add(key);
141
- }
142
- // Every option resolves, or none is written. A partly-applied selection
143
- // is a selection nobody asked for.
144
- const options = value.map((raw) => resolveOption(field, raw));
145
- return {
146
- jiraValue: options.map((o) => ({ id: o.id })),
147
- view: { ...named, value: options },
148
- resolvedOptions: options,
149
- };
150
- }
151
- }
152
- }
153
- /**
154
- * Which option Jira offers under this name, if exactly one does.
155
- *
156
- * An option id wins outright, then an exact label ignoring case and space.
157
- * Nothing partial: Jira's option lists are short and a caller can name one
158
- * exactly, so a near miss is a question rather than a guess.
159
- */
160
- function resolveOption(field, requested) {
161
- const allowed = field.allowedValues;
162
- if (!allowed) {
163
- throw new JamError("JAM_WRITE_CUSTOM_FIELD_TYPE_UNSUPPORTED", `${field.name} (${field.id}) is a select field, but Jira did not say which options it offers, so JAM cannot resolve "${requested}" to one.`, { fieldId: field.id, fieldName: field.name, schema: field.schema });
164
- }
165
- const wanted = requested.trim();
166
- const byId = allowed.filter((o) => o.id === wanted);
167
- const matches = byId.length > 0
168
- ? byId
169
- : allowed.filter((o) => o.label.trim().toLowerCase() === wanted.toLowerCase());
170
- if (matches.length === 0) {
171
- throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", allowed.length === 0
172
- ? `Jira offers no options for ${field.name} on this issue, so "${requested}" cannot be set.`
173
- : `"${requested}" is not an option Jira offers for ${field.name}. Allowed: ${allowed.map((o) => o.label).join(", ")}.`, { fieldId: field.id, requested, allowed });
174
- }
175
- if (matches.length > 1) {
176
- throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `"${requested}" matches ${matches.length} options for ${field.name}. Pass the option id of the one you mean.`, { fieldId: field.id, requested, candidates: matches });
177
- }
178
- return matches[0];
179
- }
180
- /**
181
- * Do this plan's premises still hold?
182
- *
183
- * Semantic, like the create schema check and for the same reason: comparing
184
- * whole metadata documents would invalidate every outstanding plan whenever an
185
- * unrelated field appeared on the screen. What is compared is what the plan
186
- * actually rested on - the field is still settable, still the same family,
187
- * still the same schema, and every option it chose is still offered under the
188
- * same label.
189
- *
190
- * A renamed option is treated as a changed one. The id is the identity, but a
191
- * label is what the plan showed a human before they agreed to it, and "Backend"
192
- * becoming "Platform" is a different statement about the issue.
193
- */
194
- export function assertCustomFieldUnchanged(issueKey, requirements, metadata) {
195
- const field = metadata.find((f) => f.id === requirements.fieldId);
196
- if (!field) {
197
- throw schemaChanged(`${requirements.fieldName} (${requirements.fieldId}) is no longer on ${issueKey}'s edit screen for this account.`, { issueKey, fieldId: requirements.fieldId });
198
- }
199
- if (!field.operations.includes("set")) {
200
- throw schemaChanged(`Jira no longer offers "set" for ${requirements.fieldName} on ${issueKey}.`, { issueKey, fieldId: field.id, operations: field.operations });
201
- }
202
- if (field.schema.type !== requirements.schema.type ||
203
- field.schema.items !== requirements.schema.items) {
204
- throw schemaChanged(`${requirements.fieldName} is no longer a ${requirements.kind} field.`, { issueKey, fieldId: field.id, planned: requirements.schema, current: field.schema });
205
- }
206
- for (const planned of requirements.resolvedOptions ?? []) {
207
- const current = field.allowedValues?.find((o) => o.id === planned.id);
208
- if (!current) {
209
- throw schemaChanged(`Option "${planned.label}" is no longer offered for ${requirements.fieldName}.`, { issueKey, fieldId: field.id, option: planned });
210
- }
211
- if (current.label !== planned.label) {
212
- throw schemaChanged(`Option "${planned.label}" has been renamed to "${current.label}", so this plan no longer describes the change it showed.`, { issueKey, fieldId: field.id, planned, current });
213
- }
214
- }
215
- }
216
- function schemaChanged(what, details) {
217
- return new JamError("JAM_WRITE_SCHEMA_CHANGED", `${what} This plan was built on the field's configuration as it was, so it no longer describes a change JAM can make. Nothing was written - plan again.`, details);
218
- }
219
- function wrongType(field, kind, value) {
220
- const wanted = {
221
- text: "a string",
222
- number: "a number",
223
- "single-option": "a string naming one option",
224
- "multi-option": "an array of strings naming options",
225
- }[kind];
226
- return new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `${field.name} (${field.id}) is a ${kind} field and needs ${wanted}. JAM does not convert between types - "5" and 5 are different values, and guessing which was meant is not JAM's to do.`, { fieldId: field.id, kind, received: typeof value });
227
- }
228
- function refuseClear(field) {
229
- return new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `custom-field.update sets a value; it does not clear one. ${field.name} cannot be set to an empty value in this version.`, { fieldId: field.id, reason: "CLEAR_NOT_SUPPORTED" });
230
- }
@@ -1,22 +0,0 @@
1
- import type { EditFieldMetadata } from "../domain/write.js";
2
- /**
3
- * What Jira will let this account change on one issue, right now.
4
- *
5
- * `GET /rest/api/3/issue/{key}/editmeta` is the authority, and it is asked
6
- * rather than reconstructed. A custom field's applicability depends on the
7
- * project, the issue type, the field's contexts, the screen it is on and the
8
- * permissions of whoever is asking - JAM does not carry a copy of that model,
9
- * and the field-context APIs that would let it try need administrator rights
10
- * most tokens do not have. So the question is put to Jira in the form it can
11
- * answer exactly: on this issue, for this account, what is editable and how.
12
- *
13
- * The same shape as the other read-shaped ports, for the same reasons: it
14
- * mutates nothing, so it does not belong behind the write port's no-retry
15
- * contract, and it answers a question about a configuration rather than about
16
- * an issue, so the read port's completeness semantics would mean nothing here.
17
- *
18
- * It does not retry. Its answer decides a mutation.
19
- */
20
- export interface JiraEditMetadataPort {
21
- getEditableFields(issueKey: string): Promise<EditFieldMetadata[]>;
22
- }
@@ -1 +0,0 @@
1
- export {};