dsh-plugin-forge-cnb
GitHub: https://github.com/genius-alray/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 login refresh is picked up without a restart;
- a 401 /
errcode: 16 response is reported as unauthenticated with a message telling the user to run
cnb login;
CNB_TOKEN can 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: always null. CNB exposes no closed_at/merged_at on merge requests at all.
unresolvedThreads: always 0. 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 (or blocked_on = code_conflict/no_merge_base) is
conflicting; mergeable/merging/merged is mergeable; everything else is unknown.
- mergeReady:
state == open && !is_wip && !is_merged && mergeable_state == mergeable && blocked_on == unblocked — mergeable_state alone is not enough (a mergeable MR can still wait for
review or CI).
- reviewDecision:
changes_requested on any reviewer wins, then all-approved is approved, otherwise
review-required; no reviewers is none.
- checksRollup: CNB's aggregate
state of commit-statuses is used directly (success/failure/
error/pending); individual success|pending|failure|error|skipped items become check rows with
created_at/updated_at as start/completion times, capped at 50 in polling snapshots
(checksTruncated) and complete in fetchChecks.
- 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 without repo-manage:r), the default set [merge, squash, rebase] is offered so
the merge button does not disappear.
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 from filename;
- emits per file:
diff --git a/<path> b/<path>, new file mode 100644/deleted file mode 100644,
--- a/<path> + +++ b/<path> (or /dev/null on 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) as Binary files … differ, which jsdiff records as
isBinary, 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_wip is read-only on CNB, so markReady always 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/deletions and diff() 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 from header) unless the payload happens to carry previous_filename. Only
GET /{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 answer 200 with
merged:false; both are handled as failed with the host message. fast_forward is a valid CNB
merge_style but 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 via head.repo.id !== base.repo.id (or differing paths), caches the head repository per
change request, and reads fileText/fileUrl from head.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.cool is tested; for another host the API base is derived as
https://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