diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bfb33e71b1..b2e07a2a35 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "genesis-tools", - "version": "1.0.113", + "version": "1.0.114", "description": "Plugins for GenesisTools CLI development and management", "owner": { "name": "genesiscz", @@ -11,7 +11,7 @@ { "name": "genesis-tools", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", - "version": "1.0.113", + "version": "1.0.114", "author": { "name": "GenesisTools" }, diff --git a/docs/benchmarks-du.md b/docs/benchmarks-du.md index 667fc93b4b..eb8e58092f 100644 --- a/docs/benchmarks-du.md +++ b/docs/benchmarks-du.md @@ -1322,3 +1322,88 @@ to the 512 cap, and a scratch directory registers nothing outside itself. `bun s (with the two other touched suites) exited 0: 101 pass, 0 fail. The foreign check requires `foreign>=1`, `missing=0` and `leaked=0`. `src/du/native/bench.sh` was not run. The change is the mount list for root `/`, and a bench must not walk `/`. + +## 2026-10-22 18:57 — Cloud-backed content is measured by stat only by default (dataless files, File Provider domains) + +Opening a dataless file makes `fileproviderd` download it. Before this change the default scan pruned only +`~/Library/CloudStorage` and `~/Library/Mobile Documents` by name, so Desktop and Documents synced to iCloud (and a +scan rooted inside a provider folder) were walked with the normal open + `F_LOG2PHYS_EXT` path for every shared file. +`clonesize ~/Desktop --freeable` and the same for `~/Documents` each hit a 45 s timeout on this Mac. + +What changed in `clonesize.c`: + +- Every walk (scan, `--bigfiles`, `--index`) requests `ATTR_CMN_FLAGS` (bit 0x00040000, between MODTIME and FILEID), + so each entry layout gains a u32 at offset 52 and every later field moves by 4. The Bun engine's shim + (`cs_getattrbulk`) gains it at offset 36. +- A file is cloud-backed when it has `SF_DATALESS`, or when it sits under a File Provider domain root (the + `com.apple.file-provider-domain-id` xattr). Only the places a root can sit pay for a `getxattr`: children of a home + folder, of `.../Library/CloudStorage`, `.../Library/Mobile Documents`, and the scan root with its ancestors. +- Default: a cloud-backed file is counted from the listing (allocated size into `naive`, and in full into the unique + totals) and never opened. A dataless folder is counted and never entered. Each walk thread sets + `IOPOL_TYPE_VFS_MATERIALIZE_DATALESS_FILES` to off. `--include-cloud` lifts all of it. +- `--bigfiles` leaves dataless files out (`dataless_skipped`); `--index` flags a dataless folder `IDX_F_CLOUD` and + does not enter it; a new `--cloudfiles` mode lists the downloaded cloud files for `tools du downloaded`. + +Which totals move: only trees that hold cloud-backed content. There, clone sharing between cloud-backed files is no +longer deduped (they count at their full allocation, as `du` does), and `files_opened` drops to 0 for them. Every +total on a tree without cloud-backed content is unchanged. Checked old binary against new, back to back, at the same +moment (the repo is a live tree, so the T1 numbers of the two bench runs below differ between runs): + +| Target | old vs new | +|---|---| +| T1 (repo) `--json` | identical `files_listed` 331921, `files_opened` 115978, `naive` 10743083008, `unique` 8786771088, `unique_allocated` 9595559936, `shared` 1147523072 | +| T2 (node_modules) `--json` | identical, `naive` 1680945152, `unique` 1238311583 | +| T2 `--index --threads 8` | 784 rows identical once node ids (scheduling order) are removed | +| T1 `--bigfiles --min-bytes 1000000` | 1176 files, same paths, `dataless_skipped` 0 | + +`src/du/native/bench.sh` (system CPU is the metric; the load average was 20 to 88 throughout, so wall time is noise): + +| Mode | before (sys s) | after (sys s) | +|---|---|---| +| T1 flat | 33.48 | 32.98 | +| T1 depth2 | 30.44 | 30.31 | +| T2 flat | 7.91 | 9.30 | +| T2 depth2 | 7.08 | 6.66 | +| T1 cache-cold | 28.31 | 27.66 | +| T1 cache-warm | 17.86 | 17.51 | + +Load: before 35.00 at start, 88.66 at end; after 20.09 at start, 69.52 at end. Old against new in one hyperfine run +(load 60): `--index` T2 2.66 s against 2.39 s system, `--bigfiles` T1 6.95 s against 5.16 s. No regression shows +above the noise; the flags attribute is one more u32 per entry in the same kernel call. + +Cloud folders with the new binary (counts by lstat only: `find -x \( -flags +dataless -prune -type f -print \)`): + +| Folder | wall | opened | dataless files before / after | local KB before / after | +|---|---|---|---|---| +| ~/Desktop (iCloud) | 0.01 s | 0 | 889 / 889 | 3840 / 3840 | +| ~/Documents (iCloud) | 0.10 s | 0 | 8949 / 8949 | 159140 / 159140 | +| ~/Library/CloudStorage/Dropbox | 0.23 s | 0 | 52753 / 52753 | 0 / 0 | +| Tresorit Drive mount root | 0.00 s | 0 | 24 dataless folders, none entered / 24 | — | + +## 2026-10-22 19:27 — `tools du evict` without a Swift compiler + +No scan code changed: the C engine, the Bun engine and their totals are untouched, so no benchmark was rerun. +`evict` now picks its helper: the compiled `cloudctl` when it exists or `swiftc` can build it, otherwise +`native/cloudctl.js` through `osascript -l JavaScript`. Developer tools are detected with `xcode-select -p`, +because `/usr/bin/swiftc` exists on every Mac and opens the "install the tools" dialog when they are missing. + +Parity, status of every downloaded ~/Desktop file plus two control paths: 278 rows from each helper, 0 differing. +The JS helper must pass the keys as an `NSMutableArray`; a JS array bridges to an empty key list, and single-key +reads leave out the upload error that the batched Swift read returns. + +| Check | Swift helper | osascript helper (helper moved aside, fake `xcode-select` exiting 2) | +|---|---|---| +| `evict ~/Desktop --all --top 1000`, dry run | 45 eligible (180 KB), 231 refused | 45 eligible (180 KB), 231 refused (194 not uploaded, 37 not iCloud) | +| `evict --apply` on a probe file created in iCloud Drive | — | evicted: 176 blocks → 0, flags `compressed,dataless` | + +## 2026-10-22 21:52 — PR #558 review: the Bun planner counts dataless folders once, and `evict` has a deadline + +No hot-loop code changed: the C engine is untouched, and in the Bun engine only the planning walk (a few hundred +directory listings on the main thread) moved, so no benchmark was rerun. One total moves, in the Bun engine only: +`cloud_backed.dataless_dirs` goes down to the C engine's count. The planner counted a dataless folder when it listed +its parent, so a frontier leaf was counted again by the worker that recursed it, and once more on every later +planning round. It now counts only the dataless folders of interior directories, which no worker lists. + +The planning walk also runs with dataless materialization off, and a dataless scan root is never listed. The scan +then finds nothing, as the C engine's guarded open of that root does. `cloudctl` runs stop after 60 s plus 2 s per +path, and the call fails with that reason (an `evict` names that some files may already be evicted). diff --git a/plugins/genesis-tools/.claude-plugin/plugin.json b/plugins/genesis-tools/.claude-plugin/plugin.json index 5bb0e9df88..68d79199bf 100644 --- a/plugins/genesis-tools/.claude-plugin/plugin.json +++ b/plugins/genesis-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools", - "version": "1.0.113", + "version": "1.0.114", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools/skills/azure-devops/SKILL.md b/plugins/genesis-tools/skills/azure-devops/SKILL.md index b6f76a614a..bb7729bb28 100644 --- a/plugins/genesis-tools/skills/azure-devops/SKILL.md +++ b/plugins/genesis-tools/skills/azure-devops/SKILL.md @@ -19,7 +19,7 @@ az login --allow-no-subscriptions --use-device-code # if Conditional Access b tools azure-devops configure "https://dev.azure.com/MyOrg/MyProject/_workitems" ``` -`configure` takes any Azure DevOps URL of the project and writes `.claude/azure/config.json`. The tool finds that file from the current directory up to three parent levels. A missing login or config stops the command with a message that names the missing step. Add `-v` for debug logging. `tools azure-devops --readme` prints the full README. +`configure` takes any Azure DevOps URL of the project and writes `.claude/azure/config.json`. The tool finds that file from the current directory up to three parent levels. To use a config file from any folder, pass `--config ` (a global option, valid before or after the subcommand) or set `AZURE_DEVOPS_CONFIG=`. The order is `--config`, then `AZURE_DEVOPS_CONFIG`, then the search. An explicit path that is not a readable file stops the command with an error; it never falls back to the search. With an explicit config, the `tasks/` and `wiki/` folders live next to that file instead of in the working directory, and `configure` writes to that path. A missing login or config stops the command with a message that names the missing step. Add `-v` for debug logging. `tools azure-devops --readme` prints the full README. ## CLI Reference @@ -46,6 +46,7 @@ tools azure-devops comment add --file x.md # Post markdown (--text, --file tools azure-devops comment edit --file x.md # Replace a comment's text tools azure-devops comment delete --yes # --yes is required without a terminal (alias: rm) tools azure-devops workitem-create # Create work item (alias: create) +tools azure-devops workitem-update # Read multilineFieldsFormat; with --field, patch in one request (alias: update) tools azure-devops timelog configure # Interactive: setup API key, user, allowed types tools azure-devops timelog types # List available time types tools azure-devops timelog list -w # List time logs for work item (--day , --from/--to, --user @me) @@ -332,6 +333,23 @@ When user says "analyze workitem/task X" or "analyze tasks from query Y": | "Get task 12345 with screenshots" | `tools azure-devops workitem 12345 --task-folders --images` | | "Analyze bug with images" | Fetch with `--images` → Read images → Explore agent with visual context | +### Update fields + +`workitem-update` (alias `update`) reads one work item, including `multilineFieldsFormat`, and can update fields in a single JSON Patch. Without `--confirm` it prints the patch and writes nothing. Field names are the caller's; the command hard-codes none. + +```bash +tools azure-devops workitem-update 12345 --json +tools azure-devops workitem-update 12345 \ + --field System.Description=@description.html \ + --markdown System.Description \ + --image-root ./drafts \ + --reuse uploaded-images.json \ + --rev 6 \ + --confirm --json +``` + +`--field` repeats. A value that starts with `@` is a file path. `--markdown` repeats and switches that multiline field to Markdown when it is not already; the server cannot switch it back. `--reuse` is a JSON object `{ "": { "sha256": "", "url": "" } }`. A local `` whose file still has that sha256 keeps the URL and is not uploaded again. The patch always starts with `test /rev` (the live rev, or `--rev` when it still matches). After `--confirm`, the read-back treats these server rewrites as the same text: an emoji stored as `🔗`, a quoted integer attribute stored without quotes (`width="375"` as `width=375`; `width="48%"` keeps the quotes), and an image `src` replaced by its attachment URL. `--json` prints the result. A differing read-back exits 1. + ## Creating Work Items The `workitem-create` command (alias `create`) supports multiple modes for creating new work items. diff --git a/plugins/genesis-tools/skills/browser-router/SKILL.md b/plugins/genesis-tools/skills/browser-router/SKILL.md index b600720024..3fe893a658 100644 --- a/plugins/genesis-tools/skills/browser-router/SKILL.md +++ b/plugins/genesis-tools/skills/browser-router/SKILL.md @@ -58,7 +58,7 @@ A link carries only the session id, the number and one letter. Anything else doe ## Run Claude in a new cmux surface -`tools cmux launch --prompt ` prints the quoted command for a new cmux surface. With `--open` it opens the surface (`--surface new|split|workspace`) and runs the command there. `--account` defaults to the default Claude account. The cmux-claude preset passes `--open`. A raw link that starts an agent always asks; only a link minted on this machine (`handoff_post` adds one as `paste.runLink`) runs without the card, once. +`tools cmux launch --prompt ` prints the quoted command for a new cmux surface. With `--open` it opens the surface (`--surface new|split|workspace`) and runs the command there. `--account` defaults to the default Claude account; `--account auto` runs `tools claude run auto`, which picks the account with the most headroom when the surface starts. A handoff's run link is minted with `account=auto`. The cmux-claude preset passes `--open`. A raw link that starts an agent always asks; only a link minted on this machine (`handoff_post` adds one as `paste.runLink`) runs without the card, once. ```bash tools browser-router route 'http://127.0.0.1:8787/add/:id' --run --arg add --arg '{id}' --arg --qty --arg '{qty}' --notify 'Added' --approval allow diff --git a/src/ai/commands/usage/poll-daemon.test.ts b/src/ai/commands/usage/poll-daemon.test.ts index 53afe5e562..949a0a292a 100644 --- a/src/ai/commands/usage/poll-daemon.test.ts +++ b/src/ai/commands/usage/poll-daemon.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { AccountUsageSnapshot } from "@genesiscz/utils/ai/usage-poll/types"; -import { anthropicRows, notifiableWindows } from "./poll-daemon"; +import { anthropicRows, notifiableWindows, rowsOfAnswer, sessionRowsArgv } from "./poll-daemon"; /** * The daemon fans one poll round out to three consumers with different rules: threshold @@ -86,3 +86,41 @@ describe("anthropicRows", () => { expect(rows.filter((r) => !r.stale)).toEqual([]); }); }); + +describe("sessionRowsArgv", () => { + test("the child and the hub server get the same cache-safe command, providers included", () => { + expect(sessionRowsArgv({ hours: 24, minRows: 10 })).toEqual([ + "ai", + "usage", + "sessions", + "--json", + "--fresh", + "--no-cache-write", + "--journal-only", + "--hours", + "24", + "--min", + "10", + ]); + expect(sessionRowsArgv({ providers: ["claude", "codex"], limit: 5 })).toEqual([ + "ai", + "usage", + "sessions", + "--json", + "--fresh", + "--no-cache-write", + "--journal-only", + "--provider", + "claude", + "codex", + "--limit", + "5", + ]); + }); + + test("rowsOfAnswer reads the rows and refuses an answer without them", () => { + expect(rowsOfAnswer(`{"rows":[{"sessionId":"a"}],"fetchedAt":1}`)?.map((row) => row.sessionId)).toEqual(["a"]); + expect(rowsOfAnswer(`{"error":"busy"}`)).toBeNull(); + expect(() => rowsOfAnswer("not json")).toThrow(); + }); +}); diff --git a/src/ai/commands/usage/poll-daemon.ts b/src/ai/commands/usage/poll-daemon.ts index eb171c7d49..f3c4ce4e3f 100644 --- a/src/ai/commands/usage/poll-daemon.ts +++ b/src/ai/commands/usage/poll-daemon.ts @@ -10,7 +10,7 @@ import { NotificationManager } from "@genesiscz/utils/ai/usage-poll/notification import { pollAccounts } from "@genesiscz/utils/ai/usage-poll/poll"; import { usagePollStorage } from "@genesiscz/utils/ai/usage-poll/storage"; import type { AccountUsageSnapshot } from "@genesiscz/utils/ai/usage-poll/types"; -import { withTimeout } from "@genesiscz/utils/async"; +import { execTool } from "@genesiscz/utils/cli"; import { recordRunOnExit } from "@genesiscz/utils/cli/run-record"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { SafeJSON } from "@genesiscz/utils/json"; @@ -75,6 +75,34 @@ export function anthropicRows(snapshots: readonly AccountUsageSnapshot[]) { const prof = profiler.scope("ai-usage"); +/** The `usage sessions` argv that recomputes `query`. `--journal-only`: these rows go into the cache file, which must + * never hold a live tmux tab. */ +export function sessionRowsArgv(query: AgentSessionRowsOptions): string[] { + const argv = ["ai", "usage", "sessions", "--json", "--fresh", "--no-cache-write", "--journal-only"]; + if (query.providers && query.providers.length > 0) { + argv.push("--provider", ...query.providers); + } + + for (const [flag, value] of [ + ["--hours", query.hours], + ["--min", query.minRows], + ["--limit", query.limit], + ] as const) { + if (value !== undefined) { + argv.push(flag, String(value)); + } + } + + return argv; +} + +/** The rows of a `usage sessions --json` answer, or null when it holds none. */ +export function rowsOfAnswer(stdout: string): AgentSessionRow[] | null { + const parsed: unknown = SafeJSON.parse(stdout, { strict: true }); + const rows = isObject(parsed) ? parsed.rows : null; + return Array.isArray(rows) ? rows : null; +} + /** * Genesis.app asks `usage sessions --json --hours 24 --min 10` every 35 s, and a fresh answer costs about 1.1 s of * CPU (1,130 ms median on 2026-10-12, `scripts/benchmarks/agent-sessions/session-listing.ts`): process start, every @@ -86,12 +114,16 @@ const prof = profiler.scope("ai-usage"); * 60 s timeout SIGTERMed the tick 20 times that day, losing its "completed" line. A walk cut short leaves the * previous file for the next tick. The budget counts from process start, because the runner's clock starts at * spawn and on 2026-10-12 the first line of `main` reached it 5-38 s after spawn. Never throws. + * + * The walk runs in a child, never in this process: its SQLite reads are synchronous, so on a loaded disk they held + * the event loop past the budget timer, and the runner SIGTERMed the tick 9 times on 2026-10-21 (twice the SIGTERM + * handler could not run either, and it took a SIGKILL). A child is killed at the budget, whatever it is doing. */ async function refreshSessionRows(): Promise { - const budgetMs = TICK_BUDGET_MS - (Date.now() - performance.timeOrigin); + const remainingMs = () => TICK_BUDGET_MS - (Date.now() - performance.timeOrigin); + const budgetMs = remainingMs(); try { - const { listAgentSessionRows } = await import("@app/ai/lib/sessions/agent-session-rows"); const { refreshSessionRowsCache } = await import("@app/ai/lib/sessions/rows-cache"); const { callHubServer } = await import("@app/hub/server/client"); // The resident hub server answers with warm caches (directory walk, metadata, rows); this process @@ -101,20 +133,11 @@ async function refreshSessionRows(): Promise { return null; } - // `--journal-only`: these rows go into the cache file, which must never hold a live tmux tab. - const argv = ["ai", "usage", "sessions", "--json", "--fresh", "--no-cache-write", "--journal-only"]; - for (const [flag, value] of [ - ["--hours", query.hours], - ["--min", query.minRows], - ["--limit", query.limit], - ] as const) { - if (value !== undefined) { - argv.push(flag, String(value)); - } - } - // Short: a stalled server must not eat the budget the local walk needs (a cold walk is ~2 s CPU). - const answer = await callHubServer({ argv, timeoutMs: Math.max(1000, Math.min(5_000, budgetMs / 4)) }); + const answer = await callHubServer({ + argv: sessionRowsArgv(query), + timeoutMs: Math.max(1000, Math.min(5_000, budgetMs / 4)), + }); if (!answer) { // This tick builds the rows itself. A server that is not running is started for the next tick; // the hub window is not needed for that (238 of 644 refreshes on 2026-10-12 found no server). @@ -128,24 +151,49 @@ async function refreshSessionRows(): Promise { } try { - const parsed: unknown = SafeJSON.parse(answer.stdout, { strict: true }); - const rows = isObject(parsed) ? parsed.rows : null; - return Array.isArray(rows) ? rows : null; + return rowsOfAnswer(answer.stdout); } catch (err) { logger.debug({ err }, "[ai-usage] hub server rows unreadable; computing locally"); return null; } }; - const outcome = await withTimeout( - prof.measureAsync( + let cut = false; + const local = async (query: AgentSessionRowsOptions): Promise => { + const timeout = remainingMs(); + const child = timeout > 0 ? await execTool(sessionRowsArgv(query), { timeout }) : null; + if (!child || child.timedOut) { + cut = true; + throw new Error(`session rows walk passed the tick's budget (${Math.round(budgetMs / 1000)} s left)`); + } + + const rows = child.success ? rowsOfAnswer(child.stdout) : null; + if (!rows) { + throw new Error(`usage sessions exited ${child.exitCode} without rows: ${child.stderr.slice(-300)}`); + } + + return rows; + }; + + try { + const outcome = await prof.measureAsync( "tick.session-rows", - () => refreshSessionRowsCache(listAgentSessionRows, { remote }), + () => refreshSessionRowsCache(local, { remote }), cpuMeta() - ), - Math.max(0, budgetMs), - new Error(`session rows walk passed the tick's budget (${Math.round(budgetMs / 1000)} s left)`) - ); - logger.info(outcome, "[ai-usage] session rows cache"); + ); + logger.info(outcome, "[ai-usage] session rows cache"); + } catch (err) { + if (!cut) { + throw err; + } + + // The designed outcome on a loaded machine, not a fault: in all 7 cuts on 2026-10-21 every read was slow + // (the Claude projects walk 8 to 25 s for 3,600 files, the history catalog 30 to 40 s), while 1,317 + // finished walks that day took under 1 s each. + logger.info( + { budgetMs: Math.round(budgetMs) }, + "[ai-usage] session rows walk cut at the tick's budget; the previous rows stay" + ); + } } catch (err) { logger.warn({ err }, "[ai-usage] session rows cache refresh failed"); } diff --git a/src/azure-devops/README.md b/src/azure-devops/README.md index 8c07ef2e64..99425718e9 100644 --- a/src/azure-devops/README.md +++ b/src/azure-devops/README.md @@ -400,6 +400,7 @@ tools azure-devops workitem-create --type Bug --title "Error in checkout" --seve | `dashboard ` | Extract queries from a dashboard | | `list` (alias `ls`) | List all cached work items | | `workitem-create` (alias `create`) | Create new work items (interactive or from template) | +| `workitem-update` (alias `update`) | Read a work item's field formats, or update fields in one JSON Patch (`--field`, `--markdown`, `--confirm`) | | `iterations` | List the project's sprints (alias `sprints`) | | `sprint` | List the work items of one sprint | | `history show`, `history sync` | One item's history; bulk history sync for cached items (`--since`, `--batch`) | @@ -529,6 +530,13 @@ Configuration is saved to `.claude/azure/config.json` in your project directory. **Config Search**: The tool searches for `.claude/azure/config.json` starting from the current directory, then up to 3 parent levels. This allows running the tool from subdirectories. +**Explicit config**: To run from a folder with no config above it, pass the global option `--config ` or set `AZURE_DEVOPS_CONFIG=`. The order is `--config`, then `AZURE_DEVOPS_CONFIG`, then the search. A path that is missing, is a folder, or cannot be read is an error; the tool never falls back to the search. `tasks/` and `wiki/` are then created next to the config file, and `configure` writes the config to that path. + +```bash +tools azure-devops --config /path/to/project/.claude/azure/config.json workitem 12345 +AZURE_DEVOPS_CONFIG=/path/to/project/.claude/azure/config.json tools azure-devops workitem 12345 +``` + ## Output Formats ### AI Format (Default) diff --git a/src/azure-devops/api.ts b/src/azure-devops/api.ts index 01da7b6d03..800702f188 100644 --- a/src/azure-devops/api.ts +++ b/src/azure-devops/api.ts @@ -17,8 +17,11 @@ import type { IterationClassificationNode, QueryNode, SavedQueryApi, + TeamCapacitiesResponse, + TeamDaysOffResponse, TeamIteration, TeamIterationsResponse, + TeamMemberCapacityApi, TeamMembersResponse, TeamsListResponse, WikiListResponse, @@ -49,10 +52,12 @@ import type { WiqlResponse, WorkItem, WorkItemFull, + WorkItemLive, WorkItemType, WorkItemTypeDefinition, WorkItemUpdate, } from "@app/azure-devops/types"; +import { wiqlTimePrecisionParam } from "@app/azure-devops/wiql-builder"; import { concurrentMap } from "@genesiscz/utils/async"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; @@ -292,31 +297,46 @@ export class Api { private async send( method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", url: string, - options: { body?: unknown; contentType?: string; description?: string } = {} + options: { body?: unknown; rawBody?: Uint8Array; contentType?: string; description?: string } = {} ): Promise { - const { body, contentType = "application/json", description } = options; + const { body, rawBody, contentType = "application/json", description } = options; const shortUrl = url.replace(this.config.org, "").slice(0, 80); logger.debug(`[api] ${method} ${shortUrl}${description ? ` (${description})` : ""}`); + if (body !== undefined) { const bodyStr = SafeJSON.stringify(body); const truncated = bodyStr.length > 500 ? `${bodyStr.slice(0, 500)}... (${bodyStr.length} chars)` : bodyStr; logger.debug(`[api] ${method} body: ${truncated}`); + } else if (rawBody !== undefined) { + logger.debug(`[api] ${method} body: ${rawBody.byteLength} bytes`); } + const startTime = Date.now(); const token = await this.getAccessToken(); const headers: Record = { Authorization: `Bearer ${token}`, }; - if (body !== undefined) { + + if (body !== undefined || rawBody !== undefined) { headers["Content-Type"] = contentType; } + let payload: BodyInit | undefined; + + if (rawBody !== undefined) { + const copy = new ArrayBuffer(rawBody.byteLength); + new Uint8Array(copy).set(rawBody); + payload = new Blob([copy]); + } else if (body !== undefined) { + payload = SafeJSON.stringify(body); + } + const response = await fetch(url, { method, headers, - body: body !== undefined ? SafeJSON.stringify(body) : undefined, + body: payload, }); const elapsed = Date.now() - startTime; @@ -1076,6 +1096,37 @@ export class Api { }; } + /** One work item, including `multilineFieldsFormat`, which the batch read does not ask for. */ + async getWorkItemLive(id: number): Promise { + const url = Api.witUrl(this.config, ["workitems", String(id)], { $expand: "relations" }); + const item = await this.get(url, `work item #${id}`); + + return { + id: item.id, + rev: item.rev, + fields: item.fields ?? {}, + multilineFieldsFormat: item.multilineFieldsFormat ?? {}, + relations: item.relations, + }; + } + + /** Upload bytes as a work-item attachment. The returned URL is what an `` stores. */ + async uploadAttachment(fileName: string, bytes: Uint8Array): Promise { + const url = Api.witUrl(this.config, "attachments", { fileName }); + const response = await this.send("POST", url, { + rawBody: bytes, + contentType: "application/octet-stream", + description: `attachment ${fileName}`, + }); + const created = SafeJSON.parse(await response.text()) as { url?: string }; + + if (!created?.url) { + throw new Error(`attachment upload of ${fileName} returned no url`); + } + + return created.url; + } + /** * Get the definition of a work item type including fields, states, and transitions */ @@ -1178,8 +1229,16 @@ export class Api { * Pass `team` to execute in a team context, which is required by macros such * as `@CurrentIteration`. */ - async runWiql(wiql: string, options?: { top?: number; team?: string }): Promise { - const queryParams = { $top: options?.top ? String(options.top) : undefined }; + async runWiql( + wiql: string, + options?: { top?: number; team?: string; timePrecision?: boolean } + ): Promise { + // A clock in a date literal is rejected unless the request opts into time precision. + // Date-only queries keep the default, so a bare day still means that whole day. + const queryParams = { + $top: options?.top ? String(options.top) : undefined, + timePrecision: wiqlTimePrecisionParam(wiql, options?.timePrecision), + }; const url = options?.team ? Api.teamApiUrl(this.config, options.team, ["wit", "wiql"], queryParams) : Api.witUrl(this.config, "wiql", queryParams); @@ -1198,6 +1257,42 @@ export class Api { return data.value ?? []; } + /** Every team of the project, up to 500. */ + async getTeams(): Promise { + const url = Api.orgUrl(this.config, ["projects", this.config.projectId, "teams"], { $top: "500" }); + const data = await this.get(url, "teams"); + + return data.value ?? []; + } + + /** Capacity of every member of `team` in one iteration, per activity, with the member's days off. */ + async getTeamCapacities(team: string, iterationId: string): Promise { + const url = Api.teamApiUrl(this.config, team, [ + "work", + "teamsettings", + "iterations", + iterationId, + "capacities", + ]); + const data = await this.get(url, `capacity of team "${team}"`); + + return data.teamMembers ?? data.value ?? []; + } + + /** The days off the whole team shares in one iteration. */ + async getTeamDaysOff(team: string, iterationId: string): Promise> { + const url = Api.teamApiUrl(this.config, team, [ + "work", + "teamsettings", + "iterations", + iterationId, + "teamdaysoff", + ]); + const data = await this.get(url, `team days off of "${team}"`); + + return data.daysOff ?? []; + } + /** * How far `classificationnodes` is asked to walk. Deep enough for * `Project > Year > Release > Sprint` style hierarchies with headroom; diff --git a/src/azure-devops/api.types.ts b/src/azure-devops/api.types.ts index 6cdfed4e38..a1531c401d 100644 --- a/src/azure-devops/api.types.ts +++ b/src/azure-devops/api.types.ts @@ -119,6 +119,23 @@ export interface TeamMembersResponse { value: Array<{ identity: IdentityRef }>; } +/** One team member as `work/teamsettings/iterations/{id}/capacities` returns it (7.0+ `teamMembers`, older `value`). */ +export interface TeamMemberCapacityApi { + teamMember: { displayName: string; uniqueName?: string; id?: string }; + activities?: Array<{ name?: string | null; capacityPerDay?: number }>; + daysOff?: Array<{ start: string; end: string }>; +} + +export interface TeamCapacitiesResponse { + teamMembers?: TeamMemberCapacityApi[]; + value?: TeamMemberCapacityApi[]; +} + +/** Raw response from `work/teamsettings/iterations/{id}/teamdaysoff`. */ +export interface TeamDaysOffResponse { + daysOff?: Array<{ start: string; end: string }>; +} + /** Raw response from projects list */ export interface ProjectsListResponse { value: Array<{ id: string; name: string }>; diff --git a/src/azure-devops/commands/config-option.ts b/src/azure-devops/commands/config-option.ts new file mode 100644 index 0000000000..c55046bdde --- /dev/null +++ b/src/azure-devops/commands/config-option.ts @@ -0,0 +1,15 @@ +import { setConfigFlag } from "@app/azure-devops/config"; +import { CONFIG_ENV_VAR, CONFIG_FLAG } from "@app/azure-devops/lib/config-path"; +import type { Command } from "commander"; + +/** The global --config option. Its value is kept for the run, before the action of any command. */ +export function registerConfigOption(program: Command): void { + program + .option( + `${CONFIG_FLAG} `, + `Path of the Azure DevOps config.json (env: ${CONFIG_ENV_VAR}). Default: search for .claude/azure/config.json from the working directory.` + ) + .hook("preAction", (command) => { + setConfigFlag(command.opts<{ config?: string }>().config); + }); +} diff --git a/src/azure-devops/commands/configure.ts b/src/azure-devops/commands/configure.ts index a7b8ae640e..4eed5816c6 100644 --- a/src/azure-devops/commands/configure.ts +++ b/src/azure-devops/commands/configure.ts @@ -5,10 +5,11 @@ * and project configuration from any Azure DevOps URL. */ +import { basename } from "node:path"; import { exitWithAuthGuide } from "@app/azure-devops/cli.utils"; import { azDevopsDefaults, buildAdoConfig, saveAdoConfig } from "@app/azure-devops/lib/ado-configure"; import { azNotOnPathMessage, isAzOnPath } from "@app/azure-devops/lib/az-cli.utils"; -import { getLocalConfigDir } from "@app/azure-devops/utils"; +import { getExplicitConfigPath, getLocalConfigDir } from "@app/azure-devops/utils"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger, out } from "@genesiscz/utils/logger"; @@ -62,7 +63,8 @@ async function handleConfigure(url: string): Promise { // Report what was SAVED, not what the URL carried. A same-project reconfigure // keeps the team and the timelog block, so printing newConfig said // "Team: (none)" while the file on disk still had one (PR #333 review t7). - const saved = saveAdoConfig(newConfig, configDir); + const explicitConfig = getExplicitConfigPath(); + const saved = saveAdoConfig(newConfig, configDir, explicitConfig ? basename(explicitConfig) : undefined); if (saved.config.team) { const kept = newConfig.team ? "" : " (kept from the existing config)"; diff --git a/src/azure-devops/commands/history-activity.ts b/src/azure-devops/commands/history-activity.ts index 57bbf37d86..c9df8313fc 100644 --- a/src/azure-devops/commands/history-activity.ts +++ b/src/azure-devops/commands/history-activity.ts @@ -18,9 +18,10 @@ import { } from "@app/azure-devops/cache"; import { buildWorkItemHistory, userMatches } from "@app/azure-devops/history"; import { + formatWindowBound, groupEventsByDay, + historyCoversChange, isInvertedWindow, - localDay, parseDayBoundary, realDayCount, resolveUpdateDate, @@ -226,8 +227,13 @@ async function scanCachedActivity( // ============= Discovery ============= -/** Discover work items changed by user but not in cache, sync their history + comments */ -async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDate?: Date): Promise { +/** Discover work items the user changed in the window and sync any history the cache does not already cover. */ +async function discoverAndSync( + api: Api, + userName: string, + fromDate?: Date, + toDate?: Date +): Promise<{ syncedIds: number[]; changedCount: number }> { // Find existing cached IDs const cacheFiles = await storage.listCacheFiles(false); const cachedIds = new Set(); @@ -243,10 +249,11 @@ async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDa const userValue = isMeMacro ? "@Me" : `'${escapeWiqlValue(userName)}'`; let wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.ChangedBy] = ${userValue}`; - // The bounds carry their time, for the reason `findMentions` names: a bare `YYYY-MM-DD` means - // MIDNIGHT to WIQL, so `<= ''` dropped everything changed on the --to day. The day part - // alone was also the wrong day, because these bounds are local moments and `toISOString()` - // reports them in UTC. + // The bounds carry their time. A bare `YYYY-MM-DD` means midnight to WIQL, so `<= ''` + // dropped everything changed on the --to day, and the day part alone was also the wrong day: + // these bounds are local moments and `toISOString()` reports them in UTC. The clock is legal + // only with time precision, which `runWiql` turns on for a literal that contains one. Date + // precision rejects it: "You cannot supply a time with the date". if (fromDate) { wiql += ` AND [System.ChangedDate] >= '${fromDate.toISOString()}'`; } @@ -257,22 +264,31 @@ async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDa const response = await api.runWiql(wiql, { top: 500 }); const serverIds = response.workItems.map((wi) => wi.id); + const changedDates: Map> = serverIds.length > 0 + ? await api.getWorkItemFields(serverIds, ["System.ChangedDate"]) + : new Map(); - // Find items that need history sync (not cached OR no history section) + // A history section only means we synced this item once. WIQL already named it as changed + // inside the window, so skip it only when the cached updates reach its current ChangedDate. const needSync: number[] = []; for (const id of serverIds) { + const changed = changedDates.get(id)?.["System.ChangedDate"]; + const changedDate = typeof changed === "string" ? changed : undefined; + if (!cachedIds.has(id)) { needSync.push(id); continue; } + const cached = await loadWorkItemCache(id); - if (!cached?.history) { + + if (!historyCoversChange(cached?.history?.updates, changedDate)) { needSync.push(id); } } if (needSync.length === 0) { - return []; + return { syncedIds: [], changedCount: serverIds.length }; } // Sync history for discovered items @@ -299,7 +315,7 @@ async function discoverAndSync(api: Api, userName: string, fromDate?: Date, toDa // comments" and `isCommentsFresh` suppressed the retry for seven days. `batchGetComments` logs // each failure; leaving the section unwritten is what lets the next run try again. - return needSync; + return { syncedIds: needSync, changedCount: serverIds.length }; } // ============= Fetch Missing Comments ============= @@ -488,7 +504,10 @@ export async function handleHistoryActivity(options: ActivityOptions): Promise 0 - ? `Discovered and synced ${newIds.length} new work items` - : "No new work items to discover" + discovered.syncedIds.length > 0 + ? `Synced history for ${discovered.syncedIds.length} work items` + : discovered.changedCount > 0 + ? `${discovered.changedCount} changed work items already cached` + : "No work items changed in this window" ); } diff --git a/src/azure-devops/commands/index.ts b/src/azure-devops/commands/index.ts index 4288e8a805..53e0f60059 100644 --- a/src/azure-devops/commands/index.ts +++ b/src/azure-devops/commands/index.ts @@ -10,3 +10,4 @@ export { registerWikiCommand } from "./wiki"; export { registerWorkitemCommand } from "./workitem"; export { registerWorkitemCacheCommand } from "./workitem-cache"; export { registerWorkitemCreateCommand } from "./workitem-create"; +export { registerWorkItemUpdateCommand } from "./workitem-update"; diff --git a/src/azure-devops/commands/workitem-update.ts b/src/azure-devops/commands/workitem-update.ts new file mode 100644 index 0000000000..1d9ef5a567 --- /dev/null +++ b/src/azure-devops/commands/workitem-update.ts @@ -0,0 +1,178 @@ +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; + +import { Api } from "@app/azure-devops/api"; +import { formatJSON } from "@app/azure-devops/cache"; +import { handleCliError } from "@app/azure-devops/cli.utils"; +import { resolveWorkItemId } from "@app/azure-devops/lib/comments"; +import { + executeWorkItemUpdate, + localImageSources, + parseFieldSpec, + parseReuseFile, + sha256Hex, + type WorkItemUpdateImage, + type WorkItemUpdateResult, +} from "@app/azure-devops/lib/work-item-update"; +import { requireConfig } from "@app/azure-devops/utils"; +import { out } from "@genesiscz/utils/logger"; +import type { Command } from "commander"; + +function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +function resolveImages(values: string[], imageRoot: string): WorkItemUpdateImage[] { + const sources = [...new Set(values.flatMap((value) => localImageSources(value)))]; + const images: WorkItemUpdateImage[] = []; + + for (const src of sources) { + const relative = decodeURI(src); + const path = isAbsolute(relative) ? relative : join(imageRoot, relative); + + if (!existsSync(path)) { + throw new Error(`local image not found: ${path}`); + } + + const bytes = new Uint8Array(readFileSync(path)); + + images.push({ src, name: src.slice(src.lastIndexOf("/") + 1), sha256: sha256Hex(bytes), bytes }); + } + + return images; +} + +function printResult(result: WorkItemUpdateResult, asJson: boolean): void { + if (!result.ok) { + if (asJson) { + out.println(formatJSON(result)); + } else { + out.error(result.error); + + for (const mismatch of result.mismatches ?? []) { + out.error(`${mismatch.field}: sent and stored differ`); + } + } + + process.exitCode = 1; + return; + } + + if (asJson) { + out.println(formatJSON(result)); + return; + } + + if (result.dryRun) { + out.println(`dry run, rev ${result.plan.rev}, nothing sent`); + out.println(formatJSON(result.plan.operations)); + + for (const upload of result.plan.uploads) { + out.println(`would upload ${upload.src}`); + } + + return; + } + + out.println(`updated work item to rev ${result.rev}`); +} + +export function registerWorkItemUpdateCommand(program: Command): void { + program + .command("workitem-update") + .alias("update") + .description( + "Read a work item, including multilineFieldsFormat, and update fields in one JSON Patch. Without --confirm, print the patch and send nothing." + ) + .argument("", "Work item id or URL") + .option( + "--field ", + "Set this field. Repeat for each field. A value that starts with @ is a file path (Name=@./text.html).", + collect, + [] + ) + .option( + "--markdown ", + "Switch this multiline field to Markdown when it is not already. Repeat for each field. The name is the caller's; Markdown cannot be switched back.", + collect, + [] + ) + .option( + "--rev ", + "Refuse the update unless the work item is still at this rev. The patch tests /rev either way." + ) + .option("--image-root ", "Directory that relative paths are resolved against.", process.cwd()) + .option( + "--reuse ", + 'JSON { "": { "sha256": "", "url": "" } }. A local image with the same sha256 is not uploaded again.' + ) + .option("--confirm", "Send the patch. Without it, the command only prints what it would send.") + .option("--json", "Print the live work item, the dry-run plan, or the verify result as JSON.") + .action( + async ( + workitem: string, + options: { + field: string[]; + markdown: string[]; + rev?: string; + imageRoot: string; + reuse?: string; + confirm?: boolean; + json?: boolean; + } + ) => { + try { + const config = requireConfig(); + const api = new Api(config); + const id = resolveWorkItemId(workitem, config.org); + const asJson = options.json === true; + + if (options.field.length === 0 && options.markdown.length === 0) { + const item = await api.getWorkItemLive(id); + out.println(asJson ? formatJSON(item) : `work item ${item.id} rev ${item.rev}`); + + if (!asJson) { + out.println(formatJSON(item.multilineFieldsFormat)); + } + + return; + } + + const rev = options.rev === undefined ? undefined : Number(options.rev); + + if (rev !== undefined && !Number.isInteger(rev)) { + throw new Error(`--rev must be an integer, got '${options.rev}'`); + } + + const fields = options.field.map((spec) => + parseFieldSpec(spec, (path) => readFileSync(path, "utf8")) + ); + const reuse = options.reuse ? parseReuseFile(readFileSync(options.reuse, "utf8")) : new Map(); + const images = resolveImages( + fields.map((field) => field.value), + options.imageRoot + ); + const result = await executeWorkItemUpdate( + { + read: (itemId) => api.getWorkItemLive(itemId), + upload: (fileName, bytes) => api.uploadAttachment(fileName, bytes), + update: (itemId, operations) => + api.updateWorkItem(itemId, operations).then(() => undefined), + }, + { + id, + rev, + fields, + markdownFields: options.markdown, + images, + reuse, + confirm: options.confirm === true, + } + ); + printResult(result, asJson); + } catch (error) { + handleCliError(error); + } + } + ); +} diff --git a/src/azure-devops/config.ts b/src/azure-devops/config.ts index e75ec7a0a7..1352980526 100644 --- a/src/azure-devops/config.ts +++ b/src/azure-devops/config.ts @@ -2,8 +2,17 @@ * Azure DevOps CLI - Configuration loading and management */ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { readFileSync } from "node:fs"; +import { + azureDataDir, + CONFIG_ENV_VAR, + CONFIG_FLAG, + ConfigPathError, + type ConfigPathInput, + configPathSourceLabel, + explicitConfigPath, + resolveConfigPath, +} from "@app/azure-devops/lib/config-path"; import type { AzureConfig, AzureConfigWithTimeLog, TimeLogUser } from "@app/azure-devops/types"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { env } from "@genesiscz/utils/env"; @@ -15,37 +24,41 @@ export function getRelativeTime(date: Date): string { return formatRelativeTime(date, { rounding: "round" }); } -/** - * Search for config file starting from cwd, up to 3 parent levels - */ -export function findConfigPath(): string | null { - const configName = ".claude/azure/config.json"; - let currentDir = resolve(env.paths.getClarityProjectCwd()); +let configFlag: string | undefined; - for (let i = 0; i < 4; i++) { - // current + 3 levels up - const configPath = join(currentDir, configName); - - if (existsSync(configPath)) { - return configPath; - } +/** Remember the value of the global --config option for the rest of the run. */ +export function setConfigFlag(value: string | undefined): void { + configFlag = value; +} - const parentDir = dirname(currentDir); +function activeConfigInput(): ConfigPathInput { + return { + flag: configFlag, + envValue: env.paths.getAzureDevopsConfig(), + searchFrom: env.paths.getClarityProjectCwd(), + cwd: process.cwd(), + }; +} - if (parentDir === currentDir) { - break; // reached root - } - currentDir = parentDir; - } +/** + * The config file: --config, then AZURE_DEVOPS_CONFIG, then a search from cwd up to 3 parent levels. + * Throws a ConfigPathError when an explicit path is not a readable file. + */ +export function findConfigPath(): string | null { + return resolveConfigPath(activeConfigInput())?.path ?? null; +} - return null; +/** The path --config or AZURE_DEVOPS_CONFIG names, whether or not the file exists yet. */ +export function getExplicitConfigPath(): string | null { + return explicitConfigPath(activeConfigInput())?.path ?? null; } /** - * Get the config directory for the current project (in cwd) + * Get the config directory: the folder of an explicit config, otherwise `.claude/azure` in cwd. + * The tasks and wiki folders live in it. */ export function getLocalConfigDir(): string { - return join(process.cwd(), ".claude/azure"); + return azureDataDir(activeConfigInput()); } /** @@ -72,16 +85,37 @@ export function extractOrgName(orgUrl: string): string | null { * Load config from file or return null if not found. */ export function loadConfig(): AzureConfig | null { - const configPath = findConfigPath(); + const found = resolveConfigPath(activeConfigInput()); - if (!configPath) { + if (!found) { return null; } try { - return SafeJSON.parse(readFileSync(configPath, "utf-8")); - } catch { - return null; + return SafeJSON.parse(readFileSync(found.path, "utf-8")); + } catch (error) { + if (found.source === "search") { + return null; + } + + const reason = error instanceof Error ? error.message : String(error); + + throw new ConfigPathError( + `Azure DevOps config cannot be parsed: ${found.path} (from ${configPathSourceLabel(found.source)}): ${reason}` + ); + } +} + +function loadConfigOrExit(): AzureConfig | null { + try { + return loadConfig(); + } catch (error) { + if (error instanceof ConfigPathError) { + out.error(`\n❌ ${error.message}\n`); + process.exit(1); + } + + throw error; } } @@ -89,7 +123,7 @@ export function loadConfig(): AzureConfig | null { * Require config or exit with helpful error */ export function requireConfig(): AzureConfig { - const config = loadConfig(); + const config = loadConfigOrExit(); if (!config) { out.error(` @@ -101,6 +135,7 @@ Run configure with any Azure DevOps URL from your project: ${toolCommand("azure-devops configure")} "https://myorg.visualstudio.com/MyProject/_queries/query/..." This will create .claude/azure/config.json in the current directory. +To use a config file elsewhere, pass ${CONFIG_FLAG} or set ${CONFIG_ENV_VAR}. `); process.exit(1); } @@ -112,7 +147,7 @@ This will create .claude/azure/config.json in the current directory. * Load config with TimeLog settings or exit with helpful error */ export function requireTimeLogConfig(): AzureConfigWithTimeLog { - const config = loadConfig() as AzureConfigWithTimeLog | null; + const config = loadConfigOrExit() as AzureConfigWithTimeLog | null; if (!config) { out.error(` diff --git a/src/azure-devops/index.ts b/src/azure-devops/index.ts index 000e5505f3..4e54f1afff 100644 --- a/src/azure-devops/index.ts +++ b/src/azure-devops/index.ts @@ -12,6 +12,7 @@ * tools azure-devops dashboard [options] * tools azure-devops list * tools azure-devops workitem-create [options] + * tools azure-devops workitem-update|update [--field Name=value] [--markdown Field] [--confirm] * tools azure-devops timelog [options] * tools azure-devops history [options] * tools azure-devops iterations [options] @@ -39,6 +40,7 @@ handleReadmeFlag(import.meta.url); // Import command registration functions import { registerAncestorsCommand } from "@app/azure-devops/commands/ancestors"; import { registerCommentCommand } from "@app/azure-devops/commands/comment"; +import { registerConfigOption } from "@app/azure-devops/commands/config-option"; import { registerConfigureCommand } from "@app/azure-devops/commands/configure"; import { registerDashboardCommand } from "@app/azure-devops/commands/dashboard"; import { registerHistoryCommand } from "@app/azure-devops/commands/history"; @@ -50,6 +52,7 @@ import { registerWikiCommand } from "@app/azure-devops/commands/wiki"; import { handleWorkItem, registerWorkitemCommand } from "@app/azure-devops/commands/workitem"; import { registerWorkitemCacheCommand } from "@app/azure-devops/commands/workitem-cache"; import { registerWorkitemCreateCommand } from "@app/azure-devops/commands/workitem-create"; +import { registerWorkItemUpdateCommand } from "@app/azure-devops/commands/workitem-update"; import { runTool } from "@genesiscz/utils/cli"; // Wire up cross-command dependencies @@ -71,6 +74,8 @@ program process.exit(0); }); +registerConfigOption(program); + // Register all commands registerConfigureCommand(program); registerQueryCommand(program); @@ -78,6 +83,7 @@ registerWorkitemCommand(program); registerAncestorsCommand(program); registerTreeCommand(program); registerWorkitemCreateCommand(program); +registerWorkItemUpdateCommand(program); registerWorkitemCacheCommand(program); registerDashboardCommand(program); registerTimelogCommand(program); @@ -102,6 +108,7 @@ Commands: dashboard Fetch dashboard and list its queries list List cached work items (alias: ls) workitem-create Create a new work item, interactive or from template (alias: create) + workitem-update Read a work item's field formats, or update fields in one JSON Patch (alias: update) timelog Manage time log entries (add, list, delete, types, import, configure, prepare-import, export-month) history Work item history commands (show, search, sync, activity, mentions) @@ -111,6 +118,7 @@ Commands: comment Work item comments: list, add, edit, delete Global Options: + --config Config file to use (or set AZURE_DEVOPS_CONFIG); wins over the search from the working directory --team Optional team; narrows team-scoped lists (overrides config.team) -v, --verbose Enable verbose debug logging --readme Print this tool's README and exit @@ -160,6 +168,15 @@ Workitem-Create Options: --assignee Assignee email --parent Parent work item ID +Workitem-Update Options: + --field Set a field; repeat per field (Name=@file reads the value from a file) + --markdown Switch a multiline field to Markdown; repeat per field + --rev Refuse the update unless the work item is still at this rev + --image-root Directory that relative paths resolve against + --reuse JSON of already uploaded images; an image with the same sha256 is not uploaded again + --confirm Send the patch (without it, print the patch and send nothing) + --json Print the live work item, the dry-run plan, or the verify result as JSON + Timelog Subcommands: timelog add Add a time log entry (--date , default today) timelog list List time logs (--workitem, --day, --from/--to, --user) @@ -258,10 +275,10 @@ History Commands: ${toolCommand("azure-devops history mentions")} Comments that named a user in a date window (--user, --from, --to) Storage: - Config: .claude/azure/config.json (per-project, searched up to 3 levels) + Config: --config , then $AZURE_DEVOPS_CONFIG, then .claude/azure/config.json (per-project, searched up to 3 levels) Cache: ~/.genesis-tools/azure-devops/cache/ (global; queries 180 days, work items 365 days with a 5-minute freshness window, history and comments 7 days) - Tasks: .claude/azure/tasks/ (per-project, in cwd) + Tasks: .claude/azure/tasks/ (per-project, in cwd; next to the config file when --config or AZURE_DEVOPS_CONFIG is set) Documentation: https://learn.microsoft.com/en-us/azure/devops/cli/?view=azure-devops `); diff --git a/src/azure-devops/lib/activity-days.test.ts b/src/azure-devops/lib/activity-days.test.ts index a1453b45f5..3815ab1e2c 100644 --- a/src/azure-devops/lib/activity-days.test.ts +++ b/src/azure-devops/lib/activity-days.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { + formatWindowBound, groupEventsByDay, + historyCoversChange, isInvertedWindow, isSentinelDate, localDay, @@ -25,6 +27,38 @@ function update(overrides: Partial): WorkItemUpdate { }; } +describe("historyCoversChange", () => { + test("an empty cache does not cover a change", () => { + expect(historyCoversChange(undefined, "2026-10-15T02:12:15.000Z")).toBe(false); + expect(historyCoversChange([], "2026-10-15T02:12:15.000Z")).toBe(false); + }); + + test("a missing ChangedDate is not proof the cache is current", () => { + expect(historyCoversChange([update({})], undefined)).toBe(false); + }); + + test("covers the change when the newest update is that ChangedDate", () => { + const updates = [ + update({ + revisedDate: "9999-01-01T00:00:00Z", + fields: { "System.ChangedDate": { oldValue: "", newValue: "2026-10-15T02:12:15.000Z" } }, + }), + ]; + + expect(historyCoversChange(updates, "2026-10-15T02:12:15.000Z")).toBe(true); + }); + + test("does not cover a later edit than the newest cached update", () => { + const updates = [ + update({ + fields: { "System.ChangedDate": { oldValue: "", newValue: "2026-10-14T08:00:00.000Z" } }, + }), + ]; + + expect(historyCoversChange(updates, "2026-10-15T02:12:15.000Z")).toBe(false); + }); +}); + describe("resolveUpdateDate", () => { test("uses revisedDate only when the update carries no changed date", () => { expect(resolveUpdateDate(update({}))).toBe("2026-03-04T09:10:11Z"); @@ -265,6 +299,23 @@ describe("localDay", () => { expect(localDay(parseDayBoundary("2026-01-02"))).toBe("2026-01-02"); }); + test("a clock stays on the status line, in local time", () => { + withTz("Europe/Prague", () => { + expect(formatWindowBound(parseDayBoundary("2026-10-15T02:12:15.000Z"))).toBe("2026-10-15 04:12"); + }); + + withTz("America/Chicago", () => { + expect(formatWindowBound(parseDayBoundary("2026-10-15T02:12:15.000Z"))).toBe("2026-10-14 21:12"); + }); + }); + + test("midnight and end of day stay a calendar day", () => { + withTz("Europe/Prague", () => { + expect(formatWindowBound(parseDayBoundary("2026-10-15"))).toBe("2026-10-15"); + expect(formatWindowBound(parseDayBoundary("2026-10-15", { endOfDay: true }))).toBe("2026-10-15"); + }); + }); + test("covers a day either side of a UTC boundary, which is where the old form broke", () => { withTz("Europe/Prague", () => { expect(localDay(parseDayBoundary("2026-12-31"))).toBe("2026-12-31"); diff --git a/src/azure-devops/lib/activity-days.ts b/src/azure-devops/lib/activity-days.ts index 4e53e718b3..7ab5e21d03 100644 --- a/src/azure-devops/lib/activity-days.ts +++ b/src/azure-devops/lib/activity-days.ts @@ -38,6 +38,31 @@ export function resolveUpdateDate(update: WorkItemUpdate): string { return ""; } +/** + * Whether these updates already contain the work item's current ChangedDate. + * A cached history section is not proof of that: discovery used to skip any item that had one, + * so a later edit in the requested window never reached the scan. + */ +export function historyCoversChange(updates: WorkItemUpdate[] | undefined, changedDate: string | undefined): boolean { + const changedAt = changedDate ? new Date(changedDate).getTime() : Number.NaN; + + if (!updates || updates.length === 0 || Number.isNaN(changedAt)) { + return false; + } + + let latest = Number.NEGATIVE_INFINITY; + + for (const update of updates) { + const at = new Date(resolveUpdateDate(update)).getTime(); + + if (!Number.isNaN(at) && at > latest) { + latest = at; + } + } + + return Number.isFinite(latest) && latest >= changedAt; +} + /** * One end of a `--from` / `--to` window, as a moment. * @@ -131,6 +156,31 @@ export function localDay(date: Date): string { return `${date.getFullYear()}-${month}-${day}`; } +/** + * A window bound for the status line. Midnight and end-of-day stay a calendar day. + * A bound that carries a clock shows that clock in local time, so an hour ago + * does not read as the whole day. + */ +export function formatWindowBound(date: Date): string { + const day = localDay(date); + const startOfDay = + date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0 && date.getMilliseconds() === 0; + const endOfDay = + date.getHours() === 23 && + date.getMinutes() === 59 && + date.getSeconds() === 59 && + date.getMilliseconds() === 999; + + if (startOfDay || endOfDay) { + return day; + } + + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + + return `${day} ${hours}:${minutes}`; +} + export interface DatedEvent { date: string; } diff --git a/src/azure-devops/lib/ado-configure.ts b/src/azure-devops/lib/ado-configure.ts index 0219d3fc73..8247063a4e 100644 --- a/src/azure-devops/lib/ado-configure.ts +++ b/src/azure-devops/lib/ado-configure.ts @@ -69,12 +69,12 @@ export interface SavedAdoConfig { config: AzureConfigWithTimeLog; } -export function saveAdoConfig(config: AzureConfig, configDir: string): SavedAdoConfig { +export function saveAdoConfig(config: AzureConfig, configDir: string, fileName = "config.json"): SavedAdoConfig { if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } - const configPath = join(configDir, "config.json"); + const configPath = join(configDir, fileName); const existing = readExistingConfig(configPath); // Supplemental settings are only meaningful within one project. `team` names diff --git a/src/azure-devops/lib/config-path.test.ts b/src/azure-devops/lib/config-path.test.ts new file mode 100644 index 0000000000..24f01d2131 --- /dev/null +++ b/src/azure-devops/lib/config-path.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { registerConfigOption } from "@app/azure-devops/commands/config-option"; +import { + findConfigPath, + getExplicitConfigPath, + getLocalConfigDir, + loadConfig, + requireConfig, + setConfigFlag, +} from "@app/azure-devops/config"; +import { + azureDataDir, + ConfigPathError, + explicitConfigPath, + resolveConfigPath, + searchConfigPath, +} from "@app/azure-devops/lib/config-path"; +import { getTasksDir } from "@app/azure-devops/task-files"; +import { env } from "@genesiscz/utils/env"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { out } from "@genesiscz/utils/logger"; +import { Command } from "commander"; + +const CONFIG_JSON = SafeJSON.stringify({ org: "https://dev.azure.com/contoso", project: "Widgets" }); + +function tempDir(): string { + return mkdtempSync(join(tmpdir(), "ado-config-path-")); +} + +function writeConfig(root: string, name = "config.json"): string { + const dir = join(root, ".claude/azure"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, name); + writeFileSync(path, CONFIG_JSON); + + return path; +} + +describe("resolveConfigPath", () => { + test("--config wins over the environment variable and over a config the search would find", () => { + const root = tempDir(); + const searched = writeConfig(join(root, "project")); + const flagged = writeConfig(join(root, "flagged")); + const fromEnv = writeConfig(join(root, "env")); + + expect( + resolveConfigPath({ flag: flagged, envValue: fromEnv, searchFrom: dirname(dirname(searched)), cwd: root }) + ).toEqual({ path: flagged, source: "flag" }); + }); + + test("the environment variable wins over the search", () => { + const root = tempDir(); + const searched = writeConfig(join(root, "project")); + const fromEnv = writeConfig(join(root, "env")); + + expect(resolveConfigPath({ envValue: fromEnv, searchFrom: dirname(dirname(searched)), cwd: root })).toEqual({ + path: fromEnv, + source: "env", + }); + }); + + test("a blank flag or variable counts as not set, so the search runs", () => { + const root = tempDir(); + const searched = writeConfig(root); + + expect(resolveConfigPath({ flag: " ", envValue: "", searchFrom: root, cwd: root })).toEqual({ + path: searched, + source: "search", + }); + }); + + test("a relative explicit path resolves against the working directory", () => { + const root = tempDir(); + const path = writeConfig(root, "other.json"); + + expect(resolveConfigPath({ flag: ".claude/azure/other.json", searchFrom: tempDir(), cwd: root })).toEqual({ + path, + source: "flag", + }); + }); + + test("a missing explicit path is an error that names its source, even when the search would find a config", () => { + const root = tempDir(); + writeConfig(root); + const missing = join(root, "nowhere", "config.json"); + + expect(() => resolveConfigPath({ flag: missing, searchFrom: root, cwd: root })).toThrow(ConfigPathError); + expect(() => resolveConfigPath({ flag: missing, searchFrom: root, cwd: root })).toThrow(/--config/); + expect(() => resolveConfigPath({ envValue: missing, searchFrom: root, cwd: root })).toThrow( + /AZURE_DEVOPS_CONFIG/ + ); + expect(() => resolveConfigPath({ flag: missing, searchFrom: root, cwd: root })).toThrow(missing); + }); + + test("a folder is an error, not a config", () => { + const root = tempDir(); + + expect(() => resolveConfigPath({ flag: root, searchFrom: root, cwd: root })).toThrow(/not a file/); + }); + + test.skipIf(process.getuid?.() === 0)("an unreadable file is an error", () => { + const root = tempDir(); + const path = writeConfig(root); + chmodSync(path, 0o000); + + try { + expect(() => resolveConfigPath({ flag: path, searchFrom: root, cwd: root })).toThrow(/cannot be read/); + } finally { + chmodSync(path, 0o600); + } + }); + + test("the search still works when neither is given: this folder and three levels up, not four", () => { + const root = tempDir(); + const config = writeConfig(root); + const level3 = join(root, "a", "b", "c"); + const level4 = join(level3, "d"); + mkdirSync(level4, { recursive: true }); + + expect(resolveConfigPath({ searchFrom: root, cwd: root })).toEqual({ path: config, source: "search" }); + expect(searchConfigPath(level3)).toBe(config); + expect(searchConfigPath(level4)).toBeNull(); + expect(resolveConfigPath({ searchFrom: tempDir(), cwd: root })).toBeNull(); + }); +}); + +describe("azureDataDir", () => { + test("is the folder of an explicit config, otherwise .claude/azure in the working directory", () => { + expect(azureDataDir({ flag: "/srv/ado/config.json", cwd: "/work" })).toBe("/srv/ado"); + expect(azureDataDir({ envValue: "/srv/env/config.json", cwd: "/work" })).toBe("/srv/env"); + expect(azureDataDir({ cwd: "/work" })).toBe(join("/work", ".claude/azure")); + expect(explicitConfigPath({ cwd: "/work" })).toBeNull(); + }); +}); + +describe("the active config", () => { + let savedConfig: string | undefined; + let savedCwd: string | undefined; + + beforeEach(() => { + savedConfig = process.env.AZURE_DEVOPS_CONFIG; + savedCwd = process.env.CLARITY_PROJECT_CWD; + env.testing.unset("AZURE_DEVOPS_CONFIG"); + setConfigFlag(undefined); + }); + + afterEach(() => { + setConfigFlag(undefined); + + for (const [key, value] of [ + ["AZURE_DEVOPS_CONFIG", savedConfig], + ["CLARITY_PROJECT_CWD", savedCwd], + ] as const) { + if (value === undefined) { + env.testing.unset(key); + } else { + env.testing.set(key, value); + } + } + }); + + test("the flag, then the variable, then the search choose the file", () => { + const root = tempDir(); + const searched = writeConfig(join(root, "project")); + const fromEnv = writeConfig(join(root, "env")); + const flagged = writeConfig(join(root, "flagged")); + env.testing.set("CLARITY_PROJECT_CWD", join(root, "project")); + + expect(findConfigPath()).toBe(searched); + + env.testing.set("AZURE_DEVOPS_CONFIG", fromEnv); + expect(findConfigPath()).toBe(fromEnv); + + setConfigFlag(flagged); + expect(findConfigPath()).toBe(flagged); + expect(loadConfig()?.project).toBe("Widgets"); + }); + + test("tasks and the config folder follow an explicit config", () => { + const root = tempDir(); + const flagged = writeConfig(join(root, "flagged")); + + expect(getLocalConfigDir()).toBe(join(process.cwd(), ".claude/azure")); + expect(getExplicitConfigPath()).toBeNull(); + + setConfigFlag(flagged); + + expect(getLocalConfigDir()).toBe(dirname(flagged)); + expect(getTasksDir()).toBe(join(dirname(flagged), "tasks")); + expect(getTasksDir("hotfixes")).toBe(join(dirname(flagged), "tasks", "hotfixes")); + expect(getExplicitConfigPath()).toBe(flagged); + }); + + test("an explicit file that does not parse is an error, not a missing config", () => { + const root = tempDir(); + const path = join(root, "broken.json"); + writeFileSync(path, "{ not json"); + setConfigFlag(path); + + expect(() => loadConfig()).toThrow(/cannot be parsed/); + }); + + test("requireConfig stops with a clear message when the explicit path is missing", () => { + const root = tempDir(); + env.testing.set("CLARITY_PROJECT_CWD", root); + writeConfig(root); + setConfigFlag(join(root, "gone.json")); + const printed = spyOn(out, "error").mockImplementation(() => undefined); + const exited = spyOn(process, "exit").mockImplementation((code?: number | string | null) => { + throw new Error(`exit ${code}`); + }); + + try { + expect(() => requireConfig()).toThrow("exit 1"); + expect(String(printed.mock.calls[0]?.[0])).toContain("gone.json"); + } finally { + printed.mockRestore(); + exited.mockRestore(); + } + }); +}); + +describe("registerConfigOption", () => { + afterEach(() => { + setConfigFlag(undefined); + }); + + test("keeps --config before and after the subcommand, and clears it on a run without it", async () => { + const program = new Command().exitOverride(); + registerConfigOption(program); + program.command("sub").action(() => undefined); + + await program.parseAsync(["--config", "/srv/one/config.json", "sub"], { from: "user" }); + expect(getExplicitConfigPath()).toBe("/srv/one/config.json"); + + const again = new Command().exitOverride(); + registerConfigOption(again); + again.command("sub").action(() => undefined); + await again.parseAsync(["sub", "--config", "/srv/two/config.json"], { from: "user" }); + expect(getExplicitConfigPath()).toBe("/srv/two/config.json"); + + const plain = new Command().exitOverride(); + registerConfigOption(plain); + plain.command("sub").action(() => undefined); + await plain.parseAsync(["sub"], { from: "user" }); + expect(getExplicitConfigPath()).toBeNull(); + }); + + test("--help describes the option and the variable", () => { + const program = new Command(); + registerConfigOption(program); + const option = program.options.find((item) => item.long === "--config"); + + expect(option?.description).toContain("AZURE_DEVOPS_CONFIG"); + }); +}); diff --git a/src/azure-devops/lib/config-path.ts b/src/azure-devops/lib/config-path.ts new file mode 100644 index 0000000000..1472ab69f8 --- /dev/null +++ b/src/azure-devops/lib/config-path.ts @@ -0,0 +1,148 @@ +import { accessSync, constants, existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +export const AZURE_DIR = ".claude/azure"; +export const CONFIG_FLAG = "--config"; +export const CONFIG_ENV_VAR = "AZURE_DEVOPS_CONFIG"; + +const CONFIG_FILE = `${AZURE_DIR}/config.json`; +const SEARCH_LEVELS = 4; + +export type ConfigPathSource = "flag" | "env" | "search"; + +export interface ConfigPathHit { + path: string; + source: ConfigPathSource; +} + +export interface ConfigPathInput { + /** The value of --config. */ + flag?: string; + /** The value of AZURE_DEVOPS_CONFIG. */ + envValue?: string; + /** Where the upward search starts. */ + searchFrom: string; + /** What a relative explicit path is resolved against. */ + cwd: string; +} + +const SOURCE_LABEL: Record = { + flag: CONFIG_FLAG, + env: CONFIG_ENV_VAR, + search: "the upward search", +}; + +export class ConfigPathError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigPathError"; + } +} + +export function configPathSourceLabel(source: ConfigPathSource): string { + return SOURCE_LABEL[source]; +} + +/** The path --config names, then the one AZURE_DEVOPS_CONFIG names. The file is not checked. */ +export function explicitConfigPath(input: Pick): ConfigPathHit | null { + const flag = input.flag?.trim(); + + if (flag) { + return { path: resolve(input.cwd, flag), source: "flag" }; + } + + const fromEnv = input.envValue?.trim(); + + if (fromEnv) { + return { path: resolve(input.cwd, fromEnv), source: "env" }; + } + + return null; +} + +/** `.claude/azure/config.json` in startDir or in one of the three folders above it. */ +export function searchConfigPath(startDir: string): string | null { + let currentDir = resolve(startDir); + + for (let level = 0; level < SEARCH_LEVELS; level++) { + const configPath = join(currentDir, CONFIG_FILE); + + if (existsSync(configPath)) { + return configPath; + } + + const parentDir = dirname(currentDir); + + if (parentDir === currentDir) { + break; + } + + currentDir = parentDir; + } + + return null; +} + +/** + * --config wins, then AZURE_DEVOPS_CONFIG, then the upward search. An explicit path that is not a readable + * file throws a ConfigPathError: it never falls back to the search. + */ +export function resolveConfigPath(input: ConfigPathInput): ConfigPathHit | null { + const explicit = explicitConfigPath(input); + + if (explicit) { + assertReadableFile(explicit); + + return explicit; + } + + const found = searchConfigPath(input.searchFrom); + + return found ? { path: found, source: "search" } : null; +} + +/** The folder that holds tasks/ and wiki/: next to an explicit config, otherwise `.claude/azure` in cwd. */ +export function azureDataDir(input: Pick): string { + const explicit = explicitConfigPath(input); + + return explicit ? dirname(explicit.path) : join(input.cwd, AZURE_DIR); +} + +function errorCode(error: unknown): string | undefined { + if (error instanceof Error && "code" in error && typeof error.code === "string") { + return error.code; + } + + return undefined; +} + +function assertReadableFile(hit: ConfigPathHit): void { + const origin = `${hit.path} (from ${configPathSourceLabel(hit.source)})`; + let isFile: boolean; + + try { + isFile = statSync(hit.path).isFile(); + } catch (error) { + const code = errorCode(error); + + if (code === "ENOENT" || code === "ENOTDIR") { + throw new ConfigPathError( + `Azure DevOps config not found: ${origin}. Pass the path of an existing config.json, or leave the option out to search from the working directory.` + ); + } + + throw new ConfigPathError(`Azure DevOps config cannot be read: ${origin}${code ? ` (${code})` : ""}.`); + } + + if (!isFile) { + throw new ConfigPathError(`Azure DevOps config is not a file: ${origin}. Pass the config.json file itself.`); + } + + try { + accessSync(hit.path, constants.R_OK); + } catch (error) { + const code = errorCode(error); + + throw new ConfigPathError(`Azure DevOps config cannot be read: ${origin}${code ? ` (${code})` : ""}.`); + } +} diff --git a/src/azure-devops/lib/work-item-update.test.ts b/src/azure-devops/lib/work-item-update.test.ts new file mode 100644 index 0000000000..85cd4a7a5c --- /dev/null +++ b/src/azure-devops/lib/work-item-update.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, test } from "bun:test"; +import { registerWorkItemUpdateCommand } from "@app/azure-devops/commands/workitem-update"; +import { + executeWorkItemUpdate, + imageReuseMap, + parseFieldSpec, + parseReuseFile, + sameStoredText, + sha256Hex, + type WorkItemUpdateClient, + type WorkItemUpdateImage, + workItemPatch, +} from "@app/azure-devops/lib/work-item-update"; +import type { JsonPatchOperation, WorkItemLive } from "@app/azure-devops/types"; +import { Command } from "commander"; + +const ATTACHMENT = "https://example.invalid/org/_apis/wit/attachments/abc?fileName=a.png"; + +function live(overrides: Partial = {}): WorkItemLive { + return { + id: 100, + rev: 6, + fields: { "System.Description": "old" }, + multilineFieldsFormat: { "System.Description": "html" }, + ...overrides, + }; +} + +function image(src: string, bytes: Uint8Array, sha256 = sha256Hex(bytes)): WorkItemUpdateImage { + return { src, name: src.slice(src.lastIndexOf("/") + 1), sha256, bytes }; +} + +function client( + item: WorkItemLive, + uploadUrl = ATTACHMENT +): WorkItemUpdateClient & { uploads: string[]; operations: JsonPatchOperation[][] } { + const state = { ...item, fields: { ...item.fields }, multilineFieldsFormat: { ...item.multilineFieldsFormat } }; + const uploads: string[] = []; + const operations: JsonPatchOperation[][] = []; + + return { + uploads, + operations, + async read() { + return { + id: state.id, + rev: state.rev, + fields: { ...state.fields }, + multilineFieldsFormat: { ...state.multilineFieldsFormat }, + }; + }, + async upload(fileName) { + uploads.push(fileName); + return uploadUrl; + }, + async update(_id, ops) { + operations.push(ops); + state.rev += 1; + + for (const op of ops) { + if (op.op === "add" && op.path.startsWith("/fields/") && typeof op.value === "string") { + state.fields[op.path.slice("/fields/".length)] = op.value; + } + + if (op.op === "add" && op.path.startsWith("/multilineFieldsFormat/") && typeof op.value === "string") { + state.multilineFieldsFormat[op.path.slice("/multilineFieldsFormat/".length)] = op.value; + } + } + }, + }; +} + +describe("image reuse", () => { + const bytes = new TextEncoder().encode("png-bytes"); + const sha256 = sha256Hex(bytes); + + test("reuses the attachment URL when the file sha256 is unchanged, and uploads when it changed", () => { + const uploaded = new Map([["a.png", { sha256, url: ATTACHMENT }]]); + const same = image("shots/a.png", bytes, sha256); + const changed = image("shots/a.png", bytes, "f".repeat(64)); + + expect(imageReuseMap([same.src], new Map([[same.src, same.sha256]]), uploaded).get(same.src)).toBe(ATTACHMENT); + expect(imageReuseMap([changed.src], new Map([[changed.src, changed.sha256]]), uploaded).size).toBe(0); + }); + + test("a confirmed update does not upload a file whose sha256 is already uploaded", async () => { + const shot = image("shots/a.png", bytes); + const ado = client(live()); + const result = await executeWorkItemUpdate(ado, { + id: 100, + fields: [{ name: "System.Description", value: `` }], + markdownFields: [], + images: [shot], + reuse: new Map([[shot.name, { sha256: shot.sha256, url: ATTACHMENT }]]), + confirm: true, + }); + + expect(ado.uploads).toEqual([]); + expect(result.ok).toBe(true); + expect( + ado.operations[0]?.some( + (op) => op.path === "/fields/System.Description" && op.value === `` + ) + ).toBe(true); + }); +}); + +describe("the rev test and the format switch", () => { + test("the patch tests /rev and switches only the named fields that are not already Markdown", () => { + const operations = workItemPatch({ + rev: 6, + fields: [ + { name: "System.Description", value: "next" }, + { name: "System.History", value: "note" }, + ], + markdownFields: ["System.Description", "System.History"], + liveFormats: { "System.History": "markdown" }, + }); + + expect(operations[0]).toEqual({ op: "test", path: "/rev", value: 6 }); + expect(operations).toContainEqual({ + op: "add", + path: "/multilineFieldsFormat/System.Description", + value: "Markdown", + }); + expect(operations.some((op) => op.path === "/multilineFieldsFormat/System.History")).toBe(false); + expect(operations.filter((op) => op.path.startsWith("/fields/"))).toHaveLength(2); + }); + + test("a stale --rev sends nothing", async () => { + const ado = client(live()); + const result = await executeWorkItemUpdate(ado, { + id: 100, + rev: 5, + fields: [{ name: "System.Description", value: "next" }], + markdownFields: ["System.Description"], + images: [], + reuse: new Map(), + confirm: true, + }); + + expect(result).toEqual({ ok: false, error: "work item 100 is at rev 6, not 5" }); + expect(ado.operations).toEqual([]); + expect(ado.uploads).toEqual([]); + }); + + test("confirm sends the rev test and the format switch, then accepts the read-back", async () => { + const ado = client(live()); + const result = await executeWorkItemUpdate(ado, { + id: 100, + fields: [{ name: "System.Description", value: "next" }], + markdownFields: ["System.Description"], + images: [], + reuse: new Map(), + confirm: true, + }); + + expect(result.ok).toBe(true); + + if (result.ok && !result.dryRun) { + expect(result.rev).toBe(7); + } + + expect(ado.operations[0]?.[0]).toEqual({ op: "test", path: "/rev", value: 6 }); + expect(ado.operations[0]).toContainEqual({ + op: "add", + path: "/multilineFieldsFormat/System.Description", + value: "Markdown", + }); + }); +}); + +describe("read-back rewrites", () => { + const same = (stored: string, sent: string, images?: ReadonlyMap) => + sameStoredText({ stored, sent, format: "markdown", images }); + + test("an emoji stored as a character reference matches the emoji that was sent", () => { + expect(same("see 🔗 this", "see 🔗 this")).toBe(true); + expect(same("see 🔗 this", "see 🔗 this")).toBe(true); + }); + + test("a quoted integer attribute may lose its quotes, and a percent value keeps them", () => { + expect(same('', "")).toBe(true); + expect(same('', '')).toBe(true); + expect(same('', "")).toBe(false); + }); + + test("a local image src matches the attachment URL it was replaced with", () => { + const images = new Map([["shots/a.png", ATTACHMENT]]); + + expect(same(``, '', images)).toBe(true); + expect(same(``, '')).toBe(false); + }); +}); + +describe("command input", () => { + test("a @ value is read from the file, and --reuse is a name to sha256 and url", () => { + expect(parseFieldSpec("System.Description=@draft.html", (path) => `from ${path}`)).toEqual({ + name: "System.Description", + value: "from draft.html", + }); + expect( + parseReuseFile(`{ "a.png": { "sha256": "${"a".repeat(64)}", "url": "${ATTACHMENT}" } }`).get("a.png") + ).toEqual({ + sha256: "a".repeat(64), + url: ATTACHMENT, + }); + }); + + test("--help names every flag", () => { + const program = new Command(); + registerWorkItemUpdateCommand(program); + const command = program.commands.find((item) => item.name() === "workitem-update"); + const flags = command?.options.map((option) => option.long) ?? []; + + expect(flags).toEqual( + expect.arrayContaining(["--field", "--markdown", "--rev", "--image-root", "--reuse", "--confirm", "--json"]) + ); + expect(command?.description().length).toBeGreaterThan(0); + expect(command?.aliases()).toEqual(["update"]); + + for (const option of command?.options ?? []) { + expect(option.description.length, option.long).toBeGreaterThan(0); + } + }); +}); + +describe("the read-back of typed fields", () => { + function typedClient(stored: Record): WorkItemUpdateClient { + let rev = 6; + return { + async read() { + return { id: 100, rev, fields: rev > 6 ? stored : {}, multilineFieldsFormat: {} }; + }, + async upload() { + return ATTACHMENT; + }, + async update() { + rev += 1; + }, + }; + } + + const update = (ado: WorkItemUpdateClient, name: string, value: string) => + executeWorkItemUpdate(ado, { + id: 100, + fields: [{ name, value }], + markdownFields: [], + images: [], + reuse: new Map(), + confirm: true, + }); + + test("a number, a boolean and an identity the server stores in its own type match what was sent", async () => { + const identity = { displayName: "Alice Example", uniqueName: "alice@example.com" }; + const ado = typedClient({ + "Microsoft.VSTS.Scheduling.RemainingWork": 5, + "Custom.Blocked": false, + "System.AssignedTo": identity, + }); + + expect((await update(ado, "Microsoft.VSTS.Scheduling.RemainingWork", "5")).ok).toBe(true); + expect((await update(ado, "Custom.Blocked", "False")).ok).toBe(true); + expect((await update(ado, "System.AssignedTo", "alice@example.com")).ok).toBe(true); + expect((await update(ado, "System.AssignedTo", "Alice Example ")).ok).toBe(true); + }); + + test("a different stored number is still a mismatch, reported as text", async () => { + const result = await update( + typedClient({ "Microsoft.VSTS.Scheduling.RemainingWork": 4 }), + "Microsoft.VSTS.Scheduling.RemainingWork", + "5" + ); + + expect(result).toMatchObject({ + ok: false, + mismatches: [{ field: "Microsoft.VSTS.Scheduling.RemainingWork", sent: "5", stored: "4" }], + }); + }); +}); + +describe("what the read-back may forgive", () => { + test("a plain field matches exactly: a semicolon or a space the server dropped is a mismatch", async () => { + const ado = client(live({ fields: { "System.Title": "old" }, multilineFieldsFormat: {} })); + ado.update = async () => {}; + const read = ado.read; + ado.read = async (id) => ({ ...(await read(id)), rev: 7, fields: { "System.Title": "ab" } }); + const result = await executeWorkItemUpdate(ado, { + id: 100, + fields: [{ name: "System.Title", value: "a;b" }], + markdownFields: [], + images: [], + reuse: new Map(), + confirm: true, + }); + + expect(result).toMatchObject({ ok: false, mismatches: [{ field: "System.Title", sent: "a;b", stored: "ab" }] }); + expect(sameStoredText({ stored: "a b", sent: "a b", format: "plain" })).toBe(false); + }); + + test("Markdown keeps semicolons, paragraph breaks and code indentation", () => { + const md = (stored: string, sent: string) => sameStoredText({ stored, sent, format: "markdown" }); + + expect(md("ab", "a;b")).toBe(false); + expect(md("one two", "one\n\ntwo")).toBe(false); + expect(md("```\ncode\n```", "```\n code\n```")).toBe(false); + expect(md("one\r\n\r\ntwo \n", "one\n\ntwo")).toBe(true); + }); + + test("HTML forgives insignificant space and the semicolon the server adds to a style", () => { + const html = (stored: string, sent: string) => sameStoredText({ stored, sent, format: "html" }); + + expect(html('

\n a\n

', '

a

')).toBe( + true + ); + expect(html("

a;b

", "

ab

")).toBe(false); + }); + + test("--markdown without --field still checks that the field became Markdown", async () => { + const ado = client(live({ fields: { "System.Description": "body", "System.Title": "t" } })); + ado.update = async () => {}; + const read = ado.read; + ado.read = async (id) => ({ ...(await read(id)), rev: 7 }); + const formatOnly = await executeWorkItemUpdate(ado, { + id: 100, + fields: [], + markdownFields: ["System.Description"], + images: [], + reuse: new Map(), + confirm: true, + }); + const otherField = await executeWorkItemUpdate(ado, { + id: 100, + fields: [{ name: "System.Title", value: "t" }], + markdownFields: ["System.Description"], + images: [], + reuse: new Map(), + confirm: true, + }); + + for (const result of [formatOnly, otherField]) { + expect(result).toMatchObject({ + ok: false, + mismatches: [{ field: "System.Description", sent: "Markdown", stored: "html" }], + }); + } + }); +}); diff --git a/src/azure-devops/lib/work-item-update.ts b/src/azure-devops/lib/work-item-update.ts new file mode 100644 index 0000000000..58c0b4c9ac --- /dev/null +++ b/src/azure-devops/lib/work-item-update.ts @@ -0,0 +1,396 @@ +import { createHash } from "node:crypto"; +import { basename } from "node:path"; + +import type { JsonPatchOperation, WorkItemLive } from "@app/azure-devops/types"; +import { SafeJSON } from "@genesiscz/utils/json"; + +/** An image already stored on the work item. Keyed by the file's base name. */ +export interface UploadedImage { + sha256: string; + url: string; +} + +export interface WorkItemUpdateImage { + src: string; + name: string; + sha256: string; + bytes: Uint8Array; +} + +export interface WorkItemUpdateClient { + read(id: number): Promise; + upload(fileName: string, bytes: Uint8Array): Promise; + update(id: number, operations: JsonPatchOperation[]): Promise; +} + +export interface WorkItemUpdateInput { + id: number; + /** When set, the update is refused unless the work item is still at this rev. */ + rev?: number; + fields: { name: string; value: string }[]; + /** Multiline fields the caller wants stored as Markdown. Names come from the caller. */ + markdownFields: string[]; + images: WorkItemUpdateImage[]; + reuse: ReadonlyMap; + confirm: boolean; +} + +export interface WorkItemUpdatePlan { + rev: number; + operations: JsonPatchOperation[]; + uploads: { src: string; name: string; sha256: string }[]; + reused: { src: string; url: string }[]; +} + +export type WorkItemUpdateResult = + | { ok: true; dryRun: true; plan: WorkItemUpdatePlan } + | { ok: true; dryRun: false; rev: number; images: Record; plan: WorkItemUpdatePlan } + | { ok: false; error: string; mismatches?: { field: string; sent: string; stored: string }[] }; + +/** Local `` values. `http`, `https` and `data` sources are already remote. */ +export function localImageSources(html: string): string[] { + const sources = [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map((match) => match[1] ?? ""); + + return [...new Set(sources.filter((src) => src !== "" && !/^(https?:|data:)/.test(src)))]; +} + +export function replaceImageSources(html: string, urls: ReadonlyMap): string { + let out = html; + + for (const [src, url] of urls) { + out = out.replaceAll(`src="${src}"`, `src="${url}"`); + } + + return out; +} + +export function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** Sources whose file still matches a previous upload, mapped to that attachment URL. */ +export function imageReuseMap( + sources: readonly string[], + files: ReadonlyMap, + uploaded: ReadonlyMap +): Map { + const urls = new Map(); + + for (const src of sources) { + const known = uploaded.get(basename(src)); + const sha256 = files.get(src); + + if (known && sha256 && known.sha256 === sha256 && known.url !== "") { + urls.set(src, known.url); + } + } + + return urls; +} + +/** + * One patch: refuse the write when the rev moved, switch the named multiline fields to Markdown, + * then set the fields. A field that is already Markdown is left alone. + */ +export function workItemPatch(input: { + rev: number; + fields: { name: string; value: string }[]; + markdownFields: readonly string[]; + liveFormats: Readonly>; +}): JsonPatchOperation[] { + const operations: JsonPatchOperation[] = [{ op: "test", path: "/rev", value: input.rev }]; + + for (const field of input.markdownFields) { + if ((input.liveFormats[field] ?? "").toLowerCase() === "markdown") { + continue; + } + + operations.push({ op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }); + } + + for (const field of input.fields) { + operations.push({ op: "add", path: `/fields/${field.name}`, value: field.value }); + } + + return operations; +} + +/** How a read-back is compared: Markdown keeps its lines, HTML ignores insignificant space, plain text is exact. */ +export type StoredTextFormat = "markdown" | "html" | "plain"; + +const HTML_TAG = /<[a-zA-Z!/][^>]*>/; + +/** + * True when the stored text is the text that was sent, up to the rewrites the server applies to rich text: + * an emoji becomes a numeric (or hex) character reference, a quoted integer attribute loses its quotes + * (`width="375"` stored as `width=375`; `width="48%"` keeps the quotes), a declaration inside a tag gains a + * closing semicolon, and a local image src is the attachment URL from `images`. Comments are ignored too. + * Markdown keeps its line breaks and indentation, which carry meaning; HTML ignores insignificant space. + * A plain field (a title, a tag list) must match exactly. + */ +export function sameStoredText(input: { + stored: string; + sent: string; + format: StoredTextFormat; + images?: ReadonlyMap; +}): boolean { + if (input.format === "plain") { + return input.stored === input.sent; + } + + const images = input.images ?? new Map(); + return ( + normalizeAdoText(replaceImageSources(input.stored, images), input.format) === + normalizeAdoText(replaceImageSources(input.sent, images), input.format) + ); +} + +function normalizeAdoText(value: string, format: "markdown" | "html"): string { + const text = value + .replace(//g, "") + .replace(/&#(\d+);/g, (_match, code: string) => String.fromCodePoint(Number(code))) + .replace(/&#x([0-9a-fA-F]+);/g, (_match, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16))) + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/(\s[\w:-]+)="(\d+)"/g, "$1=$2") + .replace(/<[^>]+>/g, (tag) => tag.replace(/;(?=\s*["'>])/g, "")); + + if (format === "html") { + return text + .replace(/\s*(<[^>]+>)\s*/g, "$1") + .replace(/\s+/g, " ") + .trim(); + } + + return text + .replace(/\r\n?/g, "\n") + .replace(/[ \t]+$/gm, "") + .trim(); +} + +/** Markdown when the field is (or is being switched to) Markdown, HTML when the field or either value is HTML. */ +function storedTextFormat(input: { + name: string; + sent: string; + stored: unknown; + markdownFields: readonly string[]; + formats: Readonly>; +}): StoredTextFormat { + const format = (input.formats[input.name] ?? "").toLowerCase(); + + if (format === "markdown" || input.markdownFields.includes(input.name)) { + return "markdown"; + } + + const stored = typeof input.stored === "string" ? input.stored : ""; + return format === "html" || HTML_TAG.test(input.sent) || HTML_TAG.test(stored) ? "html" : "plain"; +} + +export function parseFieldSpec(spec: string, readFile: (path: string) => string): { name: string; value: string } { + const eq = spec.indexOf("="); + + if (eq <= 0) { + throw new Error(`--field expects Name=value or Name=@file, got '${spec}'`); + } + + const name = spec.slice(0, eq); + const raw = spec.slice(eq + 1); + + if (!raw.startsWith("@")) { + return { name, value: raw }; + } + + const path = raw.slice(1); + + if (path === "") { + throw new Error(`--field ${name} has an empty @ path`); + } + + return { name, value: readFile(path) }; +} + +export function parseReuseFile(text: string): Map { + const parsed: unknown = SafeJSON.parse(text); + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("--reuse must be a JSON object of { name: { sha256, url } }"); + } + + const uploaded = new Map(); + + for (const [name, value] of Object.entries(parsed)) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`--reuse entry ${name} must be an object`); + } + + const sha256 = "sha256" in value ? value.sha256 : undefined; + const url = "url" in value ? value.url : undefined; + + if (typeof sha256 !== "string" || typeof url !== "string" || sha256 === "" || url === "") { + throw new Error(`--reuse entry ${name} needs sha256 and url strings`); + } + + uploaded.set(name, { sha256, url }); + } + + return uploaded; +} + +/** A stored field value as text: a number or boolean as written, an identity as `Name `. */ +function fieldText(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + const identity = identityNames(value); + return identity.length > 0 ? identity.join(" ") : ""; +} + +/** The names an identity field answers to: its display name, unique name, and `Name `. */ +function identityNames(value: unknown): string[] { + if (value === null || typeof value !== "object") { + return []; + } + + const display = "displayName" in value && typeof value.displayName === "string" ? value.displayName : null; + const unique = "uniqueName" in value && typeof value.uniqueName === "string" ? value.uniqueName : null; + return [display, unique, display && unique ? `${display} <${unique}>` : null].filter( + (name): name is string => name !== null + ); +} + +/** Whether the read-back holds what was sent, compared by the stored value's own type and a text field's format. */ +function storedMatches(stored: unknown, sent: string, format: StoredTextFormat): boolean { + if (typeof stored === "number") { + return sent.trim() !== "" && Number(sent) === stored; + } + + if (typeof stored === "boolean") { + return sent.trim().toLowerCase() === String(stored); + } + + const identity = identityNames(stored); + if (identity.length > 0) { + return identity.some((name) => name.toLowerCase() === sent.trim().toLowerCase()); + } + + return sameStoredText({ stored: fieldText(stored), sent, format }); +} + +/** Read, substitute images, and either describe the patch or send it and compare the read-back. */ +export async function executeWorkItemUpdate( + client: WorkItemUpdateClient, + input: WorkItemUpdateInput +): Promise { + const live = await client.read(input.id); + + if (input.rev !== undefined && input.rev !== live.rev) { + return { ok: false, error: `work item ${input.id} is at rev ${live.rev}, not ${input.rev}` }; + } + + const bySrc = new Map(input.images.map((image) => [image.src, image])); + const sources = [...new Set(input.fields.flatMap((field) => localImageSources(field.value)))]; + const files = new Map(input.images.map((image) => [image.src, image.sha256])); + const reuse = imageReuseMap(sources, files, input.reuse); + const uploads: WorkItemUpdateImage[] = []; + + for (const src of sources) { + if (reuse.has(src)) { + continue; + } + + const image = bySrc.get(src); + + if (!image) { + return { ok: false, error: `local image not provided: ${src}` }; + } + + uploads.push(image); + } + + const urls = new Map(reuse); + + if (input.confirm) { + for (const image of uploads) { + urls.set(image.src, await client.upload(image.name, image.bytes)); + } + } + + const fields = input.fields.map((field) => ({ name: field.name, value: replaceImageSources(field.value, urls) })); + const operations = workItemPatch({ + rev: live.rev, + fields, + markdownFields: input.markdownFields, + liveFormats: live.multilineFieldsFormat, + }); + const plan: WorkItemUpdatePlan = { + rev: live.rev, + operations, + uploads: uploads.map(({ src, name, sha256 }) => ({ src, name, sha256 })), + reused: [...reuse.entries()].map(([src, url]) => ({ src, url })), + }; + + if (!input.confirm) { + return { ok: true, dryRun: true, plan }; + } + + await client.update(input.id, operations); + const after = await client.read(input.id); + const mismatches: { field: string; sent: string; stored: string }[] = []; + + for (const field of fields) { + const raw = after.fields[field.name]; + const format = storedTextFormat({ + name: field.name, + sent: field.value, + stored: raw, + markdownFields: input.markdownFields, + formats: after.multilineFieldsFormat, + }); + + if (!storedMatches(raw, field.value, format)) { + mismatches.push({ field: field.name, sent: field.value, stored: fieldText(raw) }); + } + } + + // On its own loop: `--markdown ` may switch a field it does not also set. + for (const name of new Set(input.markdownFields)) { + const sent = fields.find((field) => field.name === name); + const value = sent ? sent.value : fieldText(after.fields[name]); + + if (value !== "" && (after.multilineFieldsFormat[name] ?? "").toLowerCase() !== "markdown") { + mismatches.push({ field: name, sent: "Markdown", stored: after.multilineFieldsFormat[name] ?? "" }); + } + } + + if (mismatches.length > 0) { + return { + ok: false, + error: `work item ${input.id} was updated to rev ${after.rev}, but the read-back differs`, + mismatches, + }; + } + + const images: Record = {}; + + for (const [name, uploaded] of input.reuse) { + images[name] = uploaded; + } + + for (const image of input.images) { + const url = urls.get(image.src); + + if (url) { + images[image.name] = { sha256: image.sha256, url }; + } + } + + return { ok: true, dryRun: false, rev: after.rev, images, plan }; +} diff --git a/src/azure-devops/task-files.ts b/src/azure-devops/task-files.ts index 2c390247f5..128be86d72 100644 --- a/src/azure-devops/task-files.ts +++ b/src/azure-devops/task-files.ts @@ -4,14 +4,15 @@ import { existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; +import { getLocalConfigDir } from "@app/azure-devops/config"; import type { FoundTaskFile } from "@app/azure-devops/types"; import { slugify } from "@genesiscz/utils/string"; /** - * Get the tasks directory (always in cwd), optionally with category subdirectory + * Get the tasks directory (in cwd, or next to an explicit config), optionally with category subdirectory */ export function getTasksDir(category?: string): string { - const base = join(process.cwd(), ".claude/azure/tasks"); + const base = join(getLocalConfigDir(), "tasks"); return category ? join(base, category) : base; } diff --git a/src/azure-devops/templates.ts b/src/azure-devops/templates.ts index 44c29430d0..2a112bf299 100644 --- a/src/azure-devops/templates.ts +++ b/src/azure-devops/templates.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { getLocalConfigDir } from "@app/azure-devops/config"; import type { WorkItem, WorkItemFull, WorkItemTemplate, WorkItemType } from "@app/azure-devops/types"; import { SafeJSON } from "@genesiscz/utils/json"; import { htmlToMarkdown } from "@genesiscz/utils/markdown/html-to-md"; @@ -472,7 +473,7 @@ export function saveTemplate(template: WorkItemTemplate, baseDir?: string): stri const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const filename = `${timestamp}-New${template.type.replace(/\s+/g, "")}.json`; - const dir = baseDir || join(process.cwd(), ".claude/azure/tasks/created"); + const dir = baseDir || join(getLocalConfigDir(), "tasks/created"); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); diff --git a/src/azure-devops/types.ts b/src/azure-devops/types.ts index c7a0cb21f0..6716f2b249 100644 --- a/src/azure-devops/types.ts +++ b/src/azure-devops/types.ts @@ -37,6 +37,15 @@ export interface AzWorkItemRaw { relations?: Relation[]; } +/** One work item as `GET wit/workitems/{id}` returns it, including the multiline field formats. */ +export interface WorkItemLive { + id: number; + rev: number; + fields: Record; + multilineFieldsFormat: Record; + relations?: Relation[]; +} + export interface WorkItem { id: number; rev: number; @@ -398,7 +407,7 @@ export interface WorkItemTemplate { /** JSON Patch operation for Azure DevOps API */ export interface JsonPatchOperation { - op: "add" | "remove" | "replace"; + op: "add" | "remove" | "replace" | "test"; path: string; value?: unknown; } diff --git a/src/azure-devops/wiql-builder.test.ts b/src/azure-devops/wiql-builder.test.ts index 42a882f699..f05ed5d27d 100644 --- a/src/azure-devops/wiql-builder.test.ts +++ b/src/azure-devops/wiql-builder.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildCombinedQuery } from "@app/azure-devops/wiql-builder"; +import { buildCombinedQuery, wiqlRequestsTimePrecision, wiqlTimePrecisionParam } from "@app/azure-devops/wiql-builder"; describe("buildCombinedQuery", () => { test("scopes to the configured project and matches the current assignee with =", () => { @@ -54,3 +54,27 @@ describe("buildCombinedQuery", () => { expect(wiql).toContain("CONTAINS 'O''Neil'"); }); }); + +describe("wiql time precision", () => { + test("a clock in a ChangedDate literal asks for time precision", () => { + const wiql = "AND [System.ChangedDate] >= '2026-10-15T02:12:15.000Z'"; + + expect(wiqlRequestsTimePrecision(wiql)).toBe(true); + expect(wiqlTimePrecisionParam(wiql)).toBe("true"); + }); + + test("a space-separated clock asks for time precision too", () => { + expect(wiqlRequestsTimePrecision("AND [System.ChangedDate] >= '2026-10-15 02:12:15'")).toBe(true); + }); + + test("a bare day stays on date precision", () => { + const wiql = "AND [System.ChangedDate] >= '2026-10-15' ORDER BY [System.ChangedDate] DESC"; + + expect(wiqlRequestsTimePrecision(wiql)).toBe(false); + expect(wiqlTimePrecisionParam(wiql)).toBeUndefined(); + }); + + test("an explicit false keeps date precision even when the literal has a clock", () => { + expect(wiqlTimePrecisionParam("[System.ChangedDate] >= '2026-10-15T02:12:15.000Z'", false)).toBeUndefined(); + }); +}); diff --git a/src/azure-devops/wiql-builder.ts b/src/azure-devops/wiql-builder.ts index 40d92e6e4f..4bcc5e42e7 100644 --- a/src/azure-devops/wiql-builder.ts +++ b/src/azure-devops/wiql-builder.ts @@ -28,6 +28,23 @@ export function escapeWiqlValue(value: string): string { return value.replace(/'/g, "''"); } +/** + * Whether a WIQL statement contains a clock. The query endpoint runs in date + * precision unless `timePrecision=true`, and a clock then fails with + * "You cannot supply a time with the date when running a query using date precision." + * A bare `YYYY-MM-DD` stays on date precision: that mode is what makes the day mean the whole day. + */ +export function wiqlRequestsTimePrecision(wiql: string): boolean { + return /'\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(wiql); +} + +/** `true` when this call must set the WIQL `timePrecision` query flag. An explicit false wins. */ +export function wiqlTimePrecisionParam(wiql: string, explicit?: boolean): "true" | undefined { + const on = explicit ?? wiqlRequestsTimePrecision(wiql); + + return on ? "true" : undefined; +} + function toWiqlList(csv: string): string | null { const values = csv .split(",") diff --git a/src/browser-extension/extension/popup.html b/src/browser-extension/extension/popup.html index 65bc7fb822..77acdf8363 100644 --- a/src/browser-extension/extension/popup.html +++ b/src/browser-extension/extension/popup.html @@ -6,21 +6,39 @@ GenesisTools
-
+

GT GenesisTools

host -
+ +