( wsDir: string, id: string, patch: IssueFieldPatch, )
| 107 | * IssueRecord, or `not_found` when the file is absent. |
| 108 | */ |
| 109 | export async function updateIssueFields( |
| 110 | wsDir: string, |
| 111 | id: string, |
| 112 | patch: IssueFieldPatch, |
| 113 | ): Promise<MutateResult> { |
| 114 | if (!ID_RE.test(id)) return { ok: false, reason: 'not_found' } |
| 115 | const raw = await readWorkspaceFile(wsDir, relFor(id)) |
| 116 | if (raw === null) return { ok: false, reason: 'not_found' } |
| 117 | |
| 118 | const split = splitFrontmatter(raw) |
| 119 | if (!split) return { ok: false, reason: 'invalid', error: 'missing YAML frontmatter' } |
| 120 | |
| 121 | // Re-validate the existing content first so we never write back a file that |
| 122 | // was already broken, and so the merge starts from a known-good frontmatter. |
| 123 | const current = parseIssueContent(id, raw) |
| 124 | if (!current.ok) return { ok: false, reason: 'invalid', error: current.error } |
| 125 | |
| 126 | // Parse the raw frontmatter object (NOT the zod-defaulted record) so we |
| 127 | // preserve every author-written key verbatim and only overwrite what changed. |
| 128 | const data = parseFrontmatterObject(split.frontmatter) |
| 129 | if (!data) return { ok: false, reason: 'invalid', error: 'frontmatter is not a mapping' } |
| 130 | |
| 131 | if (patch.status !== undefined) { |
| 132 | if (!ISSUE_STATUSES.includes(patch.status)) { |
| 133 | return { ok: false, reason: 'invalid', error: `invalid status: ${patch.status}` } |
| 134 | } |
| 135 | data.status = patch.status |
| 136 | } |
| 137 | if (patch.priority !== undefined) { |
| 138 | if (!ISSUE_PRIORITIES.includes(patch.priority)) { |
| 139 | return { ok: false, reason: 'invalid', error: `invalid priority: ${patch.priority}` } |
| 140 | } |
| 141 | data.priority = patch.priority |
| 142 | } |
| 143 | if (patch.assignee !== undefined) { |
| 144 | const a = patch.assignee.trim() |
| 145 | if (a.length === 0) return { ok: false, reason: 'invalid', error: 'assignee must be a non-empty string' } |
| 146 | data.assignee = a |
| 147 | } |
| 148 | if (patch.agent !== undefined) { |
| 149 | if (patch.agent === null) { |
| 150 | delete data.agent |
| 151 | } else { |
| 152 | const a = patch.agent.trim() |
| 153 | if (a.length === 0) return { ok: false, reason: 'invalid', error: 'agent must be a non-empty string or null' } |
| 154 | data.agent = a |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | const content = serializeIssue(data, split.body) |
| 159 | // Final guard: never persist a file that wouldn't read back cleanly. |
| 160 | const reparsed = parseIssueContent(id, content) |
| 161 | if (!reparsed.ok) return { ok: false, reason: 'invalid', error: reparsed.error } |
| 162 | await writeWorkspaceFile(wsDir, relFor(id), content) |
| 163 | return { ok: true, issue: reparsed.issue } |
| 164 | } |
| 165 | |
| 166 | /** |
no test coverage detected