worklog.js
1 /** 2 * Log/backfill work hours. Project API paths vary by deployment, 3 * so we try common endpoints in sequence. 4 */ 5 import { cli, Strategy } from '@jackwener/opencli/registry'; 6 import { CliError } from '@jackwener/opencli/errors'; 7 import { gotoOnesHome, onesFetchInPageWithMeta, resolveOnesUserUuid, summarizeOnesError, } from './common.js'; 8 import { hoursToOnesManhourRaw } from './task-helpers.js'; 9 function summarizeOnesMutationBody(parsed, status) { 10 if (!parsed || typeof parsed !== 'object') { 11 return status >= 400 ? `HTTP ${status}` : null; 12 } 13 const o = parsed; 14 if (Array.isArray(o.errors) && o.errors.length > 0) { 15 const e0 = o.errors[0]; 16 if (e0 && typeof e0 === 'object') { 17 const msg = String(e0.message ?? '').trim(); 18 if (msg) 19 return msg; 20 } 21 return 'graphql errors'; 22 } 23 if (o.data && typeof o.data === 'object') { 24 const data = o.data; 25 if (data.addManhour && typeof data.addManhour === 'object') { 26 const key = String(data.addManhour.key ?? '').trim(); 27 if (!key) 28 return 'addManhour returned empty key'; 29 } 30 } 31 if (Array.isArray(o.bad_tasks) && o.bad_tasks.length > 0) { 32 const b = o.bad_tasks[0]; 33 return String(b.desc ?? b.code ?? JSON.stringify(b)); 34 } 35 if (typeof o.reason === 'string' && o.reason.trim()) 36 return o.reason.trim(); 37 const c = o.code; 38 if (c !== undefined && c !== null) { 39 const n = Number(c); 40 if (Number.isFinite(n) && n !== 200 && n !== 0) 41 return `code=${String(c)}`; 42 } 43 const ec = o.errcode; 44 if (typeof ec === 'string' && ec && ec !== 'OK') 45 return ec; 46 return null; 47 } 48 function describeAttemptFailure(r) { 49 if (!r.ok) 50 return summarizeOnesError(r.status, r.parsed); 51 return summarizeOnesMutationBody(r.parsed, r.status); 52 } 53 function todayLocalYmd() { 54 const d = new Date(); 55 const y = d.getFullYear(); 56 const m = String(d.getMonth() + 1).padStart(2, '0'); 57 const day = String(d.getDate()).padStart(2, '0'); 58 return `${y}-${m}-${day}`; 59 } 60 function validateYmd(s) { 61 return /^\d{4}-\d{2}-\d{2}$/.test(s); 62 } 63 function toLocalMidnightUnixSeconds(ymd) { 64 const d = new Date(`${ymd}T00:00:00`); 65 const ms = d.getTime(); 66 if (!Number.isFinite(ms)) 67 return 0; 68 return Math.floor(ms / 1000); 69 } 70 function pickTaskTotalManhourRaw(parsed) { 71 if (!parsed || typeof parsed !== 'object') 72 return null; 73 const o = parsed; 74 const n = Number(o.total_manhour); 75 return Number.isFinite(n) ? n : null; 76 } 77 export function buildAddManhourGraphqlBody(input) { 78 const { ownerId, taskId, startTime, rawManhour, note } = input; 79 const description = JSON.stringify(note); 80 const owner = JSON.stringify(ownerId); 81 const task = JSON.stringify(taskId); 82 return JSON.stringify({ 83 query: `mutation AddManhour { 84 addManhour( 85 mode: "simple" 86 owner: ${owner} 87 task: ${task} 88 type: "recorded" 89 start_time: ${startTime} 90 hours: ${rawManhour} 91 description: ${description} 92 customData: {} 93 ) { 94 key 95 } 96 }`, 97 }); 98 } 99 cli({ 100 site: 'ones', 101 name: 'worklog', 102 description: 'ONES — log work hours on a task (defaults to today; use --date to backfill; endpoint falls back by deployment).', 103 domain: 'ones.cn', 104 strategy: Strategy.COOKIE, 105 browser: true, 106 navigateBefore: false, 107 args: [ 108 { 109 name: 'task', 110 type: 'str', 111 required: true, 112 positional: true, 113 help: 'Work item UUID (usually 16 chars), from my-tasks or browser URL …/task/<id>', 114 }, 115 { 116 name: 'hours', 117 type: 'str', 118 required: true, 119 positional: true, 120 help: 'Hours to log for this entry (e.g. 2 or 1.5), converted with ONES_MANHOUR_SCALE', 121 }, 122 { 123 name: 'team', 124 type: 'str', 125 required: false, 126 help: 'Team UUID from URL …/team/<uuid>/…, or set ONES_TEAM_UUID', 127 }, 128 { 129 name: 'date', 130 type: 'str', 131 required: false, 132 help: 'Entry date YYYY-MM-DD, defaults to today (local timezone); use for backfill', 133 }, 134 { 135 name: 'note', 136 type: 'str', 137 required: false, 138 help: 'Optional note (written to description/desc)', 139 }, 140 { 141 name: 'owner', 142 type: 'str', 143 required: false, 144 help: 'Owner user UUID (defaults to current logged-in user)', 145 }, 146 ], 147 columns: ['task', 'date', 'hours', 'owner', 'endpoint'], 148 func: async (page, kwargs) => { 149 const taskId = String(kwargs.task ?? '').trim(); 150 if (!taskId) { 151 throw new CliError('CONFIG', 'task uuid required', 'Pass the work item uuid from opencli ones my-tasks or the URL.'); 152 } 153 const team = kwargs.team?.trim() || 154 process.env.ONES_TEAM_UUID?.trim() || 155 process.env.ONES_TEAM_ID?.trim(); 156 if (!team) { 157 throw new CliError('CONFIG', 'team UUID required', 'Pass --team or set ONES_TEAM_UUID (from …/team/<team>/…).'); 158 } 159 const hoursHuman = Number(String(kwargs.hours ?? '').replace(/,/g, '')); 160 if (!Number.isFinite(hoursHuman) || hoursHuman <= 0 || hoursHuman > 1000) { 161 throw new CliError('CONFIG', 'hours must be a positive number (hours)', 'Example: opencli ones worklog <taskUUID> 2 --team <teamUUID>'); 162 } 163 const dateArg = kwargs.date?.trim(); 164 const dateStr = dateArg || todayLocalYmd(); 165 if (!validateYmd(dateStr)) { 166 throw new CliError('CONFIG', 'invalid --date', 'Use YYYY-MM-DD, e.g. 2026-03-24.'); 167 } 168 const note = String(kwargs.note ?? '').trim(); 169 const rawManhour = hoursToOnesManhourRaw(hoursHuman); 170 const startTime = toLocalMidnightUnixSeconds(dateStr); 171 if (!startTime) { 172 throw new CliError('CONFIG', 'invalid date for start_time', `Could not parse date ${dateStr}.`); 173 } 174 await gotoOnesHome(page); 175 const ownerFromKw = kwargs.owner?.trim(); 176 const ownerId = ownerFromKw || (await resolveOnesUserUuid(page, { skipGoto: true })); 177 const entry = { 178 owner: ownerId, 179 manhour: rawManhour, 180 start_date: dateStr, 181 end_date: dateStr, 182 desc: note, 183 }; 184 const entryAlt = { 185 owner: ownerId, 186 allManhour: rawManhour, 187 startDate: dateStr, 188 endDate: dateStr, 189 desc: note, 190 }; 191 const enc = encodeURIComponent(taskId); 192 const gqlBody = buildAddManhourGraphqlBody({ 193 ownerId, 194 taskId, 195 startTime, 196 rawManhour, 197 note, 198 }); 199 const attempts = [ 200 { path: `team/${team}/items/graphql`, body: gqlBody }, 201 { path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify(entry) }, 202 { path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify(entryAlt) }, 203 { path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify({ manhours: [entry] }) }, 204 { path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify({ manhours: [entryAlt] }) }, 205 { path: `team/${team}/task/${enc}/manhour/add`, body: JSON.stringify(entry) }, 206 { path: `team/${team}/task/${enc}/manhour/add`, body: JSON.stringify(entryAlt) }, 207 { 208 path: `team/${team}/tasks/update3`, 209 body: JSON.stringify({ 210 tasks: [{ uuid: taskId, manhours: [entry] }], 211 }), 212 }, 213 ]; 214 const beforeInfo = await onesFetchInPageWithMeta(page, `team/${team}/task/${enc}/info`, { 215 method: 'GET', 216 skipGoto: true, 217 }); 218 const beforeTotal = beforeInfo.ok ? pickTaskTotalManhourRaw(beforeInfo.parsed) : null; 219 let lastDetail = ''; 220 for (const { path, body } of attempts) { 221 const r = await onesFetchInPageWithMeta(page, path, { 222 method: 'POST', 223 body, 224 skipGoto: true, 225 }); 226 const fail = describeAttemptFailure(r); 227 if (!fail) { 228 // Guard against false success: HTTP 200 but no actual manhour change. 229 const afterInfo = await onesFetchInPageWithMeta(page, `team/${team}/task/${enc}/info`, { 230 method: 'GET', 231 skipGoto: true, 232 }); 233 if (afterInfo.ok) { 234 const afterTotal = pickTaskTotalManhourRaw(afterInfo.parsed); 235 const changed = beforeTotal === null 236 ? afterTotal !== null 237 : afterTotal !== null && Math.abs(afterTotal - beforeTotal) >= 1; 238 if (changed) { 239 return [ 240 { 241 task: taskId, 242 date: dateStr, 243 hours: String(hoursHuman), 244 owner: ownerId, 245 endpoint: path, 246 }, 247 ]; 248 } 249 lastDetail = `no effect (total_manhour ${String(beforeTotal)} -> ${String(afterTotal)})`; 250 continue; 251 } 252 // If verification read fails, return success conservatively. 253 return [ 254 { 255 task: taskId, 256 date: dateStr, 257 hours: String(hoursHuman), 258 owner: ownerId, 259 endpoint: path, 260 }, 261 ]; 262 } 263 lastDetail = fail; 264 } 265 throw new CliError('FETCH_ERROR', `ONES worklog: all endpoints failed (last: ${lastDetail})`); 266 }, 267 });