三件事經同一個 PATCH 送出,因為排時程時它們幾乎總是一起改,分開發等於多兩次往返。 Milestone 只認既有的:指到不存在的就中止並列出可選項目,本工具不建立 Milestone。 人天估算只寫進 body 的人類可讀一行。Gitea 1.27 的 API 沒有任何請求定義接受 time_estimate——它只出現在 Issue 的回應裡——所以議題的估算欄位無法由 API 寫入。 新增的 upsertLineInSection 負責就地更新那一行:重跑改估算不會累積成兩行,值沒變 就不把 body 塞進 PATCH,免得在議題上留下一筆沒有內容的編輯紀錄。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
6.1 KiB
JavaScript
181 lines
6.1 KiB
JavaScript
/**
|
||
* 議題 body 的 markdown 解析。
|
||
*
|
||
* 純函式,不碰網路也不碰檔案系統:抽取類腳本的解析全部走這裡,
|
||
* 解析規則只寫一次,需求議題與工作包議題共用同一套。
|
||
*
|
||
* 全檔的共同前提是「圍欄裡的東西不是內容」:議題裡放 mermaid 或程式碼是常態,
|
||
* 那裡面的井字號不是標題、減號不是清單項、直線不是表格。這件事只在 eachLine
|
||
* 處理一次,其餘函式都靠它。
|
||
*/
|
||
|
||
/**
|
||
* 逐行走過內容並標註圍欄狀態。
|
||
* 圍欄以 ``` 或 ~~~ 開啟,且要同一種標記才算關閉——混用時後者只是普通文字。
|
||
* 圍欄沒關就到結尾時,其後的內容一律算在圍欄內,這與 markdown 的實際渲染一致。
|
||
* @param {string} text
|
||
* @returns {Generator<{line: string, inFence: boolean, isFence: boolean}>}
|
||
*/
|
||
function* eachLine(text) {
|
||
let fence = null;
|
||
|
||
for (const line of (text ?? '').split('\n')) {
|
||
const marker = line.match(/^\s*(`{3,}|~{3,})/)?.[1]?.[0];
|
||
|
||
if (marker && fence === null) {
|
||
fence = marker;
|
||
yield { line, inFence: true, isFence: true };
|
||
continue;
|
||
}
|
||
if (marker && marker === fence) {
|
||
fence = null;
|
||
yield { line, inFence: true, isFence: true };
|
||
continue;
|
||
}
|
||
yield { line, inFence: fence !== null, isFence: false };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 依 `## 標題` 切出各段落。圍欄內的行原樣保留在段落內容裡
|
||
* ——流程圖那一段要的就是整個 mermaid 區塊。
|
||
* @param {string} body 議題 body
|
||
* @returns {Map<string, string>} 段落名稱 → 該段內容(前後空白已修掉)
|
||
*/
|
||
export function parseSections(body) {
|
||
const sections = new Map();
|
||
const buffer = [];
|
||
let current = null;
|
||
|
||
const flush = () => {
|
||
if (current !== null) sections.set(current, buffer.join('\n').trim());
|
||
buffer.length = 0;
|
||
};
|
||
|
||
for (const { line, inFence } of eachLine(body)) {
|
||
const heading = inFence ? null : line.match(/^##\s+(.+?)\s*$/);
|
||
if (heading) {
|
||
flush();
|
||
current = heading[1];
|
||
continue;
|
||
}
|
||
if (current !== null) buffer.push(line);
|
||
}
|
||
flush();
|
||
|
||
return sections;
|
||
}
|
||
|
||
/**
|
||
* 取出文字型段落的整段內容。段落不存在時回空字串,不報錯 ——
|
||
* 缺段落是模板的正常變體,不是解析失敗。
|
||
* @param {Map<string, string>} sections
|
||
* @param {string} name
|
||
* @returns {string}
|
||
*/
|
||
export function textSection(sections, name) {
|
||
return sections.get(name) ?? '';
|
||
}
|
||
|
||
/**
|
||
* 取出列表型段落的每一項。符號清單與編號清單一視同仁,checkbox 只留文字不留標記
|
||
* ——需求議題的驗收標準不追蹤勾選狀態。巢狀項目一律攤平:需求議題的模板沒有巢狀,
|
||
* 真的出現時寧可多帶一項,也不要無聲吃掉內容。
|
||
* @param {Map<string, string>} sections
|
||
* @param {string} name
|
||
* @returns {string[]}
|
||
*/
|
||
export function listSection(sections, name) {
|
||
const items = [];
|
||
|
||
for (const { line, inFence } of eachLine(sections.get(name))) {
|
||
if (inFence) continue;
|
||
const item = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.*)$/);
|
||
if (!item) continue;
|
||
|
||
const text = item[1].replace(/^\[[ xX]\]\s*/, '').trim();
|
||
if (text !== '') items.push(text);
|
||
}
|
||
return items;
|
||
}
|
||
|
||
/**
|
||
* 取出兩欄表格型段落。以分隔列(|---|---|)為界,之後才是資料列;
|
||
* 沒有分隔列就當成沒有資料,避免把表頭當成一筆名詞。
|
||
*
|
||
* 只處理兩欄:多出來的欄會被丟掉。目前唯一的使用者是需求議題的領域名詞表。
|
||
* 工作包的介面契約是四欄(介面/產出者/消費者/形狀),wp-extract 需要另一個
|
||
* 保留全部欄位的版本,不能直接沿用這一支。
|
||
* @param {Map<string, string>} sections
|
||
* @param {string} name
|
||
* @returns {{term: string, def: string}[]}
|
||
*/
|
||
export function tableSection(sections, name) {
|
||
const rows = [];
|
||
|
||
for (const { line, inFence } of eachLine(sections.get(name))) {
|
||
if (inFence || !line.trim().startsWith('|')) continue;
|
||
rows.push(splitRow(line));
|
||
}
|
||
|
||
const separator = rows.findIndex(isSeparator);
|
||
if (separator === -1) return [];
|
||
|
||
return rows
|
||
.slice(separator + 1)
|
||
// 同一段落裡若不慎貼了第二張表,它的分隔列不該變成一筆 {term:'---'}
|
||
.filter((cells) => !isSeparator(cells))
|
||
.filter((cells) => cells.length >= 2 && cells.some((cell) => cell !== ''))
|
||
.map(([term, def]) => ({ term, def }));
|
||
}
|
||
|
||
/** 以未被逸脫的直線切欄,再把 `\|` 還原成內容裡的直線 */
|
||
function splitRow(line) {
|
||
return line
|
||
.trim()
|
||
.replace(/^\||\|$/g, '')
|
||
.split(/(?<!\\)\|/)
|
||
.map((cell) => cell.replace(/\\\|/g, '|').trim());
|
||
}
|
||
|
||
function isSeparator(cells) {
|
||
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
|
||
}
|
||
|
||
/**
|
||
* 在指定段落裡就地更新(或補上)一行「前綴+值」。
|
||
*
|
||
* 用於人天估算這種「議題上只該有一行、重跑要覆蓋而不是累積」的欄位。
|
||
* 段落不存在時補在 body 結尾——寧可放錯位置,也不要讓值靜靜消失。
|
||
*
|
||
* @param {string} body 議題 body
|
||
* @param {string} section 段落名稱,例如 '關聯'
|
||
* @param {string} line 完整的一行,例如 '估算人天:3'
|
||
* @returns {string} 更新後的 body;內容沒有變動時回傳原字串
|
||
*/
|
||
export function upsertLineInSection(body, section, line) {
|
||
const prefix = line.slice(0, line.indexOf(':') + 1);
|
||
const existing = new RegExp(`^${escapeRegExp(prefix)}.*$`, 'm');
|
||
|
||
if (existing.test(body)) {
|
||
const updated = body.replace(existing, line);
|
||
return updated;
|
||
}
|
||
|
||
const heading = `## ${section}`;
|
||
const start = body.indexOf(heading);
|
||
if (start === -1) {
|
||
return `${body.replace(/\n*$/, '')}\n\n${line}\n`;
|
||
}
|
||
|
||
// 段落的結尾是下一個標題之前,或整份 body 的結尾
|
||
const after = body.indexOf('\n## ', start + heading.length);
|
||
const end = after === -1 ? body.length : after;
|
||
const head = body.slice(0, end).replace(/\n*$/, '');
|
||
return `${head}\n${line}\n${body.slice(end).replace(/^\n+/, '\n')}`;
|
||
}
|
||
|
||
function escapeRegExp(text) {
|
||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|