期間固定以週五當錨點:一週為週一至週日,跨月那一週依該週週五所屬月份歸屬, 一筆工時因此只會落在一個月裡,不會被前後兩個月各算一次。 工時取自 /user/times——它永遠只回傳自己的工時,不必有 issue manager 權限, repo 的篩選因此在本地做。估算讀的是議題「關聯」段落裡的那一行,不是 Gitea 的 time_estimate 欄位:該欄位的 API 寫不進去,議題上唯一可信的估算就是那一行; 內嵌的議題沒帶 body 時補查一次議題,否則估算會整欄靜靜變成 null。 總計的落差只拿有估算的議題的實際去比。拿全部實際去比只有部分議題的估算, 會讓沒估算的工時全部變成「超出估算」,落差就永遠是灌水的正數。 labelledNumber 放進 issue-body:body 的解析正本在那裡,格式改一次不該動兩個模組。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
385 lines
15 KiB
JavaScript
385 lines
15 KiB
JavaScript
#!/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)}`;
|
|
}
|