Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5357964
fix(agileplace): clear planned dates with RFC-6902 remove, not null r…
thewrz Jul 21, 2026
405cfae
Merge pull request #53 from wrzonance/fix/planned-date-clear
thewrz Jul 21, 2026
ea82f63
docs(validation): record post-fix smoke run -- all AgilePlace live-ch…
thewrz Jul 21, 2026
72ffc7d
feat(probe): add read-only dependencies API discovery probe
thewrz Jul 21, 2026
01ed93c
fix(probe): harden per Codex adversarial review
thewrz Jul 21, 2026
54b2696
docs(validation): record dependencies read-endpoint discovery
thewrz Jul 21, 2026
fff24dd
docs(validation): record populated dependency entry shape
thewrz Jul 21, 2026
14ae253
docs(validation): record dependency delete shape from devtools capture
thewrz Jul 21, 2026
ef75cfd
docs(validation): record dependency create shape -- all three shapes …
thewrz Jul 21, 2026
291777a
feat(sync): mirror GitHub blocked-by edges as native AgilePlace depen…
thewrz Jul 21, 2026
a9141e8
fix(tests): stop the whole-run world leaking a real .env project config
thewrz Jul 21, 2026
98f8c5a
test(smoke): mirror the live 409 duplicate-dependency contract in the…
thewrz Jul 21, 2026
bc1366a
fix(sync): harden dependency reconciliation per gpt-5.6-sol adversari…
thewrz Jul 21, 2026
817abcb
feat(sync): retire the Blocked-flag mirror -- the flag belongs to hum…
thewrz Jul 21, 2026
105471c
chore: delete the spent one-shot flag cleanup script
thewrz Jul 21, 2026
956e13b
docs: purge stale Blocked-flag promises per final adversarial review
thewrz Jul 21, 2026
aa9e628
fix(sync): index refetched card as snapshot after create
thewrz Jul 21, 2026
3bc62bd
test(smoke): make the fake tenant's create response sparse
thewrz Jul 21, 2026
c142fdc
Merge pull request #56 from wrzonance/fix/issue-55-create-snapshot
thewrz Jul 21, 2026
a0cecc5
fix(smoke): pin the duplicate-dependency 409 contract; docs per PR #6…
thewrz Jul 21, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,6 @@ __marimo__/

# Streamlit
.streamlit/secrets.toml

# Claude Code workflow worktrees (session-local, never repo content)
.claude/worktrees/
122 changes: 114 additions & 8 deletions API-VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ This file records how the API calls this tool makes were checked before running
account. On 2026-07-17 each call was compared with the public Planview LeanKit io v2 docs and, where
it documents the same operation, the official LeanKit Node client. Most calls are confirmed. A few,
marked `[live-check]`, are not fully pinned down by public docs and should be smoke-tested once
against a disposable card. The last two sections record what was verified live: GitHub shapes on
2026-07-18, and AgilePlace write paths on 2026-07-20 (a `smoke.py` run).
against a disposable card. The last sections record what was verified live: GitHub shapes on
2026-07-18, and AgilePlace write paths on 2026-07-20 and 2026-07-21 (`smoke.py` runs).

## Confirmed against the cited sources

Expand Down Expand Up @@ -103,10 +103,11 @@ the code still fails closed (refetch, validate, or abort) rather than assuming e
Target values read from paginated GraphQL `ProjectV2Item.fieldValues` by field id; writes via
`item-edit --date`. A successful GraphQL snapshot with no matching values means the field is
cleared project-wide; a failed, malformed, or incomplete snapshot skips every date write that run.
The GraphQL date-read shape was exercised live on 2026-07-20 (see below). The
`plannedStart`/`plannedFinish` card PATCH is covered by `smoke.py` steps 5-6 but those steps were
added after the 2026-07-20 run -- it is the one AgilePlace `[live-check]` still awaiting a live
pass.
The GraphQL date-read shape was exercised live on 2026-07-20 (see below). The card PATCH was
live-run on 2026-07-21: setting both dates as strings is confirmed, but clearing them with a null
`replace` was rejected (HTTP 422 "Invalid value: must be string"), so clears now use an RFC-6902
`remove` op (issue #52). A second run the same day, after the fix merged, confirmed the
remove-based clear and the blocked-state clear live -- see "Validated live on 2026-07-21" below.

## GitHub side (standard and stable, noted for completeness)

Expand Down Expand Up @@ -156,8 +157,8 @@ Two gh behaviors learned the same day, recorded so this repo never has to redisc

A confirmed `python smoke.py` run (create -> mutate -> connect -> stale-probe -> delete on two
throwaway cards) retired every AgilePlace `[live-check]` item above except the planned-date card
PATCH -- smoke steps 5-6 (blocked state + planned dates) were added after this run and await the
next live pass:
PATCH -- smoke steps 5-6 (blocked state + planned dates) were added after this run; their first
live outcomes are recorded in the 2026-07-21 section below:

1. **Tag removal (item 1): confirmed.** Index-based `{op:"remove", path:"/tags/{i}"}` cleared the
tag in an add-then-remove round-trip (`tags` ended empty).
Expand All @@ -180,3 +181,108 @@ Model 2 additions confirmed by the same run: card create with `customId`+`extern
an external link both accepted; `card/connections` connect/disconnect round-trip works and the
children read reflects it immediately (including the authoritative empty read after disconnect).
`DELETE /io/card/{id}` (smoke cleanup only) deletes for real -- a follow-up GET returns 404.

## Validated live on 2026-07-21 (smoke.py runs, production tenant board)

Two runs: the first exercised steps 5-6 (blocked state + planned dates, added after the 2026-07-20
run) and surfaced the date-clear rejection; the second, after the issue #52 fix merged, passed the
full sequence:

1. **Blocked-state set: confirmed.** `/isBlocked` true + `/blockReason` string round-trips
(`isBlocked=True`, reason read back).
2. **Planned-date set: confirmed.** String `replace` on `/plannedStart` and `/plannedFinish`
round-trips (`2026-01-01`/`2026-01-02` read back exactly).
3. **Planned-date clear via null replace: REJECTED -- and the rejection taught two facts.**
`{op:"replace", path:"/plannedStart", value:null}` fails HTTP 422 "Invalid value: must be
string": the server type-validates `replace` values on these paths. And PATCH validation is
atomic: the 422 listed only the two date ops as invalid, yet no op in the batch (including the
valid blocked-clear ops) was applied. `op_planned_date` now emits `{op:"remove", path:"/{field}"}`
for a clear, and the offline smoke double mirrors the 422 (issue #52).
4. **Remove-based date clear + blocked-state clear: confirmed.** The second (post-fix) run's step 6
read back `isBlocked=False`, `plannedStart=None`, `plannedFinish=None`: the RFC-6902 `remove`
clears both dates, and the blocked-clear ops apply once no invalid op poisons the batch.

Both runs also re-confirmed create (`customId`+`externalLink`), the version-less create response,
flat single-card GET, both tag round-trips, the connections round-trip, the stale-version HTTP 428
rejection, and `DELETE` + 404 cleanup.

The same day's first live migration (53 cards, issue #55) established one more create-response
fact: it echoes neither `customId` nor `laneId` -- for sync purposes the response is the new card
id and nothing else. `sync.py` therefore refetches each just-created card once and indexes the
fresh card as its snapshot, and the offline sync-run double mirrors the id-only response.

With that, every AgilePlace `[live-check]` item in this file is retired -- each one now has a
recorded live outcome.

## Dependencies API discovery (2026-07-21, issue #57)

The io v2 public docs do not document the Dependencies feature's endpoints, so
`probe_dependencies.py` (strictly read-only; the GET-only invariant is test-pinned) probed the
production tenant:

- **Read endpoint confirmed: `GET /io/card/{cardId}/dependency` -> HTTP 200 `{"dependencies":[]}`.**
Singular path, matching the documented `connection/children` singular pattern.
- All other candidates returned a clean 404: `card/{cardId}/dependencies`,
`dependencies?cardId=`, `dependency?cardId=`, `board/{boardId}/dependencies`,
`card/{cardId}/connection/dependencies`. The documented `connection/children` control endpoint
answered 200 in the same run, so those 404s are real misses, not plumbing failures.
- The single-card GET embeds no dependency data: none of its 49 top-level keys is dependency-ish
(it does embed `parentCards` and `connectedCardStats` for the connections feature).

**Populated entry shape: confirmed live 2026-07-21.** After a UI-created dependency
(EP-3A -> JPOWER1, mirroring the real GitHub blocked-by edge #31 -> #68), the blocked card's read
returned:

```json
{"dependencies": [{"direction": "incoming", "cardId": "2490185684",
"timing": "finishToStart", "createdOn": "2026-07-21T16:19:24.147Z"}]}
```

- `direction` is relative to the card being read (`incoming` = the other card must progress first);
`cardId` is the OTHER end; `timing` uses camelCase enum values (`finishToStart` observed).
- **Entries carry no dependency id.** A dependency is evidently identified by its
(card, direction, other-card, timing) tuple, which implies deletion is addressed by card pair,
not by id -- the delete capture below must confirm how.

**Delete shape: confirmed live 2026-07-21** (devtools capture of the UI removing the EP-3A ->
JPOWER1 dependency):

```http
DELETE /io/card/dependency
{"cardIds": ["2490186236"], "dependsOnCardIds": ["2490185684"]}
```

Pair-addressed, as the id-less read entries implied: `cardIds` is the dependent (blocked) side,
`dependsOnCardIds` the blocker side, both plural batch arrays in the same style as
`card/connections`. No card id in the path; the UI's own session auth rode a cookie, but the
endpoint lives under the same `/io/` surface as everything the token-authenticated client uses.

**Create shape: confirmed live 2026-07-21** (devtools capture of the UI recreating the same
dependency):

```http
POST /io/card/dependency
{"cardIds": ["2490186236"], "dependsOnCardIds": ["2490185684"], "timing": "finishToStart"}
```

Same pair body as delete plus an explicit `timing` member (camelCase enum, `finishToStart`
observed). The POST *response* returns the card's updated `dependencies` list where each entry
additionally carries a `face` projection of the other card -- including a `dependencyStats` object
(`incoming/outgoing/total` x `Count/ResolvedCount/ExceptionCount/UnresolvedBlockedCount`), which is
the native health tracking. Plain reads (`GET card/{cardId}/dependency`) omit `face`; the sync needs
only `direction`/`cardId`/`timing`.

**Timing types.** The UI offers four: FS (Finish->Start), SS (Start->Start), FF (Finish->Finish),
SF (Start->Finish). Only `finishToStart`'s wire value has been observed; the other three
presumably follow the same camelCase pattern but are unconfirmed and unused -- GitHub blocked-by
has exactly one semantic ("the blocker must finish first"), which is FS. The sync reconciles
dependencies by card PAIR and never inspects or rewrites `timing` after creation: a human refining
a synced dependency's timing in the UI keeps their refinement.

**Round-trip and duplicate-create: confirmed live 2026-07-21** (`smoke.py` steps 11-12, run
suffix 10a01e, production tenant): create -> incoming read (right card, `finishToStart`) ->
delete -> empty read all behaved as coded. Duplicate create is REJECTED -- **HTTP 409 Conflict
`{"message": "Dependency already exists", "data": {"dependsOnCardId", "cardId"}}`** -- not
idempotent. The sync's diff-before-write never re-creates an existing pair, and its fail-closed
skip on unknown reads is what keeps a blind re-create (and its 409 SystemExit) impossible. The
offline doubles mirror the 409. No dependency `[live-check]` remains open.
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ With `--apply`, each run:
1. Creates a card for every active issue, epics and tasks alike. Cards are matched to issues by the
card's external-link URL, with the card's customId as a fallback. Issues closed as
`NOT_PLANNED`/`DUPLICATE` never get new cards; if one already has a URL-matched card, the run
retires it to Done and clears its stale blocked state without syncing its metadata back to GitHub.
retires it to Done without syncing its metadata back to GitHub.
2. Moves each card to the lane for its stage. For open issues, the stage is the issue's Status on
the GitHub Projects v2 board (Backlog / Ready / In progress / In review / Done), which is the
source of truth. A closed issue always resolves to Done, even when its Project item retains a
Expand All @@ -30,21 +30,23 @@ With `--apply`, each run:
the paginated AgilePlace connections endpoint. Links are removed only when both the native GitHub
sub-issue snapshot and the AgilePlace child snapshot are authoritative; either read failing makes
that epic add-only for the run. LeanKit then rolls child progress up to the parent on its own.
4. Marks a card blocked while any issue blocking it is not Done, and clears the blocked state
otherwise. A `NOT_PLANNED`/`DUPLICATE` blocker is known Done, so it cannot block dependents
forever; a blocker missing from the complete issue snapshot remains incomplete (fail-closed).
4. Mirrors GitHub blocked-by relationships as native AgilePlace card dependencies (blocker ->
blocked, Finish-to-Start), every edge included -- a Done or retired blocker's edge is structural,
and AgilePlace's own dependency health display shows satisfaction. Reconciliation is authoritative
only between two sync-managed cards, and a failed dependency read skips that card for the run.
The card's Blocked flag belongs to humans: the sync never writes `/isBlocked` or `/blockReason`.
5. Reconciles metadata and dates in both directions using a three-way merge against
`.sync-state.json`. GitHub labels and milestone sync with card tags, and removals carry over in
both directions. The Project's Start and Target date fields sync with the card's `plannedStart`
and `plannedFinish`; if both sides changed the same date, the AgilePlace value wins.

Field updates queued for an existing card -- lane, tags, dates, and blocked state -- are combined
Field updates queued for an existing card -- lane, tags, and dates -- are combined
into at most one versioned PATCH per run, so a stale write fails instead of silently overwriting
someone else's edit. A version-less card is refetched once; the PATCH proceeds only if every queued
field still matches that fresh snapshot, and otherwise the run aborts before saving merge state.
Card creation and hierarchy connections use separate POST/DELETE requests, and GitHub-side writes
are issued separately. Dry run is the default: `python sync.py` prints every planned action and
writes nothing.
Card creation, hierarchy connections, and dependencies use separate POST/DELETE requests, and
GitHub-side writes are issued separately. Dry run is the default: `python sync.py` prints every
planned action and writes nothing.

> Before the first live run: `API-VALIDATION.md` records which API shapes are confirmed and which
> still need a one-time check against the live APIs (the AgilePlace connection, blocked-state, tag,
Expand All @@ -59,9 +61,11 @@ AgilePlace *write* shapes. It previews the configured board first -- title, lane
currently on it -- and asks for typed confirmation before writing anything. It then creates two
clearly-marked throwaway cards (custom ids carry a fresh per-run suffix so they can never collide
with, or be adopted by, a real sync run), round-trips a tag add/remove, sets and clears blocked
state and planned dates, adds an external link to a bare card and reads it back, connects and
disconnects them as parent/child, sends a deliberately stale-version PATCH (which the server must
reject), and deletes both cards again, confirming each is gone. Every step is printed,
state and planned dates (the blocked round-trip validates API surface only -- the sync itself never
writes the flag), adds an external link to a bare card and reads it back, connects and disconnects
them as parent/child, round-trips a dependency create/read/delete, sends a deliberately
stale-version PATCH (which the server must reject), and deletes both cards again, confirming each
is gone. Every step is printed,
and a rejected write shows the server's full, untruncated response body so a wrong shape is
diagnosable from the console. The final summary maps one-to-one onto the `[live-check]` items in
`API-VALIDATION.md`. `--yes` skips the prompt. GitHub is never touched and `.sync-state.json` is
Expand Down Expand Up @@ -115,7 +119,7 @@ The account running the schedule needs `python` and `gh` on PATH, `gh auth login

- `sync.py`: orchestration. Creates, moves, connects, and blocks cards; reconciles tags and dates;
sends at most one field-update PATCH per existing card.
- `stages.py`: pure stage derivation, blocked-reason text, and lane/title matching (unit-tested).
- `stages.py`: pure stage derivation and lane/title matching (unit-tested).
- `reconcile.py`: pure three-way set and single-value merges (unit-tested).
- `ghkit.py`: GitHub via the `gh` CLI (issues, sub-issues, open-PR and blocked-by reads,
label/milestone writes).
Expand Down
69 changes: 68 additions & 1 deletion agileplace.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,12 @@ def ops_tag_remove(current_tags: list[str], tags_to_remove: set[str]) -> list[di


def op_planned_date(field: str, date: str | None) -> dict:
return {"op": "replace", "path": f"/{field}", "value": date} # field = plannedStart|plannedFinish
# field = plannedStart|plannedFinish. The server type-validates replace values on these paths as
# strings and 422s on null (observed live 2026-07-21, issue #52), so clearing must be a remove.
# Callers only queue a clear when the card currently has the date, so remove-on-absent can't occur.
if date is None:
return {"op": "remove", "path": f"/{field}"}
return {"op": "replace", "path": f"/{field}", "value": date}


def ops_blocked(blocked: bool, reason: str | None) -> list[dict]:
Expand Down Expand Up @@ -677,3 +682,65 @@ def disconnect_children(cfg: dict, apply: bool, parent_card_id: str, child_card_
mutate(cfg, apply, "DELETE", "card/connections",
body={"cardIds": [parent_card_id], "connections": {"children": ids}},
note=f"disconnect {parent_card_id} -> {len(ids)} child card(s)")


# --- dependencies (sequencing) -- shapes confirmed live 2026-07-21, issue #57 ---
# The endpoints are undocumented in the io v2 public docs; every shape below was captured from the
# UI and validated against the production tenant (API-VALIDATION.md "Dependencies API discovery").

DEPENDENCY_TIMING = "finishToStart"


def card_dependencies(cfg: dict, card_id: str) -> list[dict] | None:
"""The card's dependency entries ({direction, cardId, timing, ...}), or None when the read
fails or is unrecognized. None means UNKNOWN -- callers must skip reconciliation for the card
(fail closed), never treat it as an empty set. The confirmed response carries the whole list
with no pageMeta; if the server ever paginates this, the shape check below fails closed."""
try:
data = api(cfg, "GET", f"{_card_path(card_id)}/dependency")
except SystemExit as err:
print(f"WARN card {card_id} dependency read FAILED: {err} -- skipping dependency reconciliation")
return None
entries = data.get("dependencies") if isinstance(data, dict) else None
# Reject anything short of a complete, well-formed snapshot: a pageMeta member would mean
# the server started paginating (entries could be missing -> re-create -> 409 abort), and an
# entry without a usable direction/cardId hides state the diff would then act against.
if (not isinstance(data, dict) or "pageMeta" in data or not isinstance(entries, list)
or any(not _valid_dependency_entry(e) for e in entries)):
print(f"WARN card {card_id} dependency read returned an unrecognized shape -- "
"skipping dependency reconciliation")
return None
return entries


def _valid_dependency_entry(entry) -> bool:
return (isinstance(entry, dict)
and entry.get("direction") in {"incoming", "outgoing"}
and bool(entry.get("cardId")))


def incoming_dependency_ids(entries: list[dict]) -> set[str]:
"""Ids of the cards this card depends on (its blockers), from a card_dependencies read."""
return {str(entry["cardId"]) for entry in entries
if entry.get("direction") == "incoming" and entry.get("cardId")}


def create_dependencies(cfg: dict, apply: bool, card_id: str, depends_on_ids) -> None:
"""Make card_id depend on each of depends_on_ids (finish-to-start; they block it)."""
ids = sorted(str(i) for i in depends_on_ids if i)
if not ids:
return
mutate(cfg, apply, "POST", "card/dependency",
body={"cardIds": [str(card_id)], "dependsOnCardIds": ids, "timing": DEPENDENCY_TIMING},
note=f"depend {card_id} on {len(ids)} card(s)")


def delete_dependencies(cfg: dict, apply: bool, card_id: str, depends_on_ids) -> None:
"""Remove card_id's dependency on each of depends_on_ids (pair-addressed; no dependency ids
exist -- see API-VALIDATION.md)."""
ids = sorted(str(i) for i in depends_on_ids if i)
if not ids:
return
mutate(cfg, apply, "DELETE", "card/dependency",
body={"cardIds": [str(card_id)], "dependsOnCardIds": ids},
note=f"undepend {card_id} from {len(ids)} card(s)")
Loading