feat/sdlc-report-timesheet/main #34

Merged
admin merged 3 commits from feat/sdlc-report-timesheet/main into master 2026-09-17 06:54:15 +00:00
6 changed files with 1144 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
name: sdlc-report
description: 僅由 /sdlc-report 指令叫用。產出本週、指定月份或指定年份的工時報表,只印在終端。
# sdlc-report
把 Gitea 上的碼錶紀錄整理成一份可以直接在週會上使用的工時報表。
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
## 輸入
- **repo** — `owner/name`。沒給就問,不要猜。
- **期間** — 三選一,沒給就是本週:
- `--week` 本週一至今日(預設)
- `--month YYYY-MM` 指定月份,含 W1–W5 分段小計
- `--year YYYY` 指定年份,以月份分段小計
## 步驟
### 1. 取數字
```
node scripts/report.js --repo <owner/name> [--week | --month YYYY-MM | --year YYYY]
```
腳本回傳一行 JSON,裡面已經算好總計、分段小計與逐議題明細,**時分格式也一併算好了**
(`實際工時`、`落差工時`)。直接取用那些字串,不要自己再乘一次三千六百 —— 報表上的數字
自己算錯,比沒有報表更糟。
要回頭補印過去的某一週,加 `--today YYYY-MM-DD` 指定「今天」是哪一天。
### 2. 套模板印出
套用 `templates/report.md`,佔位對應如下:
- `{{期間}}` 期間標籤(`期間.標籤`)
- `{{範圍}}` 一行說明這份報表涵蓋哪個 repo、哪段日期、以幾小時當一人天
- `{{實際工時}}`、`{{估算人天}}`、`{{已估實際}}`、`{{落差}}` 取自 `總計`
- `{{分段}}` 每個分段一列表格列;**週報沒有分段,連同「分段小計」標題整段不印**——
markdown 表格只留表頭不留資料列,在終端上看起來像壞掉,不像「本來就沒有」
- `{{議題}}` 每顆議題一列表格列,議題欄寫成指回該議題的連結
- `{{附註}}` 見下方「怎麼讀落差」;沒有要提醒的就填「無」
報表**只印在終端**。不要張貼到議題、PR、聊天室或任何其他管道——這份要給誰看,是使用者的
決定,不是這個流程的。
### 3. 回報
印完就結束。不要順手去改議題、不要替使用者補登漏掉的工時。
## 期間怎麼切
三句話,沒有例外:
1. **一週為週一至週日。**
2. **跨月的那一週依「該週週五所屬月份」歸屬。** 一筆工時因此只會落在一個月裡,
不會被前後兩個月各算一次。
3. **W1–W5 指該週五是當月第幾個週五。** 當月有幾個週五就有幾段,有五個就排到 W5。
舉例:2026-01 的第一個週五是 01-02,所以 2025-12-29(週一)那天的工時算在 2026 年 1 月的
W1;2026-02-01(週日)那天的工時,它那一週的週五是 01-30,所以算在 2026 年 1 月的 W5,
而不是 2 月。
年報同理:跨年的那一週也依週五歸屬,2025-12-29 的工時會出現在 2026 年的報表裡。
## 怎麼讀落差
落差 = 實際工時 − 估算。**正數代表超出估算,負數代表還有餘裕。**
**總計的落差只涵蓋有估算的議題。** 分子是 `已估實際秒`(那些議題的實際工時)而不是 `實際秒`
(全部)——拿全部實際去比只有部分議題的估算,沒估算的工時會整批變成「超出估算」,落差就永遠
是灌水的正數。報表上把 `實際工時` 與 `已估實際` 並排印出來,兩者差多少就是沒估算的部分有多大。
估算讀的是議題「關聯」段落裡的「估算人天」那一行。換算時一人天預設為 8 小時,團隊若不是
這樣算,用 `--day-hours` 換掉。
有三件事要在 `{{附註}}` 裡講清楚,否則落差會被讀錯:
- **沒寫估算的議題,落差是空的,不是零。** 輸出裡是 `null`;一顆估算都沒有時,總計的落差也是
`null`,不要印成 0。
- **工作包還沒做完時,落差本來就會是負的。** 估算是整顆工作包的,實際卻只是這段期間內的
那一部分;只有工作包在這段期間內收掉,兩者才真的可以比。
- **`略過` 不為零時要說出來。** 那是查不到議題資訊的工時筆數,它們沒有被算進任何數字裡。
## 邊界
- 不張貼。報表只印在終端。
- 不寫入 Gitea:不改議題、不補登工時、不動碼錶。腳本唯一的非 GET,是四層前置檢查打在不存在的
議題 0 上那支寫入權探針,它不改動任何東西。
- 不替使用者決定跳過哪些日子。腳本只算實際記錄到的工時,不扣假日、不補上沒按碼錶的時間。
- 不跨 repo 彙總。一次一個 repo,要看別的就再跑一次。
+21
View File
@@ -183,6 +183,27 @@ export function referencedIndex(sections, name, label) {
return null;
}
/**
* 在段落裡找出「標籤:數字」那一行的數字,例如關聯段落的 `估算人天:3`。
* 與 referencedIndex 同形狀,差別只在這裡要的是數量而非議題編號,所以認小數。
* 全形與半形冒號都認;找不到回 null——沒填不是解析失敗,呼叫端要分得開「沒估」與「估 0」。
* @param {Map<string, string>} sections
* @param {string} name 段落名稱
* @param {string} label 標籤,例如 '估算人天'
* @returns {number|null}
*/
export function labelledNumber(sections, name, label) {
for (const { line, inFence } of eachLine(sections.get(name))) {
if (inFence) continue;
const at = line.indexOf(label);
if (at === -1) continue;
const value = line.slice(at + label.length).match(/^\s*[::]\s*(\d+(?:\.\d+)?)\s*$/);
if (value) return Number(value[1]);
}
return null;
}
/**
* 取出兩欄表格型段落,欄位固定命名為 term 與 def。
* 需求議題的領域名詞表用它;四欄的介面契約請用 tableRows。
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env node
/**
* 產出工時報表:本週、指定月份或指定年份。
*
* 只印在終端,不對任何管道張貼——給誰看是使用者的決定,不是這支腳本的。
* 這支腳本自己只讀不寫;唯一的非 GET 是四層前置檢查裡那支探測寫入權的 PATCH
* (打在不存在的議題 0 上,不會改動任何東西),那是全專案共用的前置檢查,不是報表在寫東西。
*
* 期間怎麼切是這支腳本唯一的難處,規則固定成三句話:
* 一週為週一至週日;跨月的那一週依「該週週五所屬月份」歸屬;
* W1–W5 指該週五是當月第幾個週五。
* 週五當錨點的好處是一筆工時只會落在一個月裡,跨月週不會被兩邊各算一次。
*
* 工時來源是 `/user/times`——它永遠只回傳自己的工時,不必有 issue manager 權限,
* 也就不會把別人的工時混進自己的報表。repo 的篩選因此在本地做。
*
* 估算讀的是議題「關聯」段落裡的「估算人天」那一行,不是 Gitea 的 time_estimate 欄位:
* 該欄位的 API 寫不進去(見 issue-update 的說明),議題上唯一可信的估算就是那一行。
*
* 用法:
* node scripts/report.js --repo owner/name
* [--week | --month YYYY-MM | --year YYYY] [--today YYYY-MM-DD]
* [--day-hours 8] [--host <網址>] [--dry-run]
*/
import {
ScriptError,
fetchIssue,
main,
pages,
parseFlags,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
import { labelledNumber, parseSections } from './issue-body.js';
/** 一人天預設幾小時。跳不跳假日是團隊政策,這裡只給一個可被 --day-hours 換掉的預設。 */
const DEFAULT_DAY_HOURS = 8;
const TIMES_PATH = '/user/times';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo'],
optional: ['month', 'year', 'today', 'day-hours', 'host'],
booleans: ['week', 'dry-run'],
});
const repo = parseRepo(flags.repo);
const dayHours = parseDayHours(flags['day-hours']);
const period = resolvePeriod(flags);
if (flags['dry-run']) {
return {
dryRun: true,
repo,
期間: publicPeriod(period),
requests: [{ method: 'GET', path: TIMES_PATH }],
};
}
const login = resolveLogin({ host: flags.host });
await preflight(login, repo);
const entries = await fetchTimes(login, period);
const bodies = await fetchMissingBodies(login, repo, period, entries);
return summarise({ repo, period, dayHours, entries, bodies });
});
// ── 期間 ───────────────────────────────────────────────────────────
/**
* 把三個互斥的期間 flag 收斂成一段日期範圍與它的分段。
* @returns {{類型: string, 標籤: string, 起: string, 迄: string, 分段: {名稱: string, 起: string, 迄: string}[]}}
*/
function resolvePeriod(flags) {
const chosen = ['week', 'month', 'year'].filter((name) => flags[name] !== undefined);
if (chosen.length > 1) {
throw new ScriptError(
'PERIOD_CONFLICT',
`--week、--month、--year 三選一,收到的是 ${chosen.map((n) => `--${n}`).join(' 與 ')}`,
);
}
// --today 只決定「本週」是哪一週,對月報年報毫無作用。默默忽略一個使用者明確給的值,
// 會讓他以為報表切在別的地方;寧可擋下來。
if (flags.today !== undefined && (flags.month !== undefined || flags.year !== undefined)) {
throw new ScriptError('PERIOD_CONFLICT', '--today 只搭配 --week 使用,月報與年份報表用不到它');
}
if (flags.month !== undefined) return monthPeriod(parseMonth(flags.month));
if (flags.year !== undefined) return yearPeriod(parseYear(flags.year));
return weekPeriod(parseToday(flags.today));
}
/** 本週:本週一至今日。還沒發生的日子不該出現在報表的期間裡。 */
function weekPeriod(today) {
const start = mondayOf(today);
return { 類型: 'week', 標籤: `${start} ~ ${today}`, 起: start, 迄: today, 分段: [] };
}
/** 月報:以當月的每個週五各拉出一週,週一至週日 */
function monthPeriod(month) {
const weeks = fridaysIn(month).map((friday, i) => ({
名稱: `W${i + 1}`,
起: addDays(friday, -4),
迄: addDays(friday, 2),
}));
return { 類型: 'month', 標籤: month, 起: weeks[0].起, 迄: weeks.at(-1).迄, 分段: weeks };
}
/** 年報:十二個月各自套月報的切法,分段小計到月為止 */
function yearPeriod(year) {
const months = Array.from({ length: 12 }, (_, i) => {
const month = `${year}-${String(i + 1).padStart(2, '0')}`;
const { 起, 迄 } = monthPeriod(month);
return { 名稱: month, 起, 迄 };
});
return { 類型: 'year', 標籤: String(year), 起: months[0].起, 迄: months.at(-1).迄, 分段: months };
}
/** 當月的所有週五,由早到晚 */
function fridaysIn(month) {
const [year, index] = month.split('-').map(Number);
const fridays = [];
for (let day = 1; day <= 31; day += 1) {
const date = new Date(Date.UTC(year, index - 1, day));
if (date.getUTCMonth() !== index - 1) break;
if (date.getUTCDay() === 5) fridays.push(iso(date));
}
return fridays;
}
// ── 期間參數的把關 ─────────────────────────────────────────────────
function parseMonth(value) {
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) {
throw new ScriptError('BAD_PERIOD', `--month 需為 YYYY-MM,收到的是 ${value}`);
}
return value;
}
function parseYear(value) {
if (!/^\d{4}$/.test(value)) {
throw new ScriptError('BAD_PERIOD', `--year 需為四位數年份,收到的是 ${value}`);
}
return Number(value);
}
/** 沒給就取系統日期的「今天」。給了就以它為準,讓報表能回頭補印過去的某一週。 */
function parseToday(value) {
if (value === undefined) return localDate(new Date());
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || iso(new Date(`${value}T00:00:00Z`)) !== value) {
throw new ScriptError('BAD_PERIOD', `--today 需為真實存在的 YYYY-MM-DD,收到的是 ${value}`);
}
return value;
}
function parseDayHours(value) {
if (value === undefined) return DEFAULT_DAY_HOURS;
const hours = Number(value);
if (!Number.isFinite(hours) || hours <= 0) {
throw new ScriptError('BAD_DAY_HOURS', `--day-hours 需為正數,收到的是 ${value}`);
}
return hours;
}
// ── 日期算術 ───────────────────────────────────────────────────────
//
// 一律以 YYYY-MM-DD 字串進出、以 UTC 的 Date 當中間格式:日曆上的「哪一天」
// 不該被本機時區的日光節約搬動。時區只在一個地方出現——把工時的時刻換算成
// 「使用者那天」的 localDate。
function iso(date) {
return date.toISOString().slice(0, 10);
}
function addDays(date, days) {
const moment = new Date(`${date}T00:00:00Z`);
moment.setUTCDate(moment.getUTCDate() + days);
return iso(moment);
}
/** 該日期所屬那一週的週一。週界以週一切,週日屬於前面那一週。 */
function mondayOf(date) {
const weekday = new Date(`${date}T00:00:00Z`).getUTCDay();
return addDays(date, -((weekday + 6) % 7));
}
/** 時刻 → 使用者在的時區裡的那一天。週界是以人在的時區切的,不是 UTC。 */
function localDate(moment) {
const year = moment.getFullYear();
const month = String(moment.getMonth() + 1).padStart(2, '0');
const day = String(moment.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/** 某一天的本地零時,轉成 Gitea 要的 RFC 3339 */
function startOfDay(date) {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day, 0, 0, 0, 0).toISOString();
}
/** 某一天的本地尾聲,轉成 Gitea 要的 RFC 3339 */
function endOfDay(date) {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day, 23, 59, 59, 999).toISOString();
}
// ── 取工時 ─────────────────────────────────────────────────────────
/**
* 取回期間內、屬於自己的所有工時。
* since/before 只是先讓伺服器砍掉大半;真正的期間判斷仍在本地做,
* 因為期間是以使用者的時區切的,而伺服器不知道使用者在哪個時區。
*/
async function fetchTimes(login, period) {
const entries = [];
for await (const page of pages(login, TIMES_PATH, {
query: { since: startOfDay(period.起), before: endOfDay(period.迄) },
limitCode: 'TIME_LIMIT',
limitHint: `${TIMES_PATH} 的工時筆數超出可走訪範圍,這份報表會是不完整的`,
})) {
entries.push(...page);
}
return entries;
}
/**
* 補齊估算讀不到的議題 body。
*
* 估算只存在於議題 body 的那一行,而 `/user/times` 內嵌的議題不保證帶 body——
* 少了它,整份報表的估算與落差會靜靜地全變成 null,而報表仍然回報成功。
* 因此缺 body 的議題各補一次 GET:筆數是「這段期間碰過的議題數」,不是工時筆數。
*/
async function fetchMissingBodies(login, repo, period, entries) {
const missing = new Set();
for (const entry of entries) {
const issue = entry.issue;
if (!issue || issue.number === undefined) continue;
if (issue.repository?.full_name !== repo || issue.body !== undefined) continue;
const date = localDate(new Date(entry.created));
if (date >= period.起 && date <= period.迄) missing.add(issue.number);
}
const bodies = new Map();
for (const index of missing) {
bodies.set(index, (await fetchIssue(login, repo, index)).body ?? '');
}
return bodies;
}
// ── 彙總 ───────────────────────────────────────────────────────────
function summarise({ repo, period, dayHours, entries, bodies }) {
const { total, skipped, bySegment, byIssue } = collect({ repo, period, entries, bodies });
// 工時多的排前面:週會上先講的是吃掉最多時間的那一顆
const issues = [...byIssue.values()]
.sort((a, b) => b.實際秒 - a.實際秒 || a.index - b.index)
.map((issue) => publicIssue(issue, dayHours));
const estimated = issues.filter((issue) => issue.估算人天 !== null);
const estimatedDays = estimated.reduce((sum, issue) => sum + issue.估算人天, 0);
const estimatedActual = estimated.reduce((sum, issue) => sum + issue.實際秒, 0);
// 總計的落差只拿「有估算的那些議題」的實際去比。拿全部實際去比只有部分議題的估算,
// 會讓沒估算的工時全部變成「超出估算」,落差就永遠是灌水的正數。
const gap = estimated.length === 0 ? null : gapSeconds(estimatedActual, estimatedDays, dayHours);
return {
repo,
期間: publicPeriod(period),
每日工時: dayHours,
總計: {
實際秒: total,
實際工時: formatHours(total),
估算人天: estimatedDays,
已估實際秒: estimatedActual,
已估實際工時: formatHours(estimatedActual),
落差秒: gap,
落差工時: gap === null ? null : formatGap(gap),
},
分段: period.分段.map((segment) => ({
名稱: segment.名稱,
起: segment.起,
迄: segment.迄,
實際秒: bySegment.get(segment.名稱),
實際工時: formatHours(bySegment.get(segment.名稱)),
})),
議題: issues,
略過: skipped,
};
}
/**
* 把工時逐筆歸到週次與議題底下。
* 期間外的、別的 repo 的都在這裡被濾掉;查不到議題資訊的則被數起來——
* 它們不歸到任何數字,但也不能無聲消失。
*/
function collect({ repo, period, entries, bodies }) {
const bySegment = new Map(period.分段.map((segment) => [segment.名稱, 0]));
const byIssue = new Map();
let skipped = 0;
let total = 0;
for (const entry of entries) {
const date = localDate(new Date(entry.created));
if (date < period.起 || date > period.迄) continue;
const issue = entry.issue;
if (!issue?.repository?.full_name || issue.number === undefined) {
skipped += 1;
continue;
}
if (issue.repository.full_name !== repo) continue;
const seconds = Number(entry.time) || 0;
total += seconds;
const segment = period.分段.find((s) => date >= s.起 && date <= s.迄);
if (segment) bySegment.set(segment.名稱, bySegment.get(segment.名稱) + seconds);
const known = byIssue.get(issue.number);
if (known) {
known.實際秒 += seconds;
} else {
byIssue.set(issue.number, {
index: issue.number,
title: issue.title ?? '',
url: issue.html_url ?? '',
實際秒: seconds,
估算人天: estimateDays(issue.body ?? bodies.get(issue.number)),
});
}
}
return { total, skipped, bySegment, byIssue };
}
/** 期間的對外形狀不含分段定義——分段的數字在 data.分段 裡,不必重複一份 */
function publicPeriod(period) {
return { 類型: period.類型, 標籤: period.標籤, 起: period.起, 迄: period.迄 };
}
/** 落差 = 實際 − 估算。正數是超出估算,負數是還有餘裕。 */
function gapSeconds(actualSeconds, days, dayHours) {
return actualSeconds - days * dayHours * 3600;
}
/** 逐議題那一列的對外形狀。沒有估算就沒有落差,填 null 而非零:零會被讀成「剛好準」。 */
function publicIssue(issue, dayHours) {
const gap = issue.估算人天 === null ? null : gapSeconds(issue.實際秒, issue.估算人天, dayHours);
return {
index: issue.index,
title: issue.title,
url: issue.url,
實際秒: issue.實際秒,
實際工時: formatHours(issue.實際秒),
估算人天: issue.估算人天,
落差秒: gap,
落差工時: gap === null ? null : formatGap(gap),
};
}
/**
* 從議題 body 讀出估算人天。
* 只認「關聯」段落裡的那一行——那是 issue-update 唯一寫得進去的位置,
* 其他地方出現的數字(例如描述裡順手提到的「大概三天」)不算數。
* @returns {number|null} 沒寫估算時為 null
*/
function estimateDays(body) {
return labelledNumber(parseSections(body ?? ''), '關聯', '估算人天');
}
/** 秒 → 「3h 30m」。秒數不進位成分鐘,免得湊出假的精確。 */
function formatHours(seconds) {
const minutes = Math.floor(Math.abs(seconds) / 60);
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
}
/** 落差要一眼看出方向:超出估算帶 +,還有餘裕帶 − */
function formatGap(seconds) {
if (seconds === 0) return formatHours(0);
return `${seconds > 0 ? '+' : '-'}${formatHours(seconds)}`;
}
+25
View File
@@ -0,0 +1,25 @@
# 工時報表 {{期間}}
{{範圍}}
## 總計
| 實際工時 | 估算人天 | 已估實際 | 落差 |
| --- | --- | --- | --- |
| {{實際工時}} | {{估算人天}} | {{已估實際}} | {{落差}} |
## 分段小計
| 段 | 起迄 | 實際工時 |
| --- | --- | --- |
{{分段}}
## 逐議題
| 議題 | 標題 | 實際工時 | 估算人天 | 落差 |
| --- | --- | --- | --- | --- |
{{議題}}
## 附註
{{附註}}
+514
View File
@@ -0,0 +1,514 @@
/**
* 工時報表:期間切法、週次歸屬與估算落差。
*
* 這支腳本的難處不在取資料,而在「哪一筆工時算在哪一週、哪一週算在哪個月」。
* 跨月、跨年、當月有五個週五三種邊界各自都會讓人算錯,所以逐一釘住。
*
* 時區在測試裡固定為 Asia/Taipei:週界是以人在的時區切的,不釘住時區就等於沒釘住答案。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { healthyRoutes, stubEnv, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const TZ = 'Asia/Taipei';
/** 一顆工作包議題;估算寫在「關聯」段落,那是 issue-update 唯一寫得進去的地方 */
function issue(number, { title = `工作包 ${number}`, days = null, repo = REPO } = {}) {
const 關聯 = days === null ? '需求議題:#1' : `需求議題:#1\n估算人天:${days}`;
return {
number,
title,
html_url: `https://gitea.example/${repo}/issues/${number}`,
body: `## 這個工作包在做什麼\n\n做一件事\n\n## 關聯\n\n${關聯}\n`,
repository: { full_name: repo },
};
}
/** 一筆工時。created 寫成不帶時區的本地時刻,讀起來就是「那天的幾點」 */
let nextId = 1;
function time(created, hours, issueObject) {
return {
id: nextId++,
created: new Date(`${created}T10:00:00+08:00`).toISOString(),
time: Math.round(hours * 3600),
user_name: 'tester',
issue: issueObject,
};
}
/** 啟一台假 Gitea,/user/times 回傳指定的工時清單 */
async function withTimes(t, times, overrides = {}) {
return withStubGitea(
t,
healthyRoutes(REPO, {
'GET /api/v1/user/times': { status: 200, body: times },
...overrides,
}),
);
}
const run = (stub, args) =>
runScript('report.js', ['--repo', REPO, ...args], { env: { ...stubEnv(stub), TZ } });
/** 依名稱取出分段小計的秒數 */
const segmentSeconds = (json) =>
Object.fromEntries(json.data.分段.map((s) => [s.名稱, s.實際秒]));
// ── 期間:本週 ─────────────────────────────────────────────────────
test('預設為本週:起於本週一、迄於今日', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(code, 0);
assert.equal(json.data.期間.類型, 'week');
assert.equal(json.data.期間.起, '2026-09-14');
assert.equal(json.data.期間.迄, '2026-09-17');
});
test('今天就是週一時,本週只有今天這一天', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--today', '2026-09-14']);
assert.equal(json.data.期間.起, '2026-09-14');
assert.equal(json.data.期間.迄, '2026-09-14');
});
test('今天是週日時仍屬同一週,不跳到下週一', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--today', '2026-09-20']);
assert.equal(json.data.期間.起, '2026-09-14');
assert.equal(json.data.期間.迄, '2026-09-20');
});
test('只計入期間內的工時,期間外的一秒都不算', async (t) => {
const wp = issue(12);
const stub = await withTimes(t, [
time('2026-09-13', 8, wp), // 上週日
time('2026-09-14', 2, wp), // 本週一
time('2026-09-17', 1.5, wp), // 今天
time('2026-09-18', 4, wp), // 今天之後
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, 3.5 * 3600);
});
test('週報沒有分段小計:一週之內沒有更小的段落', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 1, issue(12))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.deepEqual(json.data.分段, []);
});
// ── 期間:月報與 W1–W5 ────────────────────────────────────────────
test('月報依「該週週五所屬月份」歸屬:月初跨月的那一週算進本月', async (t) => {
// 2026-01 的第一個週五是 01-02,那一週的週一落在 2025-12-29
const stub = await withTimes(t, [time('2025-12-29', 3, issue(12))]);
const { json } = await run(stub, ['--month', '2026-01']);
assert.equal(json.data.期間.起, '2025-12-29');
assert.equal(json.data.總計.實際秒, 3 * 3600);
assert.equal(segmentSeconds(json).W1, 3 * 3600);
});
test('月報依「該週週五所屬月份」歸屬:月末跨月的那一週算進下個月', async (t) => {
// 2026-02-01 是週日,它那一週的週五是 01-30,所以歸 2026-01 而非 2026-02
const stub = await withTimes(t, [time('2026-02-01', 5, issue(12))]);
const january = await run(stub, ['--month', '2026-01']);
const february = await run(stub, ['--month', '2026-02']);
assert.equal(january.json.data.總計.實際秒, 5 * 3600);
assert.equal(february.json.data.總計.實際秒, 0, '同一筆工時不得被兩個月重複計算');
});
test('W 編號為該週五是當月第幾個週五,有五個週五的月份排到 W5', async (t) => {
// 2026-01 的週五:02、09、16、23、30
const wp = issue(12);
const stub = await withTimes(t, [
time('2026-01-02', 1, wp),
time('2026-01-09', 2, wp),
time('2026-01-16', 3, wp),
time('2026-01-23', 4, wp),
time('2026-01-30', 5, wp),
]);
const { json } = await run(stub, ['--month', '2026-01']);
assert.deepEqual(json.data.分段.map((s) => s.名稱), ['W1', 'W2', 'W3', 'W4', 'W5']);
assert.deepEqual(segmentSeconds(json), {
W1: 1 * 3600,
W2: 2 * 3600,
W3: 3 * 3600,
W4: 4 * 3600,
W5: 5 * 3600,
});
});
test('只有四個週五的月份就只有 W1–W4,不硬湊出空的 W5', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026-02']);
assert.deepEqual(json.data.分段.map((s) => s.名稱), ['W1', 'W2', 'W3', 'W4']);
});
test('沒有工時的週次仍然列出來,小計為零', async (t) => {
const stub = await withTimes(t, [time('2026-02-06', 1, issue(12))]);
const { json } = await run(stub, ['--month', '2026-02']);
assert.deepEqual(segmentSeconds(json), { W1: 3600, W2: 0, W3: 0, W4: 0 });
});
test('每個週次都標出自己的起迄,週一到週日', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026-01']);
assert.deepEqual(json.data.分段[0], {
名稱: 'W1',
起: '2025-12-29',
迄: '2026-01-04',
實際秒: 0,
實際工時: '0h 00m',
});
});
// ── 期間:年報與跨年 ──────────────────────────────────────────────
test('年報以月份分段小計', async (t) => {
const stub = await withTimes(t, [time('2026-03-04', 2, issue(12))]);
const { json } = await run(stub, ['--year', '2026']);
assert.equal(json.data.分段.length, 12);
assert.deepEqual(json.data.分段.map((s) => s.名稱).slice(0, 3), ['2026-01', '2026-02', '2026-03']);
assert.equal(segmentSeconds(json)['2026-03'], 2 * 3600);
});
test('跨年的那一週依週五歸屬:12/29 的工時算進下一年', async (t) => {
// 2025-12-29 是週一,它那一週的週五是 2026-01-02
const stub = await withTimes(t, [time('2025-12-29', 6, issue(12))]);
const y2025 = await run(stub, ['--year', '2025']);
const y2026 = await run(stub, ['--year', '2026']);
assert.equal(y2025.json.data.總計.實際秒, 0);
assert.equal(y2026.json.data.總計.實際秒, 6 * 3600);
assert.equal(segmentSeconds(y2026.json)['2026-01'], 6 * 3600);
});
test('年報的起迄由第一個與最後一個週五所在的週決定', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--year', '2026']);
// 首個週五 2026-01-02 的週一是 2025-12-29;末個週五 2026-12-25 的週日是 2026-12-27
assert.equal(json.data.期間.起, '2025-12-29');
assert.equal(json.data.期間.迄, '2026-12-27');
});
// ── 估算落差 ───────────────────────────────────────────────────────
test('估算取自議題「關聯」段落的估算人天,落差為實際減估算', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 20, issue(12, { days: 2 }))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.估算人天, 2);
assert.equal(json.data.總計.落差秒, (20 - 16) * 3600, '2 人天 × 8 小時 = 16 小時');
assert.equal(json.data.總計.落差工時, '+4h 00m');
});
test('實際少於估算時落差為負', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 6, issue(12, { days: 1 }))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.落差秒, -2 * 3600);
assert.equal(json.data.總計.落差工時, '-2h 00m');
});
test('--day-hours 換掉一人天等於幾小時的假設', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 7, issue(12, { days: 1 }))]);
const { json } = await run(stub, ['--today', '2026-09-17', '--day-hours', '7']);
assert.equal(json.data.每日工時, 7);
assert.equal(json.data.總計.落差秒, 0);
});
test('議題沒寫估算時落差為 null,不當成零', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 3, issue(12))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.議題[0].估算人天, null);
assert.equal(json.data.議題[0].落差秒, null);
assert.equal(json.data.總計.估算人天, 0, '總計只加得起來有估算的那些');
assert.equal(json.data.總計.落差秒, null, '一顆估算都沒有時,沒有東西可以比');
});
test('總計的落差只拿有估算的議題來比,沒估算的工時不算成超出估算', async (t) => {
const stub = await withTimes(t, [
time('2026-09-15', 6, issue(12, { days: 1 })), // 估 8 小時、實際 6 小時
time('2026-09-16', 30, issue(13)), // 沒估算,30 小時
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, 36 * 3600, '實際總計仍然是全部');
assert.equal(json.data.總計.已估實際秒, 6 * 3600, '落差的分母只有有估算的那顆');
assert.equal(json.data.總計.落差秒, -2 * 3600);
assert.notEqual(json.data.總計.落差秒, 28 * 3600, '拿全部實際去比部分估算會灌出假的超支');
});
// ── 逐議題明細 ─────────────────────────────────────────────────────
test('依議題彙總,帶上標題與網址,工時多的排前面', async (t) => {
const stub = await withTimes(t, [
time('2026-09-14', 1, issue(12, { title: '建立抽取契約', days: 3 })),
time('2026-09-15', 4, issue(13, { title: '補上前置檢查' })),
time('2026-09-16', 2, issue(12, { title: '建立抽取契約', days: 3 })),
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.deepEqual(json.data.議題, [
{
index: 13,
title: '補上前置檢查',
url: 'https://gitea.example/plugins/tea-sdlc/issues/13',
實際秒: 4 * 3600,
實際工時: '4h 00m',
估算人天: null,
落差秒: null,
落差工時: null,
},
{
index: 12,
title: '建立抽取契約',
url: 'https://gitea.example/plugins/tea-sdlc/issues/12',
實際秒: 3 * 3600,
實際工時: '3h 00m',
估算人天: 3,
落差秒: -21 * 3600,
落差工時: '-21h 00m',
},
]);
});
test('工時以時分呈現,秒數不進位成假的精確', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 1.51, issue(12))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際工時, '1h 30m');
});
// ── 範圍:只算指定 repo 的工時 ────────────────────────────────────
test('別的 repo 的工時不算進來', async (t) => {
const stub = await withTimes(t, [
time('2026-09-15', 2, issue(12)),
time('2026-09-15', 8, issue(4, { repo: 'plugins/other' })),
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, 2 * 3600);
assert.deepEqual(json.data.議題.map((i) => i.index), [12]);
});
test('查不到議題資訊的工時不默默消失,回報則數', async (t) => {
const stub = await withTimes(t, [
time('2026-09-15', 2, issue(12)),
{ id: 99, created: '2026-09-15T02:00:00Z', time: 3600, user_name: 'tester' },
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.略過, 1, '無議題資訊時整份報表會憑空變空,數字要留在輸出裡');
assert.equal(json.data.總計.實際秒, 2 * 3600);
});
test('沒有任何工時時回空報表,不是錯誤', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.總計.實際秒, 0);
assert.deepEqual(json.data.議題, []);
});
// ── 取資料的方式 ───────────────────────────────────────────────────
test('工時逐頁讀完,不是只讀第一頁', async (t) => {
const wp = issue(12);
const first = Array.from({ length: 50 }, () => time('2026-09-15', 0.1, wp));
const second = [time('2026-09-16', 1, wp)];
const stub = await withStubGitea(
t,
healthyRoutes(REPO, {
'GET /api/v1/user/times': (req) => ({
status: 200,
body: req.query.page === '1' ? first : second,
}),
}),
);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, Math.round((50 * 0.1 + 1) * 3600));
});
test('工時內嵌的議題沒帶 body 時,補查議題才讀得到估算', async (t) => {
// /user/times 內嵌的議題不保證帶 body;少了它,估算會整欄靜靜變成 null
const bodyless = { ...issue(12, { days: 2 }) };
delete bodyless.body;
const stub = await withTimes(
t,
[time('2026-09-15', 20, bodyless), time('2026-09-16', 1, bodyless)],
{ [`GET /api/v1/repos/${REPO}/issues/12`]: { status: 200, body: issue(12, { days: 2 }) } },
);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.議題[0].估算人天, 2);
assert.equal(json.data.總計.落差秒, (21 - 16) * 3600);
const lookups = stub.requests.filter((r) => r.path === `/api/v1/repos/${REPO}/issues/12`);
assert.equal(lookups.length, 1, '同一顆議題只補查一次,不是每筆工時各查一次');
});
test('唯一的非 GET 是前置檢查的寫入權探針,報表本身不寫任何東西', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 1, issue(12))]);
await run(stub, ['--today', '2026-09-17']);
// 四層前置檢查會 PATCH 不存在的議題 0 來實測 issues 寫入權,那一筆不改動任何東西。
// 除它以外整趟都該是 GET——報表只印在終端,不對任何管道張貼。
assert.deepEqual(
stub.requests.filter((r) => r.method !== 'GET').map((r) => `${r.method} ${r.path}`),
[`PATCH /api/v1/repos/${REPO}/issues/0`],
);
});
test('--dry-run 印出將發出的請求,且完全不碰 Gitea', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--today', '2026-09-17', '--dry-run']);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(json.data.requests, [{ method: 'GET', path: '/user/times' }]);
assert.equal(stub.requests.length, 0);
});
// ── 期間參數的把關 ─────────────────────────────────────────────────
test('三種期間彼此互斥', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--month', '2026-01', '--year', '2026']);
assert.equal(code, 1);
assert.equal(json.error.code, 'PERIOD_CONFLICT');
});
test('--month 需為 YYYY-MM', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026/01']);
assert.equal(json.error.code, 'BAD_PERIOD');
assert.match(json.error.message, /--month/);
});
test('--month 的月份需在 01–12 之間', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026-13']);
assert.equal(json.error.code, 'BAD_PERIOD');
});
test('--year 需為四位數', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--year', '26']);
assert.equal(json.error.code, 'BAD_PERIOD');
assert.match(json.error.message, /--year/);
});
test('--today 需為真實存在的日期', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--today', '2026-02-30']);
assert.equal(json.error.code, 'BAD_PERIOD');
assert.match(json.error.message, /--today/);
});
test('--day-hours 需為正數', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--day-hours', '0']);
assert.equal(json.error.code, 'BAD_DAY_HOURS');
});
test('--week 明講出來時與預設同一段期間', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 2, issue(12))]);
const explicit = await run(stub, ['--today', '2026-09-17', '--week']);
const implicit = await run(stub, ['--today', '2026-09-17']);
assert.deepEqual(explicit.json, implicit.json);
});
test('--today 搭到月報或年報時擋下,不默默忽略', async (t) => {
const stub = await withTimes(t, []);
const month = await run(stub, ['--month', '2026-01', '--today', '2026-09-17']);
const year = await run(stub, ['--year', '2026', '--today', '2026-09-17']);
assert.equal(month.json.error.code, 'PERIOD_CONFLICT');
assert.equal(year.json.error.code, 'PERIOD_CONFLICT');
});
test('期間標籤讓人一眼看出報表涵蓋什麼', async (t) => {
const stub = await withTimes(t, []);
const week = await run(stub, ['--today', '2026-09-17']);
const month = await run(stub, ['--month', '2026-01']);
const year = await run(stub, ['--year', '2026']);
assert.equal(week.json.data.期間.標籤, '2026-09-14 ~ 2026-09-17');
assert.equal(month.json.data.期間.標籤, '2026-01');
assert.equal(year.json.data.期間.標籤, '2026');
});
test('不帶 --today 時以系統日期為準,仍算得出本週', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, []);
assert.equal(code, 0);
assert.match(json.data.期間.起, /^\d{4}-\d{2}-\d{2}$/);
assert.ok(json.data.期間.起 <= json.data.期間.迄);
});
+109
View File
@@ -0,0 +1,109 @@
/**
* 工時報表的流程正本與輸出模板。
*
* 報表的難處是規則而不是程式:期間怎麼切、落差怎麼讀、印到哪裡為止。
* 規則寫在正本裡,錯了不會有任何測試自己爆掉,所以在這裡釘住。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertNeutralPrompt,
assertTemplateSections,
readPrompt,
readTemplate,
} from './helpers/prompt-doc.js';
const template = readTemplate('report');
const prompt = readPrompt('sdlc-report');
/** 報表的四個段落,順序即印出來的順序 */
const SECTIONS = ['總計', '分段小計', '逐議題', '附註'];
// ── 輸出模板 ───────────────────────────────────────────────────────
test('模板依序包含四個段落', () => {
assertTemplateSections(template, SECTIONS);
});
test('模板以 {{變數}} 佔位,不留任何空白待填欄位', () => {
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]);
assert.ok(placeholders.length >= SECTIONS.length, '每個段落至少要有一個佔位');
for (const name of placeholders) {
assert.match(name, /^[a-z一-龥]+$/u, `佔位名稱 ${name} 應為單一詞,不含空白或符號`);
}
});
test('模板不含邏輯:沒有任何條件或迴圈語法', () => {
assert.equal(/\{\{[#/^]/.test(template), false, '分段與逐議題由呼叫端展開,模板不做迴圈');
});
test('總計把實際、估算與落差擺在同一列,落差不必自己算', () => {
const totals = template.slice(template.indexOf('## 總計'), template.indexOf('## 分段小計'));
for (const placeholder of ['{{實際工時}}', '{{估算人天}}', '{{已估實際}}', '{{落差}}']) {
assert.ok(totals.includes(placeholder), `總計缺少 ${placeholder}`);
}
});
// ── 流程正本 ───────────────────────────────────────────────────────
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-report');
});
test('正本交代三種期間,並指明預設是本週', () => {
assert.match(prompt, /--week/);
assert.match(prompt, /--month YYYY-MM/);
assert.match(prompt, /--year YYYY/);
assert.match(prompt, /預設/);
});
test('正本釘住期間定義的三句話', () => {
assert.match(prompt, /一週為週一至週日/);
assert.match(prompt, /該週週五所屬月份/);
assert.match(prompt, /W1[–-]W5.*第幾個週五/s);
});
test('正本以實例說明跨月與跨年那一週落在哪邊', () => {
assert.match(prompt, /2025-12-29/, '跨年的例子要寫出具體日期,規則才驗得出來');
assert.match(prompt, /2026-02-01/, '跨月的例子要寫出具體日期');
});
test('正本指名由 report.js 取數字,並要求直接用算好的時分', () => {
assert.match(prompt, /report\.js/);
assert.match(prompt, /不要自己再乘|已經算好/);
});
test('正本明令只印在終端,不張貼到任何管道', () => {
assert.match(prompt, /只印在終端/);
assert.match(prompt, /不(要)?張貼/);
});
test('正本說明落差的正負方向,以及一人天等於幾小時', () => {
assert.match(prompt, /正數.*超出估算/);
assert.match(prompt, /8\s*小時/);
assert.match(prompt, /--day-hours/);
});
test('正本交代總計的落差只涵蓋有估算的議題', () => {
assert.match(prompt, /總計的落差只涵蓋有估算的議題/);
assert.match(prompt, /已估實際秒/, '要指名分子是哪一個欄位,否則會被讀成全部實際');
});
test('正本交代沒寫估算時落差是空的,不是零', () => {
assert.match(prompt, /不是零|非零|空的/);
assert.match(prompt, /null/);
});
test('正本交代略過的工時筆數要講出來', () => {
assert.match(prompt, /略過/);
});
test('正本的邊界寫明這是唯讀流程', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不寫入|只發 GET/);
assert.match(boundary, /不補登|不動碼錶/);
});
test('正本交代週報沒有分段小計時整段不印', () => {
assert.match(prompt, /週報沒有分段/);
});