/** * 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/', }, { 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//…, 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//…).'); } 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 2 --team '); } 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})`); }, });