/ clis / ones / worklog.js
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  });