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
269 lines
9.9 KiB
JavaScript
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})`);
|
|
},
|
|
});
|