Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

269 lines
9.9 KiB
JavaScript

/**
* Log/backfill work hours. Project API paths vary by deployment,
* so we try common endpoints in sequence.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { gotoOnesHome, onesFetchInPageWithMeta, resolveOnesUserUuid, summarizeOnesError, } from './common.js';
import { hoursToOnesManhourRaw } from './task-helpers.js';
function summarizeOnesMutationBody(parsed, status) {
if (!parsed || typeof parsed !== 'object') {
return status >= 400 ? `HTTP ${status}` : null;
}
const o = parsed;
if (Array.isArray(o.errors) && o.errors.length > 0) {
const e0 = o.errors[0];
if (e0 && typeof e0 === 'object') {
const msg = String(e0.message ?? '').trim();
if (msg)
return msg;
}
return 'graphql errors';
}
if (o.data && typeof o.data === 'object') {
const data = o.data;
if (data.addManhour && typeof data.addManhour === 'object') {
const key = String(data.addManhour.key ?? '').trim();
if (!key)
return 'addManhour returned empty key';
}
}
if (Array.isArray(o.bad_tasks) && o.bad_tasks.length > 0) {
const b = o.bad_tasks[0];
return String(b.desc ?? b.code ?? JSON.stringify(b));
}
if (typeof o.reason === 'string' && o.reason.trim())
return o.reason.trim();
const c = o.code;
if (c !== undefined && c !== null) {
const n = Number(c);
if (Number.isFinite(n) && n !== 200 && n !== 0)
return `code=${String(c)}`;
}
const ec = o.errcode;
if (typeof ec === 'string' && ec && ec !== 'OK')
return ec;
return null;
}
function describeAttemptFailure(r) {
if (!r.ok)
return summarizeOnesError(r.status, r.parsed);
return summarizeOnesMutationBody(r.parsed, r.status);
}
function todayLocalYmd() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function validateYmd(s) {
return /^\d{4}-\d{2}-\d{2}$/.test(s);
}
function toLocalMidnightUnixSeconds(ymd) {
const d = new Date(`${ymd}T00:00:00`);
const ms = d.getTime();
if (!Number.isFinite(ms))
return 0;
return Math.floor(ms / 1000);
}
function pickTaskTotalManhourRaw(parsed) {
if (!parsed || typeof parsed !== 'object')
return null;
const o = parsed;
const n = Number(o.total_manhour);
return Number.isFinite(n) ? n : null;
}
export function buildAddManhourGraphqlBody(input) {
const { ownerId, taskId, startTime, rawManhour, note } = input;
const description = JSON.stringify(note);
const owner = JSON.stringify(ownerId);
const task = JSON.stringify(taskId);
return JSON.stringify({
query: `mutation AddManhour {
addManhour(
mode: "simple"
owner: ${owner}
task: ${task}
type: "recorded"
start_time: ${startTime}
hours: ${rawManhour}
description: ${description}
customData: {}
) {
key
}
}`,
});
}
cli({
site: 'ones',
name: 'worklog',
access: 'write',
description: 'ONES — log work hours on a task (defaults to today; use --date to backfill; endpoint falls back by deployment).',
domain: 'ones.cn',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{
name: 'task',
type: 'str',
required: true,
positional: true,
help: 'Work item UUID (usually 16 chars), from my-tasks or browser URL …/task/<id>',
},
{
name: 'hours',
type: 'str',
required: true,
positional: true,
help: 'Hours to log for this entry (e.g. 2 or 1.5), converted with ONES_MANHOUR_SCALE',
},
{
name: 'team',
type: 'str',
required: false,
help: 'Team UUID from URL …/team/<uuid>/…, or set ONES_TEAM_UUID',
},
{
name: 'date',
type: 'str',
required: false,
help: 'Entry date YYYY-MM-DD, defaults to today (local timezone); use for backfill',
},
{
name: 'note',
type: 'str',
required: false,
help: 'Optional note (written to description/desc)',
},
{
name: 'owner',
type: 'str',
required: false,
help: 'Owner user UUID (defaults to current logged-in user)',
},
],
columns: ['task', 'date', 'hours', 'owner', 'endpoint'],
func: async (page, kwargs) => {
const taskId = String(kwargs.task ?? '').trim();
if (!taskId) {
throw new CliError('CONFIG', 'task uuid required', 'Pass the work item uuid from opencli ones my-tasks or the URL.');
}
const team = kwargs.team?.trim() ||
process.env.ONES_TEAM_UUID?.trim() ||
process.env.ONES_TEAM_ID?.trim();
if (!team) {
throw new CliError('CONFIG', 'team UUID required', 'Pass --team or set ONES_TEAM_UUID (from …/team/<team>/…).');
}
const hoursHuman = Number(String(kwargs.hours ?? '').replace(/,/g, ''));
if (!Number.isFinite(hoursHuman) || hoursHuman <= 0 || hoursHuman > 1000) {
throw new CliError('CONFIG', 'hours must be a positive number (hours)', 'Example: opencli ones worklog <taskUUID> 2 --team <teamUUID>');
}
const dateArg = kwargs.date?.trim();
const dateStr = dateArg || todayLocalYmd();
if (!validateYmd(dateStr)) {
throw new CliError('CONFIG', 'invalid --date', 'Use YYYY-MM-DD, e.g. 2026-03-24.');
}
const note = String(kwargs.note ?? '').trim();
const rawManhour = hoursToOnesManhourRaw(hoursHuman);
const startTime = toLocalMidnightUnixSeconds(dateStr);
if (!startTime) {
throw new CliError('CONFIG', 'invalid date for start_time', `Could not parse date ${dateStr}.`);
}
await gotoOnesHome(page);
const ownerFromKw = kwargs.owner?.trim();
const ownerId = ownerFromKw || (await resolveOnesUserUuid(page, { skipGoto: true }));
const entry = {
owner: ownerId,
manhour: rawManhour,
start_date: dateStr,
end_date: dateStr,
desc: note,
};
const entryAlt = {
owner: ownerId,
allManhour: rawManhour,
startDate: dateStr,
endDate: dateStr,
desc: note,
};
const enc = encodeURIComponent(taskId);
const gqlBody = buildAddManhourGraphqlBody({
ownerId,
taskId,
startTime,
rawManhour,
note,
});
const attempts = [
{ path: `team/${team}/items/graphql`, body: gqlBody },
{ path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify(entry) },
{ path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify(entryAlt) },
{ path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify({ manhours: [entry] }) },
{ path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify({ manhours: [entryAlt] }) },
{ path: `team/${team}/task/${enc}/manhour/add`, body: JSON.stringify(entry) },
{ path: `team/${team}/task/${enc}/manhour/add`, body: JSON.stringify(entryAlt) },
{
path: `team/${team}/tasks/update3`,
body: JSON.stringify({
tasks: [{ uuid: taskId, manhours: [entry] }],
}),
},
];
const beforeInfo = await onesFetchInPageWithMeta(page, `team/${team}/task/${enc}/info`, {
method: 'GET',
skipGoto: true,
});
const beforeTotal = beforeInfo.ok ? pickTaskTotalManhourRaw(beforeInfo.parsed) : null;
let lastDetail = '';
for (const { path, body } of attempts) {
const r = await onesFetchInPageWithMeta(page, path, {
method: 'POST',
body,
skipGoto: true,
});
const fail = describeAttemptFailure(r);
if (!fail) {
// Guard against false success: HTTP 200 but no actual manhour change.
const afterInfo = await onesFetchInPageWithMeta(page, `team/${team}/task/${enc}/info`, {
method: 'GET',
skipGoto: true,
});
if (afterInfo.ok) {
const afterTotal = pickTaskTotalManhourRaw(afterInfo.parsed);
const changed = beforeTotal === null
? afterTotal !== null
: afterTotal !== null && Math.abs(afterTotal - beforeTotal) >= 1;
if (changed) {
return [
{
task: taskId,
date: dateStr,
hours: String(hoursHuman),
owner: ownerId,
endpoint: path,
},
];
}
lastDetail = `no effect (total_manhour ${String(beforeTotal)} -> ${String(afterTotal)})`;
continue;
}
// If verification read fails, return success conservatively.
return [
{
task: taskId,
date: dateStr,
hours: String(hoursHuman),
owner: ownerId,
endpoint: path,
},
];
}
lastDetail = fail;
}
throw new CliError('FETCH_ERROR', `ONES worklog: all endpoints failed (last: ${lastDetail})`);
},
});