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/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
- const composed = release === null ? null : composeForEntry(current, target.entry, release)
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
- const summary = summarizeRelease({ ...release, tag: effectiveTag }, { fallback })
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.0",
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",