dsh-plugin-forge-cnb
CNB (cnb.cool) provider for the DSH forge registry. It registers a
ForgeProvider with ctx.forge, so the change request and working tree panel can show CNB merge
requests: state, additions/deletions, mergeability, checks, reviewers, diff, file contents, review
publication, and merge.
This is a host-side bundle with zero runtime dependencies: global fetch and Node built-ins only,
Node.js >= 22. No client or DSH package change is needed — registering the provider is enough.
What it adds
| contract method | CNB source |
|---|---|
repositoryFromRemote / changeRequestFromUrl / changeRequestUrl |
remote-URL and MR-URL parsing for https, ssh://, and scp-like remotes, nested groups (a/b/c), .git suffixes, ports; the two URL functions are exact inverses |
fetch |
GET /{repo}/-/pulls/{n} + GET /{repo}/-/pulls/{n}/commit-statuses, plus per-head-commit totals (…/files), head commit date (…/git/commits/{sha}), and cached repository merge settings |
fetchChecks |
the same reads, always with the complete check list (no 50-row polling cap) |
findByHead |
GET /{repo}/-/pulls?state=open&page_size=100&page=N, filtered client-side (CNB has no head-branch filter) |
diff |
GET /{repo}/-/pulls/{n}/files, assembled into one Git-style unified patch |
fileText / fileUrl |
GET /{repo}/-/git/raw/{commit}/{path} (falls back to the base64 contents endpoint on HTTP 413), and the /-/blob/{commit}/{path} web URL |
merge |
re-reads the MR, compares head.sha with the confirmed commit, then PUT /{repo}/-/pulls/{n}/merge with merge_style; never sends force |
markReady |
no API exists; returns a clear failure telling the user to edit the WIP title in the web UI |
submitReview |
POST /{repo}/-/pulls/{n}/reviews with event and inline comments |
readiness |
GET /user; an expired or missing token reads as unauthenticated and tells the user to run cnb login |
Install
This is a DSH bundle distributed from GitHub rather than npm. Clone it, then install the checked-out
directory as a bundle in your profile (for example with the Plugin Manager's install-bundle flow, or the
plugin_manager tool with action: install_bundle):
git clone https://github.com/genius-alray/dsh-plugin-forge-cnb
The bundle ships its own cordis.patch.yml, which inserts the provider row; you pick the bundle, not the
row. It needs Node.js >= 22 on the host and a valid cnb login session (~/.cnb/token, re-read whenever
the CLI refreshes it).
Enable it
The bundle ships a Cordis patch, so enabling it in the profile is one entry:
- insert:
- id: forge-cnb
name: 'dsh-plugin-forge-cnb'
config:
hosts: ['cnb.cool']
tokenFile: '~/.cnb/token'
timeoutMs: 10000
maxPages: 3
| config field | default | meaning |
|---|---|---|
hosts |
['cnb.cool'] |
web hosts this provider serves; repositories on other hosts are ignored |
tokenFile |
~/.cnb/token |
CNB CLI credential file; CNB_TOKEN in the environment overrides it |
timeoutMs |
10000 |
deadline of one HTTP request, combined with the caller's cancellation |
maxPages |
3 |
pages of 100 open MRs findByHead reads per lookup |
Nothing is written to any repository unless you explicitly merge or submit a review.
How a merge request binds to a session
The forge session layer discovers a session's change request by running git in the session
checkout: it maps the checkout's remote to a repository (repositoryFromRemote) and then asks
findByHead(repository, branch) for open MRs whose head is the current branch. Pasting a CNB MR URL
into the session binds it directly (changeRequestFromUrl), and bindings are re-resolved through
changeRequestUrl → changeRequestFromUrl, which is why those two are exact inverses.
gh command output parsing does not apply here — that is GitHub-CLI specific. For CNB the only
binding paths are the git remote/branch discovery and a pasted MR URL.
Credentials
The provider reads ~/.cnb/token (written by the official cnb CLI) and takes
.auths["cnb.cool"].access_token. The token is never printed or logged. A token lives ~8 hours:
- the file is re-read when its mtime/size changes, so a
cnb loginrefresh is picked up without a restart; - a 401 /
errcode: 16response is reported asunauthenticatedwith a message telling the user to runcnb login; CNB_TOKENcan supply a token for one process instead of the file.
Scopes used: repo-pr:r (MRs, statuses, files), repo-code:r (commit, raw file), repo-manage:r
(merge settings), repo-pr:rw (merge), repo-notes:rw (review). Missing scopes surface as HTTP 403
(errcode 10023) and are reported as unavailable/failed rather than crashing the panel.
Mapping decisions
updatedAt: CNB's MR detail carries no timestamps. It is the head commit's committer date (GET /{repo}/-/git/commits/{headSha}), cached per commit; when that read fails it is the first time this head commit was observed (stable across polls, not the time of every poll).closedAt: alwaysnull. CNB exposes noclosed_at/merged_aton merge requests at all.unresolvedThreads: always0. CNB has no resolved/unresolved flag on review comments anywhere in the API.- additions/deletions: sum of the MR's changed files. CNB caps that list (see limitations), so a very large MR's totals can read low; there is no MR-level statistics endpoint.
- mergeability:
conflict/no-merge-base(orblocked_on=code_conflict/no_merge_base) isconflicting;mergeable/merging/mergedismergeable; everything else isunknown. - mergeReady:
state == open && !is_wip && !is_merged && mergeable_state == mergeable && blocked_on == unblocked—mergeable_statealone is not enough (amergeableMR can still wait for review or CI). - reviewDecision:
changes_requestedon any reviewer wins, then all-approved isapproved, otherwisereview-required; no reviewers isnone. - checksRollup: CNB's aggregate
stateofcommit-statusesis used directly (success/failure/error/pending); individualsuccess|pending|failure|error|skippeditems become check rows withcreated_at/updated_atas start/completion times, capped at 50 in polling snapshots (checksTruncated) and complete infetchChecks. - mergeMethods: from
GET /{repo}/-/settings/pull-request(allow_merge_commit_merge,allow_squash_merge,allow_rebase_merge), cached per repository for 5 minutes; if it cannot be read (for example a token withoutrepo-manage:r), the default set[merge, squash, rebase]is offered so the merge button does not disappear. - review body:
POST /pulls/{n}/reviewstakesevent+ inlinecomments; an empty review body is omitted instead of sent as"". Inline comments use the same full repository paths asdiff().
Diff format
The panel parses diff() with jsdiff 9.0.0 (parsePatch), which resolves a file's path from
diff --git / --- / +++ headers and silently drops a section that has only hunks. CNB's
/pulls/{n}/files returns hunks only and reports the file basename in filename, so this provider:
- recovers the full repository-relative path from
blob_url/raw_url/contents_url(…/-/blob/{sha}/{path}), never fromfilename; - emits per file:
diff --git a/<path> b/<path>,new file mode 100644/deleted file mode 100644,--- a/<path>++++ b/<path>(or/dev/nullon the missing side), then the recorded hunks byte-for-byte, with LF line endings and a trailing newline; - renders an empty patch (
add/delete/modify) asBinary files … differ, which jsdiff records asisBinary, instead of an empty change; - keeps rename origins when a payload exposes
previous_filename; a CNB rename otherwise renders as a change of the new path.
This is covered by an offline unit test that parses the assembled patch with the real jsdiff copy when it is discoverable and skips otherwise.
Known limitations
- WIP cannot be cleared through the API:
is_wipis read-only on CNB, somarkReadyalways fails with a message asking the user to edit the title in the web UI. The provider never PATCHes the title. - The changed-file list is hard-capped at 300 entries even though CNB documents it as unpaginated
(a verified 490-file MR returns 300).
additions/deletionsanddiff()therefore cover at most 300 files for a very large MR; there is no truncation flag to expose and no workaround endpoint. - Rename origin is not recoverable from the MR file list; a rename renders as a change of the new path
(no
rename fromheader) unless the payload happens to carryprevious_filename. OnlyGET /{repo}/-/git/compare/{base}...{head}exposes it, and that endpoint shares the 300-file cap. - No close/merge timestamp (see
closedAt), so the panel cannot show when an MR was merged or closed. - Resolve state of review threads is unavailable (see
unresolvedThreads). - Merge refusal shape is unverified: CNB documents
403(die.WebError) but may answer200withmerged:false; both are handled asfailedwith the host message.fast_forwardis a valid CNBmerge_stylebut not part of the forge contract's method set, so it is not offered. - Fork merge requests: CNB bakes the base repository path plus the head commit into the file URLs,
and the base repository refuses a raw read of a foreign commit (HTTP 400
errcode 3). The provider detects a fork viahead.repo.id !== base.repo.id(or differing paths), caches the head repository per change request, and readsfileText/fileUrlfromhead.repo.path. Before the first poll it learns the head repository from one MR detail read when the base read fails. - The diff/hunks are whatever the host returns: this provider does not re-number hunks or trim lines, because a count mismatch would make the panel's parser throw.
- Custom hosts: only
cnb.coolis tested; for another host the API base is derived ashttps://api.<host>and the token must exist under that host key in the token file. - No rate-limit headers are exposed by CNB; the provider does no adaptive throttling.
Development
npm install # dev dependency: jsdiff, the same parser the panel host uses
npm test # 85 offline unit tests (fixtures + fake fetch); live test skipped
npm run check # node --check on every source and test file
The unit tests never touch the network: they load the synthetic responses under tests/fixtures/ and inject
a fake fetch. Two format tests parse the assembled patch with the panel's own jsdiff copy; they skip when
the dev dependency is not installed. Write paths (merge, review) are covered only by fake-fetch tests; this
package was never used to write to a real repository.
An optional read-only smoke test runs against any repository and merge request you can read:
CNB_LIVE=1 CNB_LIVE_REPO=owner/repo CNB_LIVE_MR=12 node --test tests/live.test.js
No comments yet. Be the first to write one.