dsh-plugin-cicd 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -3
- package/client.js +703 -72
- package/index.js +280 -4
- package/lib/bilibili.mjs +182 -4
- package/package.json +1 -1
- package/tests/bilibili-check.mjs +52 -0
- package/tests/bump-e2e.mjs +79 -0
- package/tests/client-render.mjs +217 -1
- package/tests/host-checks.mjs +56 -1
- package/tests/mount-check.mjs +3 -0
package/index.js
CHANGED
|
@@ -667,6 +667,58 @@ function firstLine(value) {
|
|
|
667
667
|
/** How many changed file names the overview carries; the count is carried in full. */
|
|
668
668
|
const LOCAL_FILE_LIMIT = 20
|
|
669
669
|
|
|
670
|
+
/**
|
|
671
|
+
* Ask repeatedly whether a comment can be read back, and answer honestly either way.
|
|
672
|
+
*
|
|
673
|
+
* The reader is injected because the interesting part is the RETRY, and the route that
|
|
674
|
+
* uses it cannot be driven in a test — reaching a real post needs a readable release
|
|
675
|
+
* list, and the suite deliberately hands the Host a `gh` that cannot exist.
|
|
676
|
+
*
|
|
677
|
+
* @param {object} params - `{read, aid, rpid, attempts, delayMs, wait}`.
|
|
678
|
+
* @returns {Promise<{visible: boolean, attempts: number, code: number|null, message: string}>}
|
|
679
|
+
*/
|
|
680
|
+
export async function confirmVisible({
|
|
681
|
+
read,
|
|
682
|
+
aid,
|
|
683
|
+
rpid,
|
|
684
|
+
attempts = 3,
|
|
685
|
+
delayMs = 3000,
|
|
686
|
+
wait = (ms) => new Promise((settle) => setTimeout(settle, ms)),
|
|
687
|
+
}) {
|
|
688
|
+
let last = { code: null, message: '' }
|
|
689
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
690
|
+
const found = await read(aid, rpid)
|
|
691
|
+
if (found !== null && found !== undefined && found.ok === true && found.visible === true) {
|
|
692
|
+
return { visible: true, attempts: attempt, code: 0, message: '' }
|
|
693
|
+
}
|
|
694
|
+
last = { code: found?.code ?? null, message: String(found?.message ?? '') }
|
|
695
|
+
if (attempt < attempts) await wait(delayMs)
|
|
696
|
+
}
|
|
697
|
+
return { visible: false, attempts, code: last.code, message: last.message }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** A clone moves a repository, not a request: give it minutes, not seconds. */
|
|
701
|
+
const CLONE_TIMEOUT_MS = 180_000
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* The address `git clone` would use for a repository, when nothing better was given.
|
|
705
|
+
*
|
|
706
|
+
* Derived rather than demanded — the panel exists to remove steps, and "paste the clone
|
|
707
|
+
* URL of the repository you already registered" is one of them. A bare name needs
|
|
708
|
+
* `owner` from the config; a name written `owner/repo` does not.
|
|
709
|
+
*
|
|
710
|
+
* @param {string} owner - the configured owner, possibly ''.
|
|
711
|
+
* @param {string} repo - the repository id as configured.
|
|
712
|
+
* @returns {string} an https URL, or '' when there is nothing to derive it from.
|
|
713
|
+
*/
|
|
714
|
+
export function defaultCloneUrl(owner, repo) {
|
|
715
|
+
const name = text(repo).trim()
|
|
716
|
+
if (name === '') return ''
|
|
717
|
+
if (name.includes('/')) return `https://github.com/${name.replace(/^\/+|\/+$/g, '')}.git`
|
|
718
|
+
const login = text(owner).trim()
|
|
719
|
+
return login === '' ? '' : `https://github.com/${login}/${name}.git`
|
|
720
|
+
}
|
|
721
|
+
|
|
670
722
|
/**
|
|
671
723
|
* The path out of one `git status --porcelain` line.
|
|
672
724
|
*
|
|
@@ -1834,6 +1886,13 @@ export async function collectRepo({ config, ghPath, entry }) {
|
|
|
1834
1886
|
slug,
|
|
1835
1887
|
label: entry.label !== '' ? entry.label : entry.repo,
|
|
1836
1888
|
localPath: entry.localPath,
|
|
1889
|
+
/*
|
|
1890
|
+
* What a 克隆 would use, and where it would land. Sent with the overview so the
|
|
1891
|
+
* panel can offer the address pre-filled instead of asking someone to type a URL
|
|
1892
|
+
* the Host can already derive from the repository name.
|
|
1893
|
+
*/
|
|
1894
|
+
cloneUrl: defaultCloneUrl(config.owner, entry.repo),
|
|
1895
|
+
checkoutRoot: config.projectsRoot,
|
|
1837
1896
|
problems: [],
|
|
1838
1897
|
}
|
|
1839
1898
|
if (slug === null) {
|
|
@@ -3700,8 +3759,15 @@ export function apply(ctx, rawConfig) {
|
|
|
3700
3759
|
return
|
|
3701
3760
|
}
|
|
3702
3761
|
const outcome = mutateConfig(current, (draft) => {
|
|
3703
|
-
const entry = { repo, ...(localPath !== '' ? { localPath } : {}), label: '' }
|
|
3704
3762
|
const index = draft.repos.findIndex((candidate) => candidate.repo === repo)
|
|
3763
|
+
/* Merged into whatever is already there, not written over it: this route is also
|
|
3764
|
+
how a path gets set, and rebuilding the entry from scratch would drop the
|
|
3765
|
+
Bilibili binding — a video unbound as a side effect of editing a directory. */
|
|
3766
|
+
const entry = {
|
|
3767
|
+
...(index === -1 ? {} : draft.repos[index]),
|
|
3768
|
+
repo,
|
|
3769
|
+
...(localPath !== '' ? { localPath } : {}),
|
|
3770
|
+
}
|
|
3705
3771
|
if (index === -1) draft.repos.push(entry)
|
|
3706
3772
|
else draft.repos[index] = entry
|
|
3707
3773
|
return { ok: true, owner: draft.owner, repos: draft.repos }
|
|
@@ -3713,6 +3779,132 @@ export function apply(ctx, rawConfig) {
|
|
|
3713
3779
|
writeJson(res, 200, { ok: true, value: { repo, localPath, registered: outcome.count, file: current.configFile } })
|
|
3714
3780
|
}
|
|
3715
3781
|
|
|
3782
|
+
/**
|
|
3783
|
+
* Clone a registered repository into the checkout root, and record where it landed.
|
|
3784
|
+
*
|
|
3785
|
+
* The missing step for a repository that is registered but has no working tree:
|
|
3786
|
+
* everything the console does with one — the dirty chip, 提交, 构建, 发布 — needs it,
|
|
3787
|
+
* and until now the answer was "go and clone it yourself".
|
|
3788
|
+
*
|
|
3789
|
+
* The address is derived from the repository name unless one is given, and the panel
|
|
3790
|
+
* shows that same derivation pre-filled (`cloneUrl` in the overview), because being
|
|
3791
|
+
* asked to paste the clone URL of a repository the Host can already name is exactly
|
|
3792
|
+
* the kind of step this console exists to remove. The destination is
|
|
3793
|
+
* `projectsRoot/<name>`, which is where `findLocalCheckout` would look for it next.
|
|
3794
|
+
*/
|
|
3795
|
+
const cloneHandler = async (req, res) => {
|
|
3796
|
+
if (!guard(req, res)) return
|
|
3797
|
+
const body = await readJsonBody(req)
|
|
3798
|
+
const current = live()
|
|
3799
|
+
const target = findEntry(current, body)
|
|
3800
|
+
if (!target.ok) {
|
|
3801
|
+
writeJson(res, 400, { ok: false, code: 'bad-request', message: target.message })
|
|
3802
|
+
return
|
|
3803
|
+
}
|
|
3804
|
+
const entry = target.entry
|
|
3805
|
+
|
|
3806
|
+
/* Already there: say so rather than making a second copy beside the first. */
|
|
3807
|
+
if (entry.localPath !== '') {
|
|
3808
|
+
const existing = await readLocalState(entry.localPath, current.requestTimeoutMs)
|
|
3809
|
+
if (existing.available === true) {
|
|
3810
|
+
writeJson(res, 409, {
|
|
3811
|
+
ok: false,
|
|
3812
|
+
code: 'already-cloned',
|
|
3813
|
+
message: `这个仓库已经有本地检出了:${entry.localPath}`,
|
|
3814
|
+
value: { repo: entry.repo, path: entry.localPath },
|
|
3815
|
+
})
|
|
3816
|
+
return
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
const requestedUrl = text(body?.url).trim()
|
|
3821
|
+
const url = requestedUrl !== '' ? requestedUrl : defaultCloneUrl(current.owner, entry.repo)
|
|
3822
|
+
if (url === '') {
|
|
3823
|
+
writeJson(res, 400, {
|
|
3824
|
+
ok: false,
|
|
3825
|
+
code: 'no-url',
|
|
3826
|
+
message: '没有可用的仓库地址:填一个,或者把行配置写成 "owner/repo"',
|
|
3827
|
+
value: { repo: entry.repo },
|
|
3828
|
+
})
|
|
3829
|
+
return
|
|
3830
|
+
}
|
|
3831
|
+
/* A shell is never involved, so this is not about injection: it is about not asking
|
|
3832
|
+
git to fetch something that is not a repository address at all. */
|
|
3833
|
+
if (!/^(https?:\/\/|git@|ssh:\/\/|file:\/\/|\/|[A-Za-z]:[\\/])/.test(url)) {
|
|
3834
|
+
writeJson(res, 400, { ok: false, code: 'bad-url', message: `不像是 git 地址:${JSON.stringify(url)}`, value: { repo: entry.repo } })
|
|
3835
|
+
return
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3838
|
+
const requestedDirectory = text(body?.directory).trim()
|
|
3839
|
+
if (requestedDirectory !== '' && !isAbsolute(requestedDirectory)) {
|
|
3840
|
+
writeJson(res, 400, { ok: false, code: 'bad-request', message: 'directory must be absolute', value: { repo: entry.repo } })
|
|
3841
|
+
return
|
|
3842
|
+
}
|
|
3843
|
+
const directory = requestedDirectory !== ''
|
|
3844
|
+
? requestedDirectory
|
|
3845
|
+
: (current.projectsRoot === '' ? '' : join(current.projectsRoot, bareRepoName(entry.repo)))
|
|
3846
|
+
if (directory === '') {
|
|
3847
|
+
writeJson(res, 409, {
|
|
3848
|
+
ok: false,
|
|
3849
|
+
code: 'no-projects-root',
|
|
3850
|
+
message: '`projectsRoot` 还没设置,所以不知道该克隆到哪里:在 profile 的 cordis.patch.yml 里给它一个绝对路径,或者指定一个目录。',
|
|
3851
|
+
value: { repo: entry.repo },
|
|
3852
|
+
})
|
|
3853
|
+
return
|
|
3854
|
+
}
|
|
3855
|
+
if (existsSync(directory)) {
|
|
3856
|
+
let occupied = true
|
|
3857
|
+
try {
|
|
3858
|
+
occupied = readdirSync(directory).length > 0
|
|
3859
|
+
} catch {
|
|
3860
|
+
/* Unreadable counts as occupied: cloning into it could destroy something. */
|
|
3861
|
+
occupied = true
|
|
3862
|
+
}
|
|
3863
|
+
if (occupied === true) {
|
|
3864
|
+
writeJson(res, 409, {
|
|
3865
|
+
ok: false,
|
|
3866
|
+
code: 'target-exists',
|
|
3867
|
+
message: `${directory} 已经存在且不是空的;换一个目录,或者先把它移走。`,
|
|
3868
|
+
value: { repo: entry.repo, path: directory },
|
|
3869
|
+
})
|
|
3870
|
+
return
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
const cloned = await runTool('git', ['clone', '--', url, directory], CLONE_TIMEOUT_MS)
|
|
3875
|
+
if (cloned.ok !== true) {
|
|
3876
|
+
writeJson(res, 502, {
|
|
3877
|
+
ok: false,
|
|
3878
|
+
code: 'clone-failed',
|
|
3879
|
+
message: commandFailureLine(cloned, 'git clone failed'),
|
|
3880
|
+
value: { repo: entry.repo, url, path: directory },
|
|
3881
|
+
})
|
|
3882
|
+
return
|
|
3883
|
+
}
|
|
3884
|
+
const outcome = mutateConfig(current, (draft) => {
|
|
3885
|
+
const index = draft.repos.findIndex((candidate) => candidate.repo === entry.repo)
|
|
3886
|
+
if (index === -1) return { ok: false, message: `not registered: ${entry.repo}` }
|
|
3887
|
+
/* The rest of the entry is kept. A clone is about where the working tree is, and
|
|
3888
|
+
rebuilding the entry without its `bilibili` would unbind a video as a side
|
|
3889
|
+
effect of pointing the console at a checkout. */
|
|
3890
|
+
draft.repos[index] = { ...draft.repos[index], localPath: directory }
|
|
3891
|
+
return { ok: true, owner: draft.owner, repos: draft.repos }
|
|
3892
|
+
})
|
|
3893
|
+
cache = null
|
|
3894
|
+
if (outcome.ok !== true) {
|
|
3895
|
+
/* The clone is on disk; only the bookkeeping failed. Saying which is the
|
|
3896
|
+
difference between "clone it again" and "fix the config". */
|
|
3897
|
+
writeJson(res, 502, {
|
|
3898
|
+
ok: false,
|
|
3899
|
+
code: 'config-failed',
|
|
3900
|
+
message: `已克隆到 ${directory},但写不进配置:${outcome.message}`,
|
|
3901
|
+
value: { repo: entry.repo, path: directory, url },
|
|
3902
|
+
})
|
|
3903
|
+
return
|
|
3904
|
+
}
|
|
3905
|
+
writeJson(res, 200, { ok: true, value: { repo: entry.repo, path: directory, url, branch: null } })
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3716
3908
|
/** Unregister a repository. */
|
|
3717
3909
|
const configRemoveHandler = async (req, res) => {
|
|
3718
3910
|
if (!guard(req, res)) return
|
|
@@ -3920,16 +4112,75 @@ export function apply(ctx, rawConfig) {
|
|
|
3920
4112
|
}
|
|
3921
4113
|
}
|
|
3922
4114
|
|
|
4115
|
+
/**
|
|
4116
|
+
* Whether a comment Bilibili ACCEPTED can actually be read back.
|
|
4117
|
+
*
|
|
4118
|
+
* `code: 0` and an rpid mean the comment was taken, not that a reader can see it. Two
|
|
4119
|
+
* of these comments were recorded as announced and then answered `12006 没有该评论`
|
|
4120
|
+
* when looked up — a post that no one can find, filed as a success.
|
|
4121
|
+
*
|
|
4122
|
+
* Measured, so the waiting is not superstition: a freshly posted comment answers
|
|
4123
|
+
* `12006` immediately and reads back from about four seconds. A single immediate check
|
|
4124
|
+
* would therefore report a perfectly good comment as invisible, which is worse than not
|
|
4125
|
+
* checking at all — so it asks, waits, and asks again.
|
|
4126
|
+
*
|
|
4127
|
+
* @returns {Promise<{visible: boolean, attempts: number, code: number|null, message: string}>}
|
|
4128
|
+
*/
|
|
4129
|
+
const verifyVisible = async (cookie, aid, rpid, options = {}) => {
|
|
4130
|
+
const client = bilibiliClient()
|
|
4131
|
+
return await confirmVisible({
|
|
4132
|
+
read: (video, comment) => client.readComment(cookie, video, comment),
|
|
4133
|
+
aid,
|
|
4134
|
+
rpid,
|
|
4135
|
+
...options,
|
|
4136
|
+
})
|
|
4137
|
+
}
|
|
4138
|
+
|
|
3923
4139
|
/** The comment one release would produce, in the configured template. */
|
|
3924
|
-
const composeForEntry = (current, entry, release) => composeComment({
|
|
4140
|
+
const composeForEntry = (current, entry, release, commits = null) => composeComment({
|
|
3925
4141
|
label: entry.label !== '' ? entry.label : entry.repo,
|
|
3926
4142
|
repo: entry.repo,
|
|
3927
4143
|
tag: typeof release?.tag === 'string' ? release.tag : '',
|
|
3928
4144
|
release,
|
|
4145
|
+
commits,
|
|
3929
4146
|
template: current.bilibiliTemplate,
|
|
3930
4147
|
date: new Date().toISOString().slice(0, 10),
|
|
3931
4148
|
})
|
|
3932
4149
|
|
|
4150
|
+
/**
|
|
4151
|
+
* What changed between the previous release and this one.
|
|
4152
|
+
*
|
|
4153
|
+
* The release itself cannot answer this for these repositories: their bodies are
|
|
4154
|
+
* exactly `**Full Changelog**: <url>`, because the release workflow creates them with
|
|
4155
|
+
* no notes. So the changes are read from the history GitHub already has — one compare
|
|
4156
|
+
* call between the two tags — which is also the only source that is true rather than
|
|
4157
|
+
* retyped.
|
|
4158
|
+
*
|
|
4159
|
+
* Best effort by design: an unreadable range is not a reason to hold back a comment
|
|
4160
|
+
* that the release itself can still describe, so every failure answers with no commits
|
|
4161
|
+
* and the composition falls back the way it always did.
|
|
4162
|
+
*
|
|
4163
|
+
* @returns {Promise<object[]>} compare entries, or an empty array.
|
|
4164
|
+
*/
|
|
4165
|
+
const readReleaseChanges = async (current, entry, release) => {
|
|
4166
|
+
const slug = resolveSlug(current.owner, entry.repo)
|
|
4167
|
+
const tag = typeof release?.tag === 'string' ? release.tag : ''
|
|
4168
|
+
if (slug === null || tag === '') return []
|
|
4169
|
+
const list = await ghJson(ghPath, ['api', `repos/${slug}/releases?per_page=30`], current.requestTimeoutMs)
|
|
4170
|
+
if (list.ok !== true || !Array.isArray(list.value)) return []
|
|
4171
|
+
const published = list.value
|
|
4172
|
+
.filter((item) => item !== null && typeof item === 'object' && item.draft !== true && typeof item.tag_name === 'string')
|
|
4173
|
+
/* Newest first, sorted here rather than trusted: the API happens to return that
|
|
4174
|
+
order, and the "previous release" this picks must not depend on a habit. */
|
|
4175
|
+
.sort((left, right) => String(right.created_at ?? '').localeCompare(String(left.created_at ?? '')))
|
|
4176
|
+
const at = published.findIndex((item) => item.tag_name === tag)
|
|
4177
|
+
const previous = at >= 0 ? published[at + 1] : undefined
|
|
4178
|
+
if (previous === undefined) return []
|
|
4179
|
+
const compare = await ghJson(ghPath, ['api', `repos/${slug}/compare/${previous.tag_name}...${tag}`], current.requestTimeoutMs)
|
|
4180
|
+
if (compare.ok !== true) return []
|
|
4181
|
+
return Array.isArray(compare.value?.commits) ? compare.value.commits : []
|
|
4182
|
+
}
|
|
4183
|
+
|
|
3933
4184
|
/**
|
|
3934
4185
|
* Post one release's update note, and record what happened either way.
|
|
3935
4186
|
*
|
|
@@ -3966,7 +4217,7 @@ export function apply(ctx, rawConfig) {
|
|
|
3966
4217
|
value: { repo: entry.repo, bvid: binding.bvid },
|
|
3967
4218
|
}
|
|
3968
4219
|
}
|
|
3969
|
-
const composed = composeForEntry(current, entry, release)
|
|
4220
|
+
const composed = composeForEntry(current, entry, release, await readReleaseChanges(current, entry, release))
|
|
3970
4221
|
const message = (text !== '' ? text : composed.text).trim()
|
|
3971
4222
|
if (message === '') {
|
|
3972
4223
|
return { ok: false, code: 'empty-comment', message: '评论内容是空的,没有发送。', value: { repo: entry.repo, tag: release.tag } }
|
|
@@ -3989,6 +4240,10 @@ export function apply(ctx, rawConfig) {
|
|
|
3989
4240
|
bvid: binding.bvid,
|
|
3990
4241
|
message,
|
|
3991
4242
|
})
|
|
4243
|
+
/* Accepted is not the same as readable. Ask, wait, ask again — see `verifyVisible`. */
|
|
4244
|
+
const verified = posted.ok === true && posted.rpid !== null
|
|
4245
|
+
? await verifyVisible(cookieHeader(cookies), video.aid, posted.rpid)
|
|
4246
|
+
: null
|
|
3992
4247
|
const previous = findLedgerEntry(read.ledger, entry.repo, release.tag)
|
|
3993
4248
|
const attempts = (Number.isFinite(previous?.attempts) ? Number(previous.attempts) : 0) + (posted.ok === true ? 0 : 1)
|
|
3994
4249
|
const record = {
|
|
@@ -4005,6 +4260,13 @@ export function apply(ctx, rawConfig) {
|
|
|
4005
4260
|
message: posted.message,
|
|
4006
4261
|
failure: posted.ok === true ? null : posted.failure.kind,
|
|
4007
4262
|
url: posted.ok === true && posted.rpid !== null ? `https://www.bilibili.com/video/${binding.bvid}/#reply${posted.rpid}` : null,
|
|
4263
|
+
/*
|
|
4264
|
+
* Whether a reader can find it, which is the fact the panel has been asserting
|
|
4265
|
+
* without knowing. `null` means the question was never asked (the post failed), and
|
|
4266
|
+
* an absent field means the same for entries written before this existed.
|
|
4267
|
+
*/
|
|
4268
|
+
visible: verified === null ? null : verified.visible === true,
|
|
4269
|
+
visibleCode: verified === null ? null : verified.code,
|
|
4008
4270
|
}
|
|
4009
4271
|
const stored = appendLedger(record)
|
|
4010
4272
|
if (posted.ok !== true) {
|
|
@@ -4027,6 +4289,12 @@ export function apply(ctx, rawConfig) {
|
|
|
4027
4289
|
account: account?.uname ?? '',
|
|
4028
4290
|
video: video.title,
|
|
4029
4291
|
trigger,
|
|
4292
|
+
visible: record.visible,
|
|
4293
|
+
/* Said in the answer rather than buried in the ledger: the panel has to be able
|
|
4294
|
+
to tell "posted" from "posted and findable". */
|
|
4295
|
+
note: record.visible === true
|
|
4296
|
+
? ''
|
|
4297
|
+
: `已发送,但公开列表里读不到(B 站说:${String(verified?.message ?? '') === '' ? String(verified?.code ?? '') : String(verified?.message ?? '')})——可能还在审核,也可能已被移除。可以稍后用【仍然发送】重发。`,
|
|
4030
4298
|
},
|
|
4031
4299
|
}
|
|
4032
4300
|
}
|
|
@@ -4525,7 +4793,11 @@ export function apply(ctx, rawConfig) {
|
|
|
4525
4793
|
ledger: read.ledger,
|
|
4526
4794
|
force,
|
|
4527
4795
|
})
|
|
4528
|
-
|
|
4796
|
+
/* The preview and the post read the same changes, so the sentence the panel shows is
|
|
4797
|
+
the sentence that goes out — including the commit list. */
|
|
4798
|
+
const composed = release === null
|
|
4799
|
+
? null
|
|
4800
|
+
: composeForEntry(current, target.entry, release, await readReleaseChanges(current, target.entry, release))
|
|
4529
4801
|
|
|
4530
4802
|
if (body?.dryRun === true) {
|
|
4531
4803
|
writeJson(res, 200, {
|
|
@@ -4592,6 +4864,10 @@ export function apply(ctx, rawConfig) {
|
|
|
4592
4864
|
[`${ROUTE_PREFIX}/auth-cancel`, authCancelHandler],
|
|
4593
4865
|
[`${ROUTE_PREFIX}/auth-logout`, authLogoutHandler],
|
|
4594
4866
|
[`${ROUTE_PREFIX}/repos-available`, reposAvailableHandler],
|
|
4867
|
+
// A registered repository with no working tree cannot be released from here, so the
|
|
4868
|
+
// console can fetch one: the address is derivable, and the destination is the root
|
|
4869
|
+
// the rest of the panel already looks in.
|
|
4870
|
+
[`${ROUTE_PREFIX}/clone`, cloneHandler],
|
|
4595
4871
|
[`${ROUTE_PREFIX}/config-add`, configAddHandler],
|
|
4596
4872
|
[`${ROUTE_PREFIX}/config-remove`, configRemoveHandler],
|
|
4597
4873
|
// The third channel: an update note under the video that introduces the
|
package/lib/bilibili.mjs
CHANGED
|
@@ -42,8 +42,51 @@ export const REPLY_TYPE_VIDEO = 1
|
|
|
42
42
|
* and a filtered comment looks exactly like a posted one from this side. The
|
|
43
43
|
* release link is still available as `{url}` for anyone who wants it.
|
|
44
44
|
*/
|
|
45
|
+
/**
|
|
46
|
+
* `{summary}` and not `{changes}`: the summary is the one that prefers whatever the
|
|
47
|
+
* release itself says, and only reaches for the commit list when the release is silent —
|
|
48
|
+
* which is what "介绍这次更新" wants in both cases. A template that would rather always
|
|
49
|
+
* show the commit subjects can say `{changes}`.
|
|
50
|
+
*/
|
|
45
51
|
export const DEFAULT_TEMPLATE = '【更新 {tag}】{summary}'
|
|
46
52
|
|
|
53
|
+
/**
|
|
54
|
+
* How long a list of changes may be. Longer than a title, because this is the part that
|
|
55
|
+
* answers "更新了什么", and shorter than a comment can hold, because it is one line of
|
|
56
|
+
* a chat box rather than a changelog.
|
|
57
|
+
*/
|
|
58
|
+
export const MAX_CHANGES_LENGTH = 240
|
|
59
|
+
|
|
60
|
+
/** What to say when the range held commits and none of them is news. */
|
|
61
|
+
export const MAINTENANCE_NOTE = '本次更新为发布流程与工程维护,功能未变'
|
|
62
|
+
|
|
63
|
+
/** Conventional-commit types, as words a viewer reads. */
|
|
64
|
+
const CHANGE_LABELS = [
|
|
65
|
+
[/^feat(\(.+\))?!?:\s*/i, '新功能'],
|
|
66
|
+
[/^fix(\(.+\))?!?:\s*/i, '修复'],
|
|
67
|
+
[/^perf(\(.+\))?!?:\s*/i, '性能'],
|
|
68
|
+
[/^refactor(\(.+\))?!?:\s*/i, '重构'],
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Types that describe the work rather than the plugin — including `docs`, which put
|
|
73
|
+
* "document the release procedure" in front of a viewer who came for the feature list.
|
|
74
|
+
*/
|
|
75
|
+
const BOOKKEEPING_SUBJECT = /^(ci|chore|test|build|style|revert|release|docs)(\(.+\))?!?:\s*/i
|
|
76
|
+
|
|
77
|
+
/** `type(scope)!:` — the repository's convention, addressed to its authors. */
|
|
78
|
+
const CONVENTIONAL_PREFIX = /^[A-Za-z]+(\([^)]*\))?!?:\s*/
|
|
79
|
+
|
|
80
|
+
/** Sort key for a list that has room for three: what is new, before what was fixed. */
|
|
81
|
+
function changeRank(entry) {
|
|
82
|
+
const raw = typeof entry === 'string' ? entry : String(entry?.commit?.message ?? '')
|
|
83
|
+
const subject = raw.split(/\r?\n/)[0]
|
|
84
|
+
if (/^(feat|perf)(\(.+\))?!?:\s*/i.test(subject)) return 0
|
|
85
|
+
if (/^(fix|refactor)(\(.+\))?!?:\s*/i.test(subject)) return 1
|
|
86
|
+
/* A subject with no conventional type is prose somebody wrote on purpose. */
|
|
87
|
+
return 0
|
|
88
|
+
}
|
|
89
|
+
|
|
47
90
|
/** The placeholders a template may use. Anything else is reported, not eaten. */
|
|
48
91
|
export const TEMPLATE_KEYS = ['tag', 'version', 'label', 'repo', 'title', 'summary', 'url', 'date']
|
|
49
92
|
|
|
@@ -270,11 +313,21 @@ function isBoilerplate(line) {
|
|
|
270
313
|
* @param {object} [options] - `{maxLength, fallback}`.
|
|
271
314
|
* @returns {string} a plain-text summary, never longer than `maxLength`.
|
|
272
315
|
*/
|
|
273
|
-
export function summarizeRelease(release, { maxLength = MAX_SUMMARY_LENGTH, fallback = '' } = {}) {
|
|
316
|
+
export function summarizeRelease(release, { commits = null, names = [], maxLength = MAX_SUMMARY_LENGTH, fallback = '' } = {}) {
|
|
274
317
|
const tag = typeof release?.tag === 'string' ? release.tag : ''
|
|
275
318
|
const version = tag.replace(/^v/, '')
|
|
276
319
|
const name = stripMarkdown(release?.name)
|
|
277
|
-
const nameIsNoise = name === '' || name === tag || name === version || name === `v${version}`
|
|
320
|
+
const nameIsNoise = name === '' || name === tag || name === version || name === `v${version}` ||
|
|
321
|
+
/* `<repo> v1.0.1` says exactly as much as `v1.0.1` does, and the release workflow
|
|
322
|
+
names every release that way — so treating it as a summary buries every real
|
|
323
|
+
change behind the one line that carries none. Which is what it did: the comments
|
|
324
|
+
read "【更新 v1.0.1】<repo> v1.0.1" and the commit list was never reached. */
|
|
325
|
+
names.some((candidate) => {
|
|
326
|
+
const base = stripMarkdown(candidate)
|
|
327
|
+
if (base === '') return false
|
|
328
|
+
return name === `${base} ${tag}` || name === `${base} ${version}` || name === `${base} v${version}` ||
|
|
329
|
+
name === `${base}: ${tag}` || name === `${base}: ${version}` || name === `${base}: v${version}`
|
|
330
|
+
})
|
|
278
331
|
if (!nameIsNoise) return clamp(name, maxLength)
|
|
279
332
|
|
|
280
333
|
const lines = String(release?.body ?? '')
|
|
@@ -283,9 +336,88 @@ export function summarizeRelease(release, { maxLength = MAX_SUMMARY_LENGTH, fall
|
|
|
283
336
|
.filter((line) => !isBoilerplate(line))
|
|
284
337
|
const joined = lines.slice(0, 3).join(' ').trim()
|
|
285
338
|
if (joined !== '') return clamp(joined, maxLength)
|
|
339
|
+
|
|
340
|
+
/*
|
|
341
|
+
* The commits, when the release says nothing itself.
|
|
342
|
+
*
|
|
343
|
+
* This is the normal case for these repositories: their releases carry a body of
|
|
344
|
+
* exactly `**Full Changelog**: <url>` and nothing else, because the release workflow
|
|
345
|
+
* creates them without notes. So "介绍这次更新" cannot come from the release — the
|
|
346
|
+
* only place the changes exist is the history between the two tags.
|
|
347
|
+
*/
|
|
348
|
+
const fromCommits = summarizeCommits(commits, { maxLength: MAX_CHANGES_LENGTH })
|
|
349
|
+
if (fromCommits !== '') return fromCommits
|
|
286
350
|
return clamp(stripMarkdown(fallback), maxLength)
|
|
287
351
|
}
|
|
288
352
|
|
|
353
|
+
/**
|
|
354
|
+
* What changed, out of the commits between two releases.
|
|
355
|
+
*
|
|
356
|
+
* Subjects only, and the ones a reader of a video comment would recognise: a merge
|
|
357
|
+
* commit, a release chore or a `[skip ci]` marker is bookkeeping, not news. Joined with
|
|
358
|
+
* `;` rather than newlines because a Bilibili comment is a chat box, not a changelog.
|
|
359
|
+
*
|
|
360
|
+
* @param {unknown} commits - GitHub compare entries (`{commit: {message}, author}`) or plain strings.
|
|
361
|
+
* @param {object} [options] - `{max, maxLength}`.
|
|
362
|
+
* @returns {string} the summary, empty when nothing survives the filter.
|
|
363
|
+
*/
|
|
364
|
+
export function summarizeCommits(commits, { max = 3, maxLength = MAX_CHANGES_LENGTH } = {}) {
|
|
365
|
+
const list = Array.isArray(commits) ? commits : []
|
|
366
|
+
if (list.length === 0) return ''
|
|
367
|
+
const seen = new Set()
|
|
368
|
+
const kept = []
|
|
369
|
+
/*
|
|
370
|
+
* Newest first, and features before fixes.
|
|
371
|
+
*
|
|
372
|
+
* The compare API hands them oldest first, and taking the first three of that put the
|
|
373
|
+
* EARLIEST work of a release in the comment — for v0.5.0, three npm details, while the
|
|
374
|
+
* 提交 button, the Bilibili note and the one-page settings that the release is actually
|
|
375
|
+
* about were cut off the end. A version culminates in what it is for, and among equals
|
|
376
|
+
* a feature is what "what's new" means.
|
|
377
|
+
*/
|
|
378
|
+
const ordered = [...list].reverse().sort((left, right) => changeRank(left) - changeRank(right))
|
|
379
|
+
for (const entry of ordered) {
|
|
380
|
+
const raw = typeof entry === 'string' ? entry : String(entry?.commit?.message ?? '')
|
|
381
|
+
const subject = raw.split(/\r?\n/)[0].trim()
|
|
382
|
+
if (subject === '') continue
|
|
383
|
+
if (/^merge\b/i.test(subject)) continue
|
|
384
|
+
/* A build tweak is not a change a viewer of the video can see. Filtering these out
|
|
385
|
+
is the difference between "what is new" and a wall of `ci:` — which is what the
|
|
386
|
+
first version of this produced, cut off mid-sentence at 90 characters. */
|
|
387
|
+
if (BOOKKEEPING_SUBJECT.test(subject)) continue
|
|
388
|
+
if (/\[skip ci\]/i.test(subject)) continue
|
|
389
|
+
let clean = stripMarkdown(subject).replace(/\s*\(#\d+\)\s*$/, '').trim()
|
|
390
|
+
if (clean === '') continue
|
|
391
|
+
/*
|
|
392
|
+
* `fix: stop the flashing` reads as a changelog line to its author and as jargon to
|
|
393
|
+
* everyone else, so a known type becomes a word. An UNKNOWN one still loses its
|
|
394
|
+
* prefix — `polish(bilibili): size the code` is a sentence with a scope in front of
|
|
395
|
+
* it, and the scope belongs to the repository's conventions, not to the viewer. The
|
|
396
|
+
* message itself is left exactly as written: these histories are in English, and
|
|
397
|
+
* translating them by machine would be inventing meaning.
|
|
398
|
+
*/
|
|
399
|
+
let label = ''
|
|
400
|
+
for (const [pattern, text] of CHANGE_LABELS) {
|
|
401
|
+
if (pattern.test(clean)) {
|
|
402
|
+
label = text
|
|
403
|
+
break
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
clean = clean.replace(CONVENTIONAL_PREFIX, '').trim()
|
|
407
|
+
if (clean === '') continue
|
|
408
|
+
if (label !== '') clean = `${label}:${clean}`
|
|
409
|
+
if (seen.has(clean)) continue
|
|
410
|
+
seen.add(clean)
|
|
411
|
+
kept.push(clean)
|
|
412
|
+
if (kept.length >= Math.max(1, max)) break
|
|
413
|
+
}
|
|
414
|
+
/* Commits existed and none of them is news — that is itself worth saying, and it is
|
|
415
|
+
not the same as having no history to read (which answers '' and lets the caller fall
|
|
416
|
+
back). A version that only moved CI should not read as an empty announcement. */
|
|
417
|
+
if (kept.length === 0) return MAINTENANCE_NOTE
|
|
418
|
+
return clamp(kept.join(';'), maxLength)
|
|
419
|
+
}
|
|
420
|
+
|
|
289
421
|
/** Cut a string to a length, on a word boundary when there is one. */
|
|
290
422
|
function clamp(value, maxLength) {
|
|
291
423
|
const text = typeof value === 'string' ? value : ''
|
|
@@ -324,9 +456,16 @@ export function renderTemplate(template, vars) {
|
|
|
324
456
|
* @param {object} params - `{label, repo, tag, release, template, date, fallback}`.
|
|
325
457
|
* @returns {{text: string, unknown: string[], summary: string, truncated: boolean}} the comment and what it could not resolve.
|
|
326
458
|
*/
|
|
327
|
-
export function composeComment({ label = '', repo = '', tag = '', release = null, template = '', date = '', fallback = '' } = {}) {
|
|
459
|
+
export function composeComment({ label = '', repo = '', tag = '', release = null, template = '', date = '', fallback = '', commits = null } = {}) {
|
|
328
460
|
const effectiveTag = tag !== '' ? tag : (typeof release?.tag === 'string' ? release.tag : '')
|
|
329
|
-
|
|
461
|
+
/* `{changes}` is the same material `{summary}` falls back to, exposed on its own so a
|
|
462
|
+
template can say "本版更新:{changes}" without depending on what the release body
|
|
463
|
+
happened to contain. */
|
|
464
|
+
const changes = summarizeCommits(commits)
|
|
465
|
+
/* The names a release title could be made of, so `dsh-ui-sound v0.1.1` counts as the
|
|
466
|
+
tag it repeats rather than as a description of the change. */
|
|
467
|
+
const names = [label !== '' ? label : repo, repo, String(repo).slice(String(repo).lastIndexOf('/') + 1)]
|
|
468
|
+
const summary = summarizeRelease({ ...release, tag: effectiveTag }, { commits, names, fallback })
|
|
330
469
|
const rendered = renderTemplate(template, {
|
|
331
470
|
tag: effectiveTag,
|
|
332
471
|
version: effectiveTag.replace(/^v/, ''),
|
|
@@ -334,6 +473,7 @@ export function composeComment({ label = '', repo = '', tag = '', release = null
|
|
|
334
473
|
repo,
|
|
335
474
|
title: stripMarkdown(release?.name),
|
|
336
475
|
summary,
|
|
476
|
+
changes,
|
|
337
477
|
url: typeof release?.url === 'string' ? release.url : '',
|
|
338
478
|
date,
|
|
339
479
|
})
|
|
@@ -755,6 +895,44 @@ export function createBilibiliClient({ fetchImpl = globalThis.fetch, timeoutMs =
|
|
|
755
895
|
}
|
|
756
896
|
},
|
|
757
897
|
|
|
898
|
+
/**
|
|
899
|
+
* Read one comment back by its id.
|
|
900
|
+
*
|
|
901
|
+
* The only way to know a comment is really there: `x/v2/reply/add` answers `code: 0`
|
|
902
|
+
* with an rpid for a comment the public listing may not serve at all, and a removed
|
|
903
|
+
* comment and one still in review are indistinguishable through this endpoint —
|
|
904
|
+
* `12006 没有该评论` is Bilibili's answer to both. So the answer here is deliberately
|
|
905
|
+
* two-valued: it is readable, or it is not, and why not is Bilibili's own sentence.
|
|
906
|
+
*
|
|
907
|
+
* @param {string} cookie - the `Cookie:` header value.
|
|
908
|
+
* @param {number} aid - the video's oid.
|
|
909
|
+
* @param {string} rpid - the comment to look for.
|
|
910
|
+
* @returns {Promise<object>} `{ok, visible, code, message, text}`.
|
|
911
|
+
*/
|
|
912
|
+
async readComment(cookie, aid, rpid) {
|
|
913
|
+
const query = `type=1&oid=${encodeURIComponent(String(aid))}&root=${encodeURIComponent(String(rpid))}&ps=20&pn=1`
|
|
914
|
+
const result = await get(`https://api.bilibili.com/x/v2/reply/reply?${query}`, {
|
|
915
|
+
cookie,
|
|
916
|
+
referer: 'https://www.bilibili.com/',
|
|
917
|
+
})
|
|
918
|
+
if (result.payload === null) {
|
|
919
|
+
return { ok: false, visible: false, code: null, message: result.error ?? `HTTP ${String(result.status)}`, text: '' }
|
|
920
|
+
}
|
|
921
|
+
const payload = result.payload
|
|
922
|
+
if (payload.code !== 0) {
|
|
923
|
+
return { ok: true, visible: false, code: Number(payload.code), message: String(payload.message ?? ''), text: '' }
|
|
924
|
+
}
|
|
925
|
+
const root = payload.data?.root ?? null
|
|
926
|
+
const visible = root !== null && root !== undefined && String(root.rpid ?? '') === String(rpid)
|
|
927
|
+
return {
|
|
928
|
+
ok: true,
|
|
929
|
+
visible,
|
|
930
|
+
code: 0,
|
|
931
|
+
message: visible ? '' : '这条评论不在公开列表里',
|
|
932
|
+
text: String(root?.content?.message ?? ''),
|
|
933
|
+
}
|
|
934
|
+
},
|
|
935
|
+
|
|
758
936
|
/**
|
|
759
937
|
* A device id, best effort.
|
|
760
938
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-cicd",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "发布台(Release Console)for DeepSeek Harness: a sidebar entry and a full main-panel page that show what each repository has built, what it has released, whether the local checkout is ahead of the release, whether the copy installed in this profile is the published artifact, and which version npm holds — with buttons to trigger a build, cut a release, publish a draft, push that version to npm, install the release into the profile, and hand a restart to dsh-plugin-restart. It also carries the third channel: when a repository's release goes public, one short update note is posted under the video that introduces the plugin, with the credential obtained from a Bilibili sign-in inside the panel. Reuses the machine's authenticated gh CLI and the Host's own package manager, so the plugin stores no GitHub or npm credential of its own; the one credential it does own is the Bilibili session it created, kept in its own directory and never echoed back.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"icon": "./icon.svg",
|