Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run test:integration*)"
]
}
}
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
pull_request:
push:
branches: [master, proposed_type_script_api_version]

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
# Unit tier only: the integration tier needs a testing instance and writes fixtures.
- run: npm run verify
- name: Pack
if: matrix.node == 24
run: npm pack
- uses: actions/upload-artifact@v4
if: matrix.node == 24
with:
name: versionone-sdk-tarball
path: versionone-sdk-*.tgz
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ npm test
- `resolve.numbers` / `resolve.ref` - asset numbers and OID tokens to assets, in one request
- `assets.sendGuarded` - a batch as a compare-and-set on each asset's ChangeDate; changed assets come back `skipped`
- `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch
- `createFetchTransport` - configurable HTTP transport
- `createFetchTransport` - configurable HTTP transport: `credentials` for same-origin cookie auth, `onUnauthorized` to re-authenticate on a 401 (resends idempotent requests once), and a per-request `signal`; `client.withSignal(signal)` ties a whole client to one
- `ApiError` - typed API error; `CommandFailureError` - an api/asset batch in which commands failed (`failures` by index, plus the `response`)

API reference: https://docs.digital.ai/agility-api/
Expand All @@ -38,6 +38,10 @@ API reference: https://docs.digital.ai/agility-api/
4. Export from `src/index.ts`.
5. Add tests under `test/`.

## Installing before a release

No registry package yet. Pin a commit: `npm install github:versionone/VersionOne.SDK.JavaScript#<sha>` builds `dist` on install (the `prepare` script). CI also uploads an `npm pack` tarball on every run.

## Before PR

- Run `npm run verify` to validate typecheck, build, and tests before opening a pull request.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
},
"scripts": {
"build": "tsup",
"prepare": "npm run build",
"dev": "tsup --watch",
"download:metamodel": "node scripts/download-metamodel.mjs",
"clean": "rimraf dist",
Expand Down
8 changes: 4 additions & 4 deletions plans/001-sdk-adoption-campaign.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,15 +351,15 @@ ESM" here means finishing the edges:

## Checklist

- [ ] A1 transport: credentials, onUnauthorized, signal
- [x] A1 transport: credentials, onUnauthorized (idempotent requests resent once; writes never), signal (`RequestOptions.signal`, `client.withSignal`)
- [x] A2 command-failure error (`CommandFailureError`, `throwOnFailure`, reads always throw)
- [x] A3 loc2 (`loc2.localize`, `client.localize`; GET, POST past 2000 encoded chars, culture via Accept-Language)
- [ ] A4 slack-shaped reads (queryV1 or api/asset)
- [x] A5 services port from gh-v1 — `resolve` (`client.resolve.numbers`/`.ref`), `guard` (`client.assets.sendGuarded`), `batch` (`client.batch.run`/`.reversible`)
- [ ] A6 CI + prerelease publish
- [ ] A6 CI + prerelease publish — CI (`.github/workflows/ci.yml`: verify on Node 20/22/24, `npm pack` artifact) and git-SHA installs (`prepare`) done; publish waits on the registry decision
- [ ] G0–G3 gh-v1 migrations (gh-v1 session)
- [ ] S1–S4 slack-bot migrations (slack-bot session, `bolt-migration`)
- [ ] B1–B4 slack bot SDK swap
- [ ] B1–B4 slack bot SDK swap — brief: `plans/003-adopt-sdk-k6i-agility-slack-bot.md`
- [x] C0 gh-v1 surveyed (2026-11-04)
- [ ] C1–C4 gh-v1
- [ ] C1–C4 gh-v1 — brief: `plans/002-adopt-sdk-gh-v1.md`
- [ ] D1–D4 agility-rooms
79 changes: 79 additions & 0 deletions plans/002-adopt-sdk-gh-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Plan 002: gh-v1 adopts `@versionone/sdk` (Track C)

Brief for a session on `versionone/gh-v1`. Start once the SDK PR that adds
this file has merged into `proposed_type_script_api_version`. Source plan:
`plans/001-sdk-adoption-campaign.md`, Track C.

## Goal

gh-v1 talks to Agility only through `@versionone/sdk`. CLI behaviour and
output stay the same; the code that duplicates the SDK goes.

## Where things stand (checked 2026-11-06, gh-v1 `2c58c7e`)

- G0–G3 are merged: executable bin, `V1_URL`/`V1_ACCESS_TOKEN` config, ESM
(`"type": "module"`), `jsconfig.json` + `npm run typecheck`, one paging
module (`lib/paging.js`).
- The bin `gh-v1` is now a `sh` launcher that runs `gh-v1.js` with the real
node (bypassing Volta's shim, which mangles arguments on Windows). The
swap does not touch either; keep `test/launcher.test.js` green.
- One seam: `lib/api.js` exports `api(cfg)` returning `query`, `queries`,
`asset`, `attempt`, `rest`, `meta`, `localize`, `raw`, `blob`,
`permalink`, typed by the `Api` JSDoc typedef. Its tests under `test/`
stub global `fetch`.
- String-boolean and OID helpers (`isTrue`, `oidOf`, `momentless`) are used
in `lib/api.js`, `meta.js`, `resolve.js`, `board.js`, `batch.js`, and
`commands/apply.js`, `bulk.js`, `board.js`.

## Dependency

No registry package yet. Add the first runtime dependency as a pinned commit:
`"@versionone/sdk": "github:versionone/VersionOne.SDK.JavaScript#<merge sha>"`.
The SDK's `prepare` script builds `dist` on install. gh-v1 is ESM now, so use
`import` (the ESM build), not `require`.

## Steps, one PR each, on branch `sdk-adoption`

1. **C1 Seam swap** (`lib/api.js`). Keep `api(cfg)` and the `Api` typedef.
Inside, build one `AgilityApiClient({ baseUrl: cfg.v1Url, accessToken })`.
Map: `query`/`queries` → `client.assets.query`/`queries`;
`asset` → `client.assets.send(cmds, { throwOnFailure: true })`
(a `CommandFailureError` carries `failures` by index — keep `err.failures`);
`attempt` → `client.assets.send` (failures returned, not thrown);
`rest` → `client.data.list`/`get`; `meta` → `client.meta.*`;
`localize` → `client.localize`. `raw`, `blob`, `permalink` stay local.
Rows now arrive translated (`true`, numbers, single relations as one
Ref), so update the fixtures in tests that assert raw strings, and add
the meta fixture the schema needs (`test/meta-fixture.js`, or seed
`ClientOptions.metamodel`).
2. **C2 Delete the decodes.** Remove `isTrue`/`oidOf` call sites listed
above; import `momentless` from the SDK.
3. **C3 Invert the services.** `resolveAssets` → `client.resolve.numbers`
(its `missing` drives the "not found — skipped" lines). `batch.run` →
`client.batch.run`, with `confirm` as the prompt and the receipt written
as returned. `--reverse` → `client.batch.reversible` (`--force` passes
`force`). Keep the CLI's UX for an unguardable batch by checking
`unguardable()` first, then sending with `guard: false` and the existing
note. Prompting, rendering, permalinks and receipt files stay in gh-v1.
4. **C4 Passthrough.** `raw`/`blob` stay as they are. `lib/guard.js` stays:
it is CLI policy, not SDK behaviour.

## Rules

- Requests out must match today's, apart from what translation changes.
Add a test that asserts the request body where it differs, and say why.
- Every query keeps an explicit page (the SDK refuses one without).
- Live Agility calls go only to the API/SDK testing instance,
`https://www14.v1host.com/v1sdktesting` (`V1_URL`). Writes are allowed
there and nowhere else: name fixtures `sdk-integration …` and delete them
by exact OID when done. Every query sets an explicit page size and stays
well under 20 s of server work.

## Done when

- `npm test` and `npm run typecheck` pass on each PR.
- `lib/api.js` has no `fetch` call except in `raw`/`blob`.
- No `isTrue`/`oidOf`/local `momentless` remain in `lib/`.
- `lib/resolve.js` and `lib/batch.js` keep only prompting, rendering and
file I/O.
- Plan 001's checklist line `C1–C4 gh-v1` is ticked, with the PR links.
80 changes: 80 additions & 0 deletions plans/003-adopt-sdk-k6i-agility-slack-bot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Plan 003: k6i-agility-slack-bot adopts `@versionone/sdk` (Track B)

Brief for a session on `digital-ai/k6i-agility-slack-bot`. Start once the
SDK PR that adds this file has merged into `proposed_type_script_api_version`.
Source plan: `plans/001-sdk-adoption-campaign.md`, Track B. Work on
`bolt-migration` (branch `sdk-adoption` from it), never `main`.

## Goal

`services/v1.js`'s `createV1Client` runs on `@versionone/sdk`, with the same
exported surface, so listeners and features do not change. The bot's
hand-decoding and rest-1 write path go.

## Where things stand (checked 2026-11-06, `bolt-migration` `4d77693`)

- `createV1Client({ url, accessToken })` uses native `fetch` and throws
`AuthFailureError` on 401. Its methods: `query` (query.v1), `bulk`,
`createWithNumber`, `exec`/`quickClose`/`reactivate` (api/asset),
`create`/`update` (rest-1 writes via `lib/rest1DataTransformer.js`),
`findAssets` (rest-1 `find`/`findin`, `page` up to 200),
`fetchLocalizations` (loc-2), and `queryFor*` helpers built on
`lib/queries.js`. `test/v1-service.test.js` stubs global `fetch`.
- **S1–S4 are not done yet**: `jsconfig.json` still says `commonJS`/`es6`;
reads still post to `query.v1`; `axios`, `botkit*`, `botbuilder-*` are
still dependencies and `lib/v1api.js` still exists. These come first.

## Dependency

No registry package yet. Pin a commit:
`"@versionone/sdk": "github:versionone/VersionOne.SDK.JavaScript#<merge sha>"`.
The SDK's `prepare` script builds `dist` on install. The bot is ESM, so
`import { AgilityApiClient } from "@versionone/sdk"`.

## Steps, one PR each

0. **S1–S4 first**, as written in `plans/handoffs/k6i-agility-slack-bot.md`.
B1 needs at least S2 (reads on api/asset) and S3 (axios and Botkit gone).
If the bot's own session already merged them, skip.
1. **B1 Client swap** (`services/v1.js`). Inside `createV1Client`, build one
`new AgilityApiClient({ baseUrl: url, accessToken })`. `query` →
`client.assets.queries` (positional; a failed query now throws instead
of shifting later results); `bulk`/`exec`/`createWithNumber` →
`client.assets.send`; `fetchLocalizations` → `client.localize`;
`findAssets` → `client.data.list("BaseAsset", { find, findin, where,
sort, page, deleted: false })`. Map `ApiError` with `status === 401`
to `AuthFailureError` in one place. The SDK's `onUnauthorized` is not
needed. `test/v1-service.test.js` stays the parity proof.
2. **B2 Delete the decodes.** Rows arrive translated: drop the
`"Team.HasProcess" === "True"` and `IsKanban.toLowerCase() === "true"`
comparisons, local `toMomentless` (use the SDK's `momentless`), and the
`results[1]`/`results[2]` guessing in `queryForPrimaryWorkitemByOid` and
`queryForTeamRoomContext`.
3. **B3 Writes on api/asset.** `create`/`update` → `client.assets.send`,
with the change comment as `ChangeComment` inside the `update` node (or
at the root of a create). `execute` cannot carry a comment. Delete
`lib/rest1DataTransformer.js` and its test.
4. **B4 Number resolution.** `queryForAssetByNumber` →
`client.resolve.numbers([n])`; a miss is `missing`, not an empty row.

## Rules

- Every api/asset query keeps an explicit `page` (the SDK refuses one
without). Values from user text go in `with` variables, not spliced into
`filter`/`where` (an em dash breaks a literal).
- Fixtures send what the server sends (S4): `"True"`, not `true`.
- Live Agility calls go only to the API/SDK testing instance,
`https://www14.v1host.com/v1sdktesting` (`V1_URL`). Writes are allowed
there and nowhere else: name fixtures `sdk-integration …` and delete them
by exact OID when done. Every query sets an explicit page size and stays
well under 20 s of server work.

## Done when

- `npm test` passes on each PR; S1's `npm run typecheck` passes.
- `services/v1.js` makes no `fetch` call of its own; nothing posts to
`query.v1` or writes through rest-1.
- No string-boolean comparisons or local `toMomentless` remain.
- Sandbox-workspace smoke passes: asset mention, `/agility find`,
backlog-item status change, webhook notification.
- Plan 001's checklist lines `S1–S4` and `B1–B4` are ticked, with PR links.
7 changes: 7 additions & 0 deletions specs/000-layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ retries only idempotent requests (GET by default), only on a network
failure or a 5xx, never a 4xx; a batch write is never retried unless the
caller has made it idempotent with a guard and says so.

Re-authentication follows the same rule. On a 401 (or a status the caller
lists) the transport calls `onUnauthorized` once per request; "retry" resends
an idempotent request one more time, outside the `retries` count. A plain
write is never resent: the hook still runs, so the session can be repaired,
and the caller decides. A caller's abort (`signal`) ends the request as
`ABORTED` and is never retried.

A failure never reads as an empty. api/asset answers 200 and commits per
command, so a failed command is reported inside the envelope
(`commandFailures`, each by `sourceCommandIndex`). `assetApi.send` returns
Expand Down
24 changes: 22 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface ClientOptions {
* queries first touch them.
*/
metamodel?: Metamodel;
/** Share an existing schema (and its cache) instead of starting a new one. */
schema?: Schema;
}

/**
Expand All @@ -44,9 +46,27 @@ export class AgilityApiClient {
accessToken: config.accessToken,
timeoutMs: config.timeoutMs,
retries: config.retries,
defaultHeaders: config.defaultHeaders
defaultHeaders: config.defaultHeaders,
credentials: config.credentials,
onUnauthorized: config.onUnauthorized,
unauthorizedStatuses: config.unauthorizedStatuses
});
this.schema = new Schema(this.transport, opts.metamodel);
this.config = config;
this.schema = opts.schema ?? new Schema(this.transport, opts.metamodel);
}

private readonly config: ClientConfig;

/**
* The same client, with every request it sends tied to `signal`: abort it
* (on navigation, say) and in-flight requests end with an ApiError coded
* ABORTED. The schema and its cache are shared; a type load that other
* callers are waiting on is not cancelled.
*/
withSignal(signal: AbortSignal): AgilityApiClient {
const base = this.transport;
const transport: Transport = { request: (options) => base.request({ ...options, signal: options.signal ?? signal }) };
return new AgilityApiClient(this.config, { transport, schema: this.schema });
}

/** meta.v1: the instance's schema, raw. (The translated, cached view is `schema`.) */
Expand Down
4 changes: 3 additions & 1 deletion src/core/clientConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export interface ClientConfig {
import type { FetchTransportConfig } from "./fetchTransport";

export interface ClientConfig extends Pick<FetchTransportConfig, "credentials" | "onUnauthorized" | "unauthorizedStatuses"> {
/** The instance base URL: "https://www7.v1host.com/V1Production". */
baseUrl: string;
/** A ready Authorization header value ("Basic …" for a dev instance). Wins over accessToken. */
Expand Down
Loading
Loading