diff --git a/SKILL.md b/SKILL.md index a9be6f51..f013baac 100644 --- a/SKILL.md +++ b/SKILL.md @@ -236,6 +236,16 @@ opencli grok ask --prompt "问题" --web # 显式 grok.com consumer web UI 路 # HuggingFace (public) opencli hf top --limit 10 # 热门模型 +# ONES project management (Browser Bridge + ONES_BASE_URL; legacy Project API, see docs/adapters/browser/ones.md) +opencli ones me # Requires ONES logged in Chrome; optional ONES_USER_ID/TOKEN for headers +opencli ones login --email you@corp.com --password '***' # If needed; stderr prints export hints +opencli ones token-info +opencli ones tasks --project --limit 30 +opencli ones my-tasks --limit 100 # Defaults to assignee=self; if it looks too broad try --mode field004 or --mode both +opencli ones worklog 2 --team [--date YYYY-MM-DD] [--note '...'] # Log/backfill hours +opencli ones task --team # Work item detail (URL .../task/) +opencli ones logout + # 超星学习通 (browser) opencli chaoxing assignments # 作业列表 opencli chaoxing exams # 考试列表 diff --git a/docs/adapters-doc/ones.md b/docs/adapters-doc/ones.md new file mode 100644 index 00000000..c2866d75 --- /dev/null +++ b/docs/adapters-doc/ones.md @@ -0,0 +1,32 @@ +# ONES 项目管理平台(OpenCLI) + +基于官方 [ONES Project API](https://developer.ones.cn/zh-CN/docs/api/readme/),经 **Chrome + Browser Bridge** 在页面里 `fetch`(`credentials: 'include'`)。 + +## 环境变量 + +| 变量 | 必填 | 说明 | +|------|------|------| +| `ONES_BASE_URL` | 是 | 与 Chrome 中访问的 ONES 根 URL 一致 | +| `ONES_USER_ID` / `ONES_AUTH_TOKEN` | 视部署 | 若接口强制要文档中的 Header,再设置(可先只依赖浏览器登录) | +| `ONES_EMAIL` / `ONES_PHONE` / `ONES_PASSWORD` | 否 | 供 `ones login` 脚本化 | + +## 命令 + +```bash +export ONES_BASE_URL=https://your-host +# 安装扩展,Chrome 已登录 ONES + +opencli ones me +opencli ones token-info # teams column includes name(uuid), useful for tasks +opencli ones tasks --limit 20 --project +opencli ones my-tasks --limit 100 # default assignee=self +opencli ones my-tasks --mode field004 # deployments using field004 as assignee +opencli ones my-tasks --mode both # assignee OR creator +opencli ones task --team # single task (URL .../task/) +opencli ones worklog 2 --team # log hours for today +opencli ones worklog 1 --team --date 2026-03-01 # backfill +opencli ones login --email you@corp.com --password '***' # optional; stderr prints header export hints +opencli ones logout +``` + +更完整的说明见 [docs/adapters/browser/ones.md](../adapters/browser/ones.md)。 diff --git a/docs/adapters/browser/ones.md b/docs/adapters/browser/ones.md new file mode 100644 index 00000000..06a18415 --- /dev/null +++ b/docs/adapters/browser/ones.md @@ -0,0 +1,59 @@ +# ONES + +**Mode**: 🔐 Browser Bridge · **Domain**: `ones.cn` (self-hosted via `ONES_BASE_URL`) + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli ones login` | Login via Project API (`auth/login`) | +| `opencli ones me` | Current user profile (`users/me`) | +| `opencli ones token-info` | Token/user/team summary (`auth/token_info`) | +| `opencli ones tasks` | Team task list with status/project labels and hours | +| `opencli ones my-tasks` | My tasks (`assign`/`field004`/`owner`/`both`) | +| `opencli ones task` | Task detail by UUID (`team/:team/task/:id/info`) | +| `opencli ones worklog` | Log/backfill hours (GraphQL `addManhour` first, then REST fallbacks) | +| `opencli ones logout` | Logout (`auth/logout`) | + +## Usage Examples + +```bash +# Required: your ONES base URL +export ONES_BASE_URL=https://your-instance.example.com + +# Optional if your deployment requires auth headers +# export ONES_USER_ID=... +# export ONES_AUTH_TOKEN=... + +# Login/profile +opencli ones login --email you@company.com --password 'your-password' +opencli ones me +opencli ones token-info + +# Task lists +opencli ones tasks --limit 20 +opencli ones tasks --project --assign +opencli ones my-tasks --limit 100 +opencli ones my-tasks --mode both + +# Task detail +opencli ones task --team + +# Worklog: today / backfill +opencli ones worklog 2 --team +opencli ones worklog 1.5 --team --date 2026-03-23 --note "integration" + +opencli ones logout +``` + +## Prerequisites + +- Chrome running and logged into your ONES instance +- [Browser Bridge extension](/guide/browser-bridge) installed +- `ONES_BASE_URL` set to the same origin opened in Chrome + +## Notes + +- This adapter targets legacy ONES Project API deployments. +- `ONES_TEAM_UUID` can be set to omit `--team` in `tasks` / `my-tasks` / `task`. +- Hours display and input use `ONES_MANHOUR_SCALE` (default `100000`). diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 62e4f56e..3343403b 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -37,6 +37,7 @@ Run `opencli list` for the live registry. | **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser | | **[substack](/adapters/browser/substack)** | `feed` `search` `publication` | 🔐 Browser | | **[tiktok](/adapters/browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser | +| **[ones](/adapters/browser/ones)** | `login` `me` `token-info` `tasks` `my-tasks` `task` `worklog` `logout` | 🔐 Browser Bridge + `ONES_BASE_URL` | ## Public API Adapters diff --git a/src/clis/ones/common.ts b/src/clis/ones/common.ts new file mode 100644 index 00000000..3daad26a --- /dev/null +++ b/src/clis/ones/common.ts @@ -0,0 +1,187 @@ +/** + * ONES 旧版 Project API — 经 Browser Bridge 在已登录标签页内 fetch(携带 Cookie)。 + * 文档:https://developer.ones.cn/zh-CN/docs/api/readme/ + */ + +import type { IPage } from '../../types.js'; +import { CliError } from '../../errors.js'; + +export const API_PREFIX = '/project/api/project'; + +export function getOnesBaseUrl(): string { + const u = process.env.ONES_BASE_URL?.trim().replace(/\/+$/, ''); + if (!u) { + throw new CliError( + 'CONFIG', + 'Missing ONES_BASE_URL', + 'Set ONES_BASE_URL to your deployment origin, e.g. https://your-team.ones.cn (no trailing slash).', + ); + } + return u; +} + +export function onesApiUrl(apiPath: string): string { + const base = getOnesBaseUrl(); + const p = apiPath.replace(/^\/+/, ''); + return `${base}${API_PREFIX}/${p}`; +} + +/** 打开 ONES 根地址,确保后续 fetch 与页面同源、带上登录 Cookie */ +export async function gotoOnesHome(page: IPage): Promise { + await page.goto(getOnesBaseUrl(), { waitUntil: 'load' }); + await page.wait(2); +} + +/** + * 在页面内发起请求。默认带 credentials;若设置了 ONES_USER_ID + ONES_AUTH_TOKEN,则附加文档要求的 Header(与纯 Cookie 二选一或并存,取决于部署)。 + */ +function buildHeaders(auth: boolean, includeJsonContentType: boolean): Record { + const ref = getOnesBaseUrl(); + const out: Record = { Referer: ref }; + if (auth) { + const uid = + process.env.ONES_USER_ID?.trim() || + process.env.ONES_USER_UUID?.trim() || + process.env.Ones_User_Id?.trim(); + const tok = process.env.ONES_AUTH_TOKEN?.trim() || process.env.Ones_Auth_Token?.trim(); + if (uid && tok) { + out['Ones-User-Id'] = uid; + out['Ones-Auth-Token'] = tok; + } + } + if (includeJsonContentType) out['Content-Type'] = 'application/json'; + return out; +} + +export function summarizeOnesError(status: number, body: unknown): string { + if (body && typeof body === 'object') { + const o = body as Record; + const parts: string[] = []; + if (typeof o.type === 'string') parts.push(o.type); + if (typeof o.reason === 'string') parts.push(o.reason); + if (typeof o.errcode === 'string') parts.push(o.errcode); + if (typeof o.message === 'string') parts.push(o.message); + if (o.code !== undefined && o.code !== null) parts.push(`code=${String(o.code)}`); + if (parts.length) return parts.filter(Boolean).join(' · '); + } + return status === 401 ? 'Unauthorized' : `HTTP ${status}`; +} + +/** ONES 部分接口 HTTP 200 但 body 仍为错误(如 reason: ServerError) */ +function throwIfOnesPeekBusinessError(apiPath: string, parsed: unknown): void { + if (parsed === null || typeof parsed !== 'object') return; + const o = parsed as Record; + if (Array.isArray(o.groups)) return; + const hasErr = + (typeof o.reason === 'string' && o.reason.length > 0) || + (typeof o.errcode === 'string' && o.errcode.length > 0) || + (typeof o.type === 'string' && o.type.length > 0); + if (!hasErr) return; + const detail = summarizeOnesError(200, parsed); + throw new CliError( + 'FETCH_ERROR', + `ONES ${apiPath}: ${detail}`, + '若 query 不合法会返回 ServerError;可试 opencli ones tasks(空 must)或检查筛选器文档。响应全文可用 -v 或临时打日志。', + ); +} + +export async function onesFetchInPageWithMeta( + page: IPage, + apiPath: string, + options: { + method?: string; + body?: string | null; + auth?: boolean; + skipGoto?: boolean; + } = {}, +): Promise<{ ok: boolean; status: number; parsed: unknown }> { + if (!options.skipGoto) { + await gotoOnesHome(page); + } + + const url = onesApiUrl(apiPath); + const method = (options.method ?? 'GET').toUpperCase(); + const auth = options.auth !== false; + const body = options.body ?? null; + const includeCt = body !== null || method === 'POST' || method === 'PUT' || method === 'PATCH'; + const headers = buildHeaders(auth, includeCt); + + const urlJs = JSON.stringify(url); + const methodJs = JSON.stringify(method); + const headersJs = JSON.stringify(headers); + const bodyJs = body === null ? 'null' : JSON.stringify(body); + + const raw = await page.evaluate(` + (async () => { + const url = ${urlJs}; + const method = ${methodJs}; + const headers = ${headersJs}; + const body = ${bodyJs}; + const init = { + method, + headers: { ...headers }, + credentials: 'include', + }; + if (body !== null) init.body = body; + const res = await fetch(url, init); + const text = await res.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + return { ok: res.ok, status: res.status, parsed }; + })() + `); + + return raw as { ok: boolean; status: number; parsed: unknown }; +} + +/** 当前操作用户 8 位 uuid(Header 或 GET users/me) */ +export async function resolveOnesUserUuid(page: IPage, opts?: { skipGoto?: boolean }): Promise { + const fromEnv = + process.env.ONES_USER_ID?.trim() || + process.env.ONES_USER_UUID?.trim() || + process.env.Ones_User_Id?.trim(); + if (fromEnv) return fromEnv; + + const data = (await onesFetchInPage(page, 'users/me', { skipGoto: opts?.skipGoto })) as Record; + const u = data.user && typeof data.user === 'object' ? (data.user as Record) : data; + if (!u || typeof u.uuid !== 'string') { + throw new CliError( + 'FETCH_ERROR', + 'Could not read current user uuid from users/me', + 'Set ONES_USER_ID or ensure Chrome is logged in; try: opencli ones me -f json', + ); + } + return String(u.uuid); +} + +export async function onesFetchInPage( + page: IPage, + apiPath: string, + options: { + method?: string; + body?: string | null; + auth?: boolean; + /** 已在 ONES 根页时设为 true,避免每条 API 都 goto+wait(显著提速) */ + skipGoto?: boolean; + } = {}, +): Promise { + const r = await onesFetchInPageWithMeta(page, apiPath, options); + if (!r.ok) { + const detail = summarizeOnesError(r.status, r.parsed); + const hint = + r.status === 401 + ? '在 Chrome 中打开 ONES 并登录;或先执行 opencli ones login 后按提示 export ONES_USER_ID / ONES_AUTH_TOKEN;并确认 ONES_BASE_URL 与浏览器地址一致。' + : '检查 ONES_BASE_URL、VPN/内网,以及实例是否仍为 Project API 路径。'; + throw new CliError('FETCH_ERROR', `ONES ${apiPath}: ${detail}`, hint); + } + + if (apiPath.includes('/filters/peek')) { + throwIfOnesPeekBusinessError(apiPath, r.parsed); + } + + return r.parsed; +} diff --git a/src/clis/ones/enrich-tasks.ts b/src/clis/ones/enrich-tasks.ts new file mode 100644 index 00000000..eefe82a2 --- /dev/null +++ b/src/clis/ones/enrich-tasks.ts @@ -0,0 +1,47 @@ +/** + * peek 列表只有轻量字段,用 batch tasks/info 补全 summary 等(ONES 文档 #7) + */ + +import type { IPage } from '../../types.js'; +import { onesFetchInPage } from './common.js'; + +const BATCH_SIZE = 40; + +export async function enrichPeekEntriesWithDetails( + page: IPage, + team: string, + entries: Record[], + skipGoto: boolean, +): Promise[]> { + const ids = [...new Set(entries.map((e) => String(e.uuid ?? '').trim()).filter(Boolean))]; + if (ids.length === 0) return entries; + + const byId = new Map>(); + + try { + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + const slice = ids.slice(i, i + BATCH_SIZE); + const parsed = (await onesFetchInPage(page, `team/${team}/tasks/info`, { + method: 'POST', + body: JSON.stringify({ ids: slice }), + skipGoto, + })) as Record; + + const tasks = Array.isArray(parsed.tasks) ? (parsed.tasks as Record[]) : []; + for (const t of tasks) { + const id = String(t.uuid ?? ''); + if (id) byId.set(id, t); + } + } + } catch { + return entries; + } + + if (byId.size === 0) return entries; + + return entries.map((e) => { + const id = String(e.uuid ?? ''); + const full = id ? byId.get(id) : undefined; + return full ? { ...e, ...full } : e; + }); +} diff --git a/src/clis/ones/login.ts b/src/clis/ones/login.ts new file mode 100644 index 00000000..3b28d7f3 --- /dev/null +++ b/src/clis/ones/login.ts @@ -0,0 +1,103 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { getOnesBaseUrl, onesFetchInPage } from './common.js'; + +cli({ + site: 'ones', + name: 'login', + description: + 'ONES Project API — login via Chrome Bridge (POST auth/login); stderr prints export hints for ONES_USER_ID / TOKEN', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'email', + type: 'str', + required: false, + help: 'Account email (or set ONES_EMAIL)', + }, + { + name: 'phone', + type: 'str', + required: false, + help: 'Account phone (or set ONES_PHONE); ignored if email is set', + }, + { + name: 'password', + type: 'str', + required: false, + help: 'Password (or set ONES_PASSWORD)', + }, + ], + columns: ['uuid', 'name', 'email', 'token_preview'], + + func: async (page, kwargs) => { + const email = (kwargs.email as string | undefined)?.trim() || process.env.ONES_EMAIL?.trim(); + const phone = (kwargs.phone as string | undefined)?.trim() || process.env.ONES_PHONE?.trim(); + const password = + (kwargs.password as string | undefined) || process.env.ONES_PASSWORD || ''; + + if (!password) { + throw new CliError( + 'CONFIG', + 'Password required', + 'Pass --password or set ONES_PASSWORD for non-interactive use.', + ); + } + if (!email && !phone) { + throw new CliError( + 'CONFIG', + 'email or phone required', + 'Pass --email or --phone (or set ONES_EMAIL / ONES_PHONE).', + ); + } + + getOnesBaseUrl(); + const bodyObj: Record = { password }; + if (email) bodyObj.email = email; + else bodyObj.phone = phone!; + + const parsed = (await onesFetchInPage(page, 'auth/login', { + method: 'POST', + body: JSON.stringify(bodyObj), + auth: false, + })) as Record; + + const user = parsed.user as Record | undefined; + if (!user?.uuid || !user?.token) { + throw new CliError( + 'FETCH_ERROR', + 'ONES login response missing user.uuid or user.token', + 'Your server build may differ from documented Project API.', + ); + } + + const uuid = String(user.uuid); + const token = String(user.token); + const name = String(user.name ?? ''); + const em = String(user.email ?? ''); + + const base = getOnesBaseUrl(); + console.error( + [ + '', + '后续请求会优先使用当前 Chrome 会话 Cookie;若接口仍要求 Header,可 export:', + ` export ONES_BASE_URL=${JSON.stringify(base)}`, + ` export ONES_USER_ID=${JSON.stringify(uuid)}`, + ` export ONES_AUTH_TOKEN=${JSON.stringify(token)}`, + '', + ].join('\n'), + ); + + return [ + { + uuid, + name, + email: em, + token_preview: token.length > 12 ? `${token.slice(0, 6)}…${token.slice(-4)}` : '***', + }, + ]; + }, +}); diff --git a/src/clis/ones/logout.ts b/src/clis/ones/logout.ts new file mode 100644 index 00000000..ac754e64 --- /dev/null +++ b/src/clis/ones/logout.ts @@ -0,0 +1,19 @@ +import { cli, Strategy } from '../../registry.js'; +import { onesFetchInPage } from './common.js'; + +cli({ + site: 'ones', + name: 'logout', + description: 'ONES Project API — invalidate current token (GET auth/logout) via Chrome Bridge', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['ok', 'detail'], + + func: async (page) => { + await onesFetchInPage(page, 'auth/logout', { method: 'GET' }); + return [{ ok: 'true', detail: 'Server logout ok; clear local ONES_AUTH_TOKEN if set.' }]; + }, +}); diff --git a/src/clis/ones/me.ts b/src/clis/ones/me.ts new file mode 100644 index 00000000..c65164e4 --- /dev/null +++ b/src/clis/ones/me.ts @@ -0,0 +1,34 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { onesFetchInPage } from './common.js'; + +cli({ + site: 'ones', + name: 'me', + description: 'ONES Project API — current user (GET users/me) via Chrome Bridge', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['uuid', 'name', 'email', 'phone', 'status'], + + func: async (page) => { + const data = (await onesFetchInPage(page, 'users/me')) as Record; + const u = data.user && typeof data.user === 'object' ? (data.user as Record) : data; + + if (!u || typeof u.uuid !== 'string') { + throw new CliError('FETCH_ERROR', 'Unexpected users/me response', 'See raw JSON with: opencli ones me -f json'); + } + + return [ + { + uuid: String(u.uuid), + name: String(u.name ?? ''), + email: String(u.email ?? ''), + phone: String(u.phone ?? ''), + status: u.status != null ? String(u.status) : '', + }, + ]; + }, +}); diff --git a/src/clis/ones/my-tasks.ts b/src/clis/ones/my-tasks.ts new file mode 100644 index 00000000..6d1fee9d --- /dev/null +++ b/src/clis/ones/my-tasks.ts @@ -0,0 +1,147 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import type { IPage } from '../../types.js'; +import { gotoOnesHome, onesFetchInPage, resolveOnesUserUuid } from './common.js'; +import { enrichPeekEntriesWithDetails } from './enrich-tasks.js'; +import { resolveTaskListLabels } from './resolve-labels.js'; +import { + defaultPeekBody, + flattenPeekGroups, + mapTaskEntry, +} from './task-helpers.js'; + +/** 文档示例里「负责人」常用 field004;与顶层 assign 在不同部署上二选一有效 */ +function queryAssign(userUuid: string): Record { + return { must: [{ equal: { assign: userUuid } }] }; +} + +function queryAssignField004(userUuid: string): Record { + return { must: [{ in: { 'field_values.field004': [userUuid] } }] }; +} + +function queryOwner(userUuid: string): Record { + return { must: [{ equal: { owner: userUuid } }] }; +} + +function dedupeByUuid(entries: Record[]): Record[] { + const seen = new Set(); + const out: Record[] = []; + for (const e of entries) { + const id = String(e.uuid ?? ''); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push(e); + } + return out; +} + +async function peekTasks( + page: IPage, + team: string, + query: Record, + cap: number, +): Promise[]> { + const path = `team/${team}/filters/peek`; + const body = defaultPeekBody(query); + const parsed = (await onesFetchInPage(page, path, { + method: 'POST', + body: JSON.stringify(body), + skipGoto: true, + })) as Record; + return flattenPeekGroups(parsed, cap); +} + +cli({ + site: 'ones', + name: 'my-tasks', + description: + 'ONES — my work items (filters/peek + strict must query). Default: assignee=me. Use --mode if your site uses field004 for assignee.', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'team', + type: 'str', + required: false, + positional: true, + help: 'Team UUID from URL …/team//…, or set ONES_TEAM_UUID', + }, + { + name: 'limit', + type: 'int', + default: 100, + help: 'Max rows (default 100, max 500)', + }, + { + name: 'mode', + type: 'str', + default: 'assign', + choices: ['assign', 'field004', 'owner', 'both'], + help: + 'assign=负责人(顶层 assign);field004=负责人(筛选器示例里的 field004);owner=创建者;both=负责人∪创建者(两次 peek 去重)', + }, + ], + columns: ['title', 'status', 'project', 'uuid', 'updated', '工时'], + + func: async (page, kwargs) => { + const team = + (kwargs.team as string | undefined)?.trim() || + process.env.ONES_TEAM_UUID?.trim() || + process.env.ONES_TEAM_ID?.trim(); + if (!team) { + throw new CliError( + 'CONFIG', + 'team UUID required', + 'Pass team from URL …/team//… or set ONES_TEAM_UUID.', + ); + } + + const limit = Math.max(1, Math.min(500, Number(kwargs.limit ?? 100))); + const mode = String(kwargs.mode ?? 'assign'); + + await gotoOnesHome(page); + const userUuid = await resolveOnesUserUuid(page, { skipGoto: true }); + + let entries: Record[] = []; + + if (mode === 'both') { + const cap = Math.min(500, limit * 2); + const asAssign = await peekTasks(page, team, queryAssign(userUuid), cap); + const asOwner = await peekTasks(page, team, queryOwner(userUuid), cap); + entries = dedupeByUuid([...asAssign, ...asOwner]).slice(0, limit); + } else { + const queryByMode = (): Record => { + switch (mode) { + case 'field004': + return queryAssignField004(userUuid); + case 'owner': + return queryOwner(userUuid); + case 'assign': + default: + return queryAssign(userUuid); + } + }; + + const primary = queryByMode(); + try { + entries = await peekTasks(page, team, primary, limit); + } catch (e) { + const msg = e instanceof Error ? e.message : ''; + const canFallback = + mode === 'assign' && + (msg.includes('ServerError') || msg.includes('801') || msg.includes('Params is invalid')); + if (canFallback) { + entries = await peekTasks(page, team, queryAssignField004(userUuid), limit); + } else { + throw e; + } + } + } + + const enriched = await enrichPeekEntriesWithDetails(page, team, entries, true); + const labels = await resolveTaskListLabels(page, team, enriched, true); + return enriched.map((e) => mapTaskEntry(e, labels)); + }, +}); diff --git a/src/clis/ones/resolve-labels.ts b/src/clis/ones/resolve-labels.ts new file mode 100644 index 00000000..8a660c27 --- /dev/null +++ b/src/clis/ones/resolve-labels.ts @@ -0,0 +1,80 @@ +/** + * 把 status / project 的 uuid 解析为中文名(团队级接口各查一次或按批) + */ + +import type { IPage } from '../../types.js'; +import { onesFetchInPage } from './common.js'; +import { getTaskProjectRawId } from './task-helpers.js'; + +export async function loadTaskStatusLabels( + page: IPage, + team: string, + skipGoto: boolean, +): Promise> { + const map = new Map(); + try { + const parsed = (await onesFetchInPage(page, `team/${team}/task_statuses`, { + method: 'GET', + skipGoto, + })) as Record; + const list = Array.isArray(parsed.task_statuses) + ? (parsed.task_statuses as Record[]) + : []; + for (const s of list) { + const id = String(s.uuid ?? ''); + const name = String(s.name ?? ''); + if (id && name) map.set(id, name); + } + } catch { + /* 降级为仅显示 uuid */ + } + return map; +} + +const PROJECT_INFO_CHUNK = 25; + +export async function loadProjectLabels( + page: IPage, + team: string, + projectUuids: string[], + skipGoto: boolean, +): Promise> { + const map = new Map(); + const ids = [...new Set(projectUuids.filter(Boolean))]; + if (ids.length === 0) return map; + + try { + for (let i = 0; i < ids.length; i += PROJECT_INFO_CHUNK) { + const slice = ids.slice(i, i + PROJECT_INFO_CHUNK); + const q = slice.map(encodeURIComponent).join(','); + const path = `team/${team}/projects/info?ids=${q}`; + const parsed = (await onesFetchInPage(page, path, { + method: 'GET', + skipGoto, + })) as Record; + const projects = Array.isArray(parsed.projects) ? (parsed.projects as Record[]) : []; + for (const p of projects) { + const id = String(p.uuid ?? ''); + const name = String(p.name ?? ''); + if (id && name) map.set(id, name); + } + } + } catch { + /* 降级 */ + } + return map; +} + +export async function resolveTaskListLabels( + page: IPage, + team: string, + entries: Record[], + skipGoto: boolean, +): Promise<{ statusByUuid: Map; projectByUuid: Map }> { + const projectUuids = entries.map((e) => getTaskProjectRawId(e)).filter(Boolean); + const [statusByUuid, projectByUuid] = await Promise.all([ + loadTaskStatusLabels(page, team, skipGoto), + loadProjectLabels(page, team, projectUuids, skipGoto), + ]); + return { statusByUuid, projectByUuid }; +} diff --git a/src/clis/ones/task-helpers.ts b/src/clis/ones/task-helpers.ts new file mode 100644 index 00000000..75063f79 --- /dev/null +++ b/src/clis/ones/task-helpers.ts @@ -0,0 +1,208 @@ +/** + * ONES filters/peek 响应解析(tasks / my-tasks 共用) + */ + +import { CliError } from '../../errors.js'; + +/** ONES task 里 field_values 常为 [{ field_uuid, value }, ...] */ +function pickTitleFromFieldValuesArray(fv: unknown): string { + if (!Array.isArray(fv)) return ''; + for (const item of fv) { + if (!item || typeof item !== 'object') continue; + const row = item as Record; + const fu = String(row.field_uuid ?? ''); + if (!fu.startsWith('field')) continue; + const v = row.value; + if (typeof v === 'string' && v.trim()) return v.trim(); + if (Array.isArray(v) && v.length && typeof v[0] === 'string' && v[0].trim()) return v[0].trim(); + } + return ''; +} + +export function pickTaskTitle(e: Record): string { + for (const k of ['summary', 'name', 'title', 'subject']) { + const v = e[k]; + if (typeof v === 'string' && v.trim()) return v.trim(); + } + const fromArr = pickTitleFromFieldValuesArray(e.field_values); + if (fromArr) return fromArr; + const fv = e.field_values; + if (fv && typeof fv === 'object' && !Array.isArray(fv)) { + const o = fv as Record; + for (const k of ['field001', 'field002', 'field003']) { + const v = o[k]; + if (typeof v === 'string' && v.trim()) return v.trim(); + } + } + return ''; +} + +/** 表格里标题别撑爆终端 */ +export function ellipsizeCell(s: string, max = 64): string { + const t = s.trim(); + if (t.length <= max) return t; + return `${t.slice(0, max - 1)}…`; +} + +/** 辅助列:长 uuid 缩略,完整值见 -f json */ +export function briefUuid(id: string, head = 6, tail = 4): string { + if (!id) return ''; + if (id.length <= head + tail + 1) return id; + return `${id.slice(0, head)}…${id.slice(-tail)}`; +} + +export function formatStamp(v: unknown): string { + if (v == null || v === '') return ''; + const n = Number(v); + if (Number.isNaN(n)) return String(v); + const ms = n > 1e14 ? Math.floor(n / 1000) : n > 1e12 ? n : n * 1000; + try { + return new Date(ms).toISOString().replace('T', ' ').slice(0, 19); + } catch { + return String(v); + } +} + +export function flattenPeekGroups(parsed: Record, limit: number): Record[] { + if (!Array.isArray(parsed.groups)) { + throw new CliError( + 'FETCH_ERROR', + 'Unexpected filters/peek response (missing groups)', + 'Try -f json; check team UUID and API version.', + ); + } + + const groups = parsed.groups as Record[]; + const rows: Record[] = []; + + for (const g of groups) { + const entries = Array.isArray(g.entries) ? (g.entries as Record[]) : []; + for (const e of entries) { + rows.push(e); + if (rows.length >= limit) break; + } + if (rows.length >= limit) break; + } + + return rows.slice(0, limit); +} + +function fieldArrayFirstString(fv: unknown, fieldUuid: string): string { + if (!Array.isArray(fv)) return ''; + for (const item of fv) { + if (!item || typeof item !== 'object') continue; + const row = item as Record; + if (String(row.field_uuid ?? '') !== fieldUuid) continue; + const v = row.value; + if (typeof v === 'string') return v; + if (Array.isArray(v) && v[0] != null) return String(v[0]); + } + return ''; +} + +function fvRecord(e: Record): Record | null { + const fv = e.field_values; + return fv && typeof fv === 'object' && !Array.isArray(fv) ? (fv as Record) : null; +} + +/** 工作项状态 uuid(用于查 task_statuses 得中文名) */ +export function getTaskStatusRawId(e: Record): string { + const fv = e.field_values; + const fvObj = fvRecord(e); + if (typeof e.status_uuid === 'string') return e.status_uuid; + return fieldArrayFirstString(fv, 'field016') || (fvObj ? String(fvObj.field016 ?? '') : ''); +} + +/** 项目 uuid */ +export function getTaskProjectRawId(e: Record): string { + const fv = e.field_values; + const fvObj = fvRecord(e); + if (typeof e.project_uuid === 'string') return e.project_uuid; + return fieldArrayFirstString(fv, 'field006') || (fvObj ? String(fvObj.field006 ?? '') : ''); +} + +/** + * Project API 里 assess/total/remaining_manhour 多为**定点整数**(与 Web 上「小时」不一致); + * 常见换算:raw / 1e5 ≈ 小时。若你方实例不同,可设 `ONES_MANHOUR_SCALE`(默认 100000)。 + */ +export function onesManhourScale(): number { + const raw = Number(process.env.ONES_MANHOUR_SCALE?.trim()); + if (Number.isFinite(raw) && raw > 0) return raw; + return 1e5; +} + +/** 界面/h 小数 → API 内 manhour 整数(与列表「工时」列同一刻度) */ +export function hoursToOnesManhourRaw(hours: number): number { + if (!Number.isFinite(hours) || hours <= 0) return 0; + return Math.max(1, Math.round(hours * onesManhourScale())); +} + +function formatHoursShort(hours: number): string { + if (!Number.isFinite(hours)) return ''; + const snapped = Math.round(hours * 1e6) / 1e6; + const near = Math.round(snapped); + if (Math.abs(snapped - near) < 1e-5) return `${near}h`; + const t = Math.round(snapped * 10) / 10; + return Number.isInteger(t) ? `${t}h` : `${t.toFixed(1)}h`; +} + +function formatManhourSegment(label: string, v: unknown): string | null { + if (v == null || v === '') return null; + const n = Number(v); + if (!Number.isFinite(n)) return null; + const hours = n / onesManhourScale(); + return `${label}${formatHoursShort(hours)}`; +} + +export function formatTaskManhourSummary(e: Record): string { + const parts: string[] = []; + const a = formatManhourSegment('估', e.assess_manhour); + const t = formatManhourSegment('登', e.total_manhour); + const r = formatManhourSegment('余', e.remaining_manhour); + if (a) parts.push(a); + if (t) parts.push(t); + if (r) parts.push(r); + return parts.length ? parts.join(' ') : '—'; +} + +export interface TaskLabelMaps { + statusByUuid?: Map; + projectByUuid?: Map; +} + +export function mapTaskEntry(e: Record, labels?: TaskLabelMaps): Record { + const statusId = getTaskStatusRawId(e); + const projectId = getTaskProjectRawId(e); + + const fullUuid = String(e.uuid ?? ''); + const title = ellipsizeCell(pickTaskTitle(e)); + + const briefIfLong = (s: string) => (s.length > 14 ? briefUuid(s) : s); + + const statusLabel = labels?.statusByUuid?.get(statusId) ?? briefIfLong(statusId); + const projectLabel = labels?.projectByUuid?.get(projectId) ?? briefIfLong(projectId); + + return { + title, + status: ellipsizeCell(statusLabel, 20), + project: ellipsizeCell(projectLabel, 40), + uuid: fullUuid, + updated: formatStamp(e.server_update_stamp), + 工时: ellipsizeCell(formatTaskManhourSummary(e), 36), + }; +} + +export function defaultPeekBody(query: Record): Record { + return { + with_boards: false, + boards: null, + query, + group_by: '', + sort: [{ create_time: { order: 'desc' } }], + include_subtasks: false, + include_status_uuid: true, + include_issue_type: false, + include_project_uuid: true, + is_show_derive: false, + }; +} diff --git a/src/clis/ones/task.ts b/src/clis/ones/task.ts new file mode 100644 index 00000000..72a97a29 --- /dev/null +++ b/src/clis/ones/task.ts @@ -0,0 +1,79 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { onesFetchInPage } from './common.js'; +import { formatStamp } from './task-helpers.js'; + +/** + * 工作项详情 — 对应前端路由 …/team//filter/view/…/task/ + * API: GET team/:teamUUID/task/:taskUUIDOrNumber/info + * @see https://docs.ones.cn/project/open-api-doc/project/task.html + */ +cli({ + site: 'ones', + name: 'task', + description: + 'ONES — work item detail (GET team/:team/task/:id/info); id is URL segment after …/task/', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'id', + type: 'str', + required: true, + positional: true, + help: 'Work item UUID (often 16 chars) from …/task/', + }, + { + name: 'team', + type: 'str', + required: false, + help: 'Team UUID (8 chars from …/team//…), or set ONES_TEAM_UUID', + }, + ], + columns: ['uuid', 'summary', 'number', 'status_uuid', 'assign', 'owner', 'project_uuid', 'updated'], + + func: async (page, kwargs) => { + const id = String(kwargs.id ?? '').trim(); + if (!id) { + throw new CliError('CONFIG', 'task id required', 'Pass the work item uuid from the URL path …/task/'); + } + + const team = + (kwargs.team as string | undefined)?.trim() || + process.env.ONES_TEAM_UUID?.trim() || + process.env.ONES_TEAM_ID?.trim(); + if (!team) { + throw new CliError( + 'CONFIG', + 'team UUID required', + 'Use --team or set ONES_TEAM_UUID (from …/team//…).', + ); + } + + const path = `team/${team}/task/${encodeURIComponent(id)}/info`; + const data = (await onesFetchInPage(page, path, { method: 'GET' })) as Record; + + if (typeof data.uuid !== 'string') { + const hint = + typeof data.reason === 'string' + ? data.reason + : 'Use -f json to inspect response; check id length (often 16) and team.'; + throw new CliError('FETCH_ERROR', `ONES task info: ${hint}`, 'Confirm task uuid and team match the browser URL.'); + } + + return [ + { + uuid: String(data.uuid), + summary: String(data.summary ?? ''), + number: data.number != null ? String(data.number) : '', + status_uuid: String(data.status_uuid ?? ''), + assign: String(data.assign ?? ''), + owner: String(data.owner ?? ''), + project_uuid: String(data.project_uuid ?? ''), + updated: formatStamp(data.server_update_stamp), + }, + ]; + }, +}); diff --git a/src/clis/ones/tasks.ts b/src/clis/ones/tasks.ts new file mode 100644 index 00000000..5306f69f --- /dev/null +++ b/src/clis/ones/tasks.ts @@ -0,0 +1,92 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { gotoOnesHome, onesFetchInPage } from './common.js'; +import { enrichPeekEntriesWithDetails } from './enrich-tasks.js'; +import { resolveTaskListLabels } from './resolve-labels.js'; +import { defaultPeekBody, flattenPeekGroups, mapTaskEntry } from './task-helpers.js'; + +function buildQuery(project?: string, assign?: string): Record { + const must: unknown[] = []; + if (project?.trim()) { + must.push({ in: { 'field_values.field006': [project.trim()] } }); + } + if (assign?.trim()) { + must.push({ equal: { assign: assign.trim() } }); + } + if (must.length === 0) { + return { must: [] }; + } + return { must }; +} + +cli({ + site: 'ones', + name: 'tasks', + description: + 'ONES Project API — list work items (POST team/:team/filters/peek); use token-info -f json for team uuid', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'team', + type: 'str', + required: false, + positional: true, + help: 'Team UUID (8 chars), or set ONES_TEAM_UUID', + }, + { + name: 'project', + type: 'str', + required: false, + help: 'Filter by project UUID (field006 / 所属项目)', + }, + { + name: 'assign', + type: 'str', + required: false, + help: 'Filter by assignee user UUID (负责人 assign)', + }, + { + name: 'limit', + type: 'int', + default: 30, + help: 'Max rows after flattening groups (default 30)', + }, + ], + columns: ['title', 'status', 'project', 'uuid', 'updated', '工时'], + + func: async (page, kwargs) => { + const team = + (kwargs.team as string | undefined)?.trim() || + process.env.ONES_TEAM_UUID?.trim() || + process.env.ONES_TEAM_ID?.trim(); + if (!team) { + throw new CliError( + 'CONFIG', + 'team UUID required', + 'Pass team as first argument or set ONES_TEAM_UUID (see `opencli ones token-info -f json` → teams[].uuid).', + ); + } + + const project = (kwargs.project as string | undefined)?.trim(); + const assign = (kwargs.assign as string | undefined)?.trim(); + const limit = Math.max(1, Math.min(500, Number(kwargs.limit ?? 30))); + + await gotoOnesHome(page); + + const body = defaultPeekBody(buildQuery(project, assign)); + const path = `team/${team}/filters/peek`; + const parsed = (await onesFetchInPage(page, path, { + method: 'POST', + body: JSON.stringify(body), + skipGoto: true, + })) as Record; + + const entries = flattenPeekGroups(parsed, limit); + const enriched = await enrichPeekEntriesWithDetails(page, team, entries, true); + const labels = await resolveTaskListLabels(page, team, enriched, true); + return enriched.map((e) => mapTaskEntry(e, labels)); + }, +}); diff --git a/src/clis/ones/token-info.ts b/src/clis/ones/token-info.ts new file mode 100644 index 00000000..78642bf7 --- /dev/null +++ b/src/clis/ones/token-info.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { onesFetchInPage } from './common.js'; + +cli({ + site: 'ones', + name: 'token-info', + description: + 'ONES Project API — session detail (GET auth/token_info) via Chrome Bridge: user, teams, org', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['uuid', 'name', 'email', 'teams', 'org_name'], + + func: async (page) => { + const root = (await onesFetchInPage(page, 'auth/token_info')) as Record; + const user = root.user && typeof root.user === 'object' ? (root.user as Record) : null; + if (!user?.uuid) { + throw new CliError('FETCH_ERROR', 'Unexpected auth/token_info response', 'Try `opencli ones me -f json` or check ONES_* env vars.'); + } + + const teamRows = Array.isArray(root.teams) ? (root.teams as Record[]) : []; + const teamsHint = teamRows + .map((t) => { + const n = String(t.name ?? '').trim(); + const u = String(t.uuid ?? '').trim(); + if (n && u) return `${n} (${u})`; + return u || n; + }) + .filter(Boolean) + .join(', '); + const org = root.org && typeof root.org === 'object' ? (root.org as Record) : null; + + return [ + { + uuid: String(user.uuid), + name: String(user.name ?? ''), + email: String(user.email ?? ''), + teams: teamsHint, + org_name: org ? String(org.name ?? '') : '', + }, + ]; + }, +}); diff --git a/src/clis/ones/worklog.ts b/src/clis/ones/worklog.ts new file mode 100644 index 00000000..9790b809 --- /dev/null +++ b/src/clis/ones/worklog.ts @@ -0,0 +1,284 @@ +/** + * Log/backfill work hours. Project API paths vary by deployment, + * so we try common endpoints in sequence. + */ + +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { + gotoOnesHome, + onesFetchInPageWithMeta, + resolveOnesUserUuid, + summarizeOnesError, +} from './common.js'; +import { hoursToOnesManhourRaw } from './task-helpers.js'; + +function summarizeOnesMutationBody(parsed: unknown, status: number): string | null { + if (!parsed || typeof parsed !== 'object') { + return status >= 400 ? `HTTP ${status}` : null; + } + const o = parsed as Record; + if (Array.isArray(o.errors) && o.errors.length > 0) { + const e0 = o.errors[0]; + if (e0 && typeof e0 === 'object') { + const msg = String((e0 as Record).message ?? '').trim(); + if (msg) return msg; + } + return 'graphql errors'; + } + if (o.data && typeof o.data === 'object') { + const data = o.data as Record; + if (data.addManhour && typeof data.addManhour === 'object') { + const key = String((data.addManhour as Record).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] as Record; + 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: { ok: boolean; status: number; parsed: unknown }): string | null { + if (!r.ok) return summarizeOnesError(r.status, r.parsed); + return summarizeOnesMutationBody(r.parsed, r.status); +} + +function todayLocalYmd(): string { + 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: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(s); +} + +function toLocalMidnightUnixSeconds(ymd: string): number { + 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: unknown): number | null { + if (!parsed || typeof parsed !== 'object') return null; + const o = parsed as Record; + const n = Number(o.total_manhour); + return Number.isFinite(n) ? n : null; +} + +cli({ + site: 'ones', + name: 'worklog', + 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 as string | undefined)?.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 as string | undefined)?.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 as string | undefined)?.trim(); + const ownerId = ownerFromKw || (await resolveOnesUserUuid(page, { skipGoto: true })); + + const entry: Record = { + owner: ownerId, + manhour: rawManhour, + start_date: dateStr, + end_date: dateStr, + desc: note, + }; + const entryAlt: Record = { + owner: ownerId, + allManhour: rawManhour, + startDate: dateStr, + endDate: dateStr, + desc: note, + }; + + const enc = encodeURIComponent(taskId); + const gqlBody = JSON.stringify({ + query: + 'mutation AddManhour { addManhour (mode: $mode owner: $owner task: $task type: $type start_time: $start_time hours: $hours description: $description customData: $customData) { key } }', + variables: { + mode: 'simple', + type: 'recorded', + customData: {}, + owner: ownerId, + task: taskId, + start_time: startTime, + hours: rawManhour, + description: note, + remaining_hours: null, + }, + }); + const attempts: { path: string; body: string }[] = [ + { 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})`, + ); + }, +});