Skip to content
Open
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
66 changes: 37 additions & 29 deletions CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1693,8 +1693,8 @@ Under `--include …,deps` each issue's GitHub "blocked by" dependencies become
`blocker` rows on its story. This is a **both-engines** type: the server ask
(EAT #35491) shipped before this CLI story, so the direct engine mirrors
`github.rs` rather than inventing anything. Every mapping rule below matches;
two *write-side* divergences are named at the end of this section, and both are
pinned by `tests/parity.test.js` rather than asserted here.
one *write-side* divergence is named at the end of this section, and
`tests/parity.test.js` pins it rather than this prose.

**The server engine is capability-gated.** `include_dependencies` reached the
server on 2026-08-04 and `ImportJsonRequest` has no `deny_unknown_fields`, so a
Expand All @@ -1710,15 +1710,26 @@ probe.

- **Text** — `Blocked by #<number> (<title>)`, the blocking issue's title
**trimmed**, and `resolved: false`. Byte-identical to `blocked_by_desc` up to
the length clamp, which is the first divergence listed below. A
the length clamp, which is the one divergence listed below. A
dependency row's `number` and `title` are both `#[serde(default)]` server-side,
so a row with no title renders `Blocked by #90 ()` rather than being dropped.
- **Selection** — one blocker per `blocked_by` entry, in GitHub's own listing
order. Rows whose `number` is not a positive integer are skipped (`#[serde(default)]`
covers a *missing* number, which becomes 0 and the same guard drops; a
present-but-wrong-typed one is a serde error server-side, failing that whole
page, where the CLI drops the single row and keeps the rest), and repeats are
deduplicated by number across pages, the first title winning.
deduplicated by number across pages, the first title winning. **Both engines
return them in that order.** `POST /blockers` assigns
`COALESCE(MAX(blocker_display_order), -1) + 1` per story
(`blockers.rs::create`, agile-tracker `a774ed013`), and both story projections
order by `story_id, blocker_display_order, blocker_id`. The direct-engine
writer posts one blocker after another in `blocked_by` order, so the stored
order matches the listing order. A run that loses no create lands at 0, 1, 2
and upward, the same positions the server importer writes from the entry
index. The writer contains a refused create and keeps going, and the survivors
then take consecutive positions, so the positions shift where the relative
order holds. Server ask #35639 shipped that change and closed the ordering
divergence.
- **Scope** — a blocker is recorded whether or not the blocking issue is itself
imported: the server never intersects `blocked_by` with the import set, so
neither does the CLI. An issue with an empty listing, or one whose listing
Expand All @@ -1738,27 +1749,24 @@ probe.
`github.rs` does not intersect `blocked_by` with the import set either.
`--engine direct` adds one further line — the per-issue request cost, quoted as
a lower bound — since only the direct engine spends a caller's own GitHub budget.
- **Two write-side divergences**, both a consequence of the public API's shape
rather than of a mapping choice:
- **Length is clamped in bytes, not characters.** `POST /blockers` validates
with `validate_length` → Rust's `str::len()`, so the CLI cuts `blocker_desc`
at 255 **bytes** (never splitting a character); the server importer writes
the column directly and cuts at 255 **chars** (`desc.chars().take(255)`).
For a multi-byte title between 256 bytes and 255 chars the two engines write
different text — the CLI's is a prefix of the server's. The CLI's behaviour
is the only one the public route accepts; server ask **#35629
(/s/y9q8ea68)** tracks reconciling the two ends.
- **`blocker_display_order` is not settable from the CLI.** `CreateBlocker` is
`{ blocker_desc, resolved }` and `blockers.rs` INSERTs
`(story_id, blocker_desc, resolved)`, leaving the column at its
`NOT NULL DEFAULT 0`; the server importer writes the entry index. So every
direct-engine blocker lands at 0 where a server-engine one carries its
position, and the story-list projection — which orders by
`blocker_display_order` with no tiebreaker — returns direct-engine blockers
in unspecified order. The writer still posts them sequentially in GitHub's
`blocked_by` order, because insertion order is all it controls. Server ask
**#35639 (/s/kp82mw25)** tracks both halves — a settable order on the public
create, and a tiebreaker on the projection.
- **One write-side divergence**, and it is a CLI mapping choice that the public
API no longer matches. This section spells a **live** companion story
`#NNNN (/s/slug)`, and a closed ask bare. `tests/parity.test.js` reads that
difference, so a slug beside a closed ask turns the gate red:
- **Length is clamped in bytes by the CLI, in characters by both server
ends.** The CLI cuts `blocker_desc` at 255 **bytes**, never splitting a
character (`byteLen` / `sliceBytes` in `src/mapping.js`). `POST /blockers`
validates with `validate_length`, which counts **characters** since
agile-tracker `8e5ed5479`; the server importer writes the column directly
and cuts at 255 **chars** (`relations.rs:502`, `desc.chars().take(255)`).
Both server ends therefore stop at 255 characters. The two ends part only
when the line's first 255 characters take more than 255 bytes: the CLI's
window closes first there, so it keeps fewer characters, and its text stays
a prefix of the server's. A title whose accents all sit past character 255
gets the identical 255 characters from both ends. Server ask #35629 is
accepted: it reconciled the two *server* ends toward characters, so the
public route is no longer the stricter end. The live companion is CLI bug
**#482645 (/s/3zktusgn)**, in project 91, not a server ask.

### Write surface (direct engine)

Expand Down Expand Up @@ -1854,9 +1862,9 @@ real server 2026-07-16 and mirrored by `src/mockserver.js`):
or whitespace-only description is `400 invalid_parameter`; `blocker_desc`
≤ 255) → 200 `{ blocker_id, story_id, blocker_desc, blocker_display_order,
resolved, created, expired }`. Written after the story's tasks, one sequential
request per blocker, in GitHub's own `blocked_by` order. The route binds no
display order — the response echoes the column's `DEFAULT 0` on every row — so
insertion order is all the writer controls (see the deps divergences above).
request per blocker, in GitHub's own `blocked_by` order. The route assigns
`COALESCE(MAX(blocker_display_order), -1) + 1` per story, so three sequential
posts echo 0, 1 and 2 (see the deps **Selection** bullet above).
Member-gated, not owner-gated. Used only under `--include …,deps`; a plan
carrying no blockers never touches the route, and a plan that does carry them
is refused before the first write when the client cannot write blockers at all.
Expand Down Expand Up @@ -2158,7 +2166,7 @@ and both are prescanned, in union.
`github.com` `html_url` (`github.rs:1366`); the marker is the API resource,
for the browsability reason given above.

Pinned by `tests/parity.test.js`, like the deps divergences, so the claim
Pinned by `tests/parity.test.js`, like the deps divergence, so the claim
cannot rot into prose.
- The prescan cursor-walks the project —
`GET /stories?limit=…&cursor=…&fields=…` (cursor mode whenever `cursor=` or
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,15 @@ must contain `issues` — the other types only add to an issue import:
- `deps` — each issue's "blocked by" dependencies become blockers on its story,
one per entry, reading `Blocked by #90 (Upstream fix)` and unresolved. A
blocker is recorded whether or not the blocking issue is itself imported.
Works on both engines and writes the same text, with one narrow exception: an
over-long blocker is cut at 255 **bytes** by the direct engine (what the public
API accepts) and 255 **characters** by the server, so a multi-byte title near
that boundary lands slightly shorter on `--engine direct` — tracked as EAT
#35629. The server engine needs a tracker new enough to accept
`include_dependencies`; against an older one the run is refused up front,
naming `--engine direct`, rather than reporting success with no blockers in it.
Works on both engines, and both return the blockers in the order GitHub lists
them. Both write the same text, with one narrow exception: the server keeps a
blocker line up to 255 characters, and near that boundary `--engine direct`
keeps fewer characters when the title uses accented or non-Latin characters. A
short accented title reaches both ends whole. What the direct engine writes is
always the start of what the server writes. Tracked as CLI bug #482645. The
server engine needs a tracker new enough to accept `include_dependencies`;
against an older one the run is refused up front, naming `--engine direct`,
rather than reporting success with no blockers in it.
On `--engine direct` the blockers ride the issue listing itself, so the flag
costs no extra GitHub request — one extra point per listing page, plus a
follow-up for an issue with more than 100 blockers. A `--dry-run` spends the
Expand Down
7 changes: 4 additions & 3 deletions src/mockserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -1305,9 +1305,10 @@ function createBlocker(state, projectId, storyId, body) {
blocker_id: state.nextId++,
story_id: storyId,
blocker_desc: desc,
// `blockers.rs` INSERTs (story_id, blocker_desc, resolved) only: the public
// route binds no order, so the column stays at its `NOT NULL DEFAULT 0`.
blocker_display_order: 0,
// `blockers.rs::create` INSERTs `COALESCE(MAX(blocker_display_order), -1) + 1`
// scoped to the story (agile-tracker `a774ed013`).
blocker_display_order:
Math.max(-1, ...story.blockers.map((/** @type {any} */ r) => r.blocker_display_order)) + 1,
resolved: body.resolved === true,
created: new Date().toISOString(),
expired: null,
Expand Down
4 changes: 2 additions & 2 deletions src/writer.js
Original file line number Diff line number Diff line change
Expand Up @@ -524,8 +524,8 @@ export async function writePlan(client, projectId, plan, options = {}) {
);
if (ok) result.tasks += 1;
}
// Written sequentially, in GitHub's own `blocked_by` order: the public
// route sets no display order, so insertion order is all the CLI controls.
// Sequential on purpose: the route numbers each create `MAX + 1` per story,
// so the posting order becomes the stored display order.
for (const [i, blocker] of (op.blockers ?? []).entries()) {
const ok = await contained("blocker", op.external_id, `blocker ${i + 1}`, () =>
retrying(() =>
Expand Down
Loading
Loading