mirror of
https://github.com/awesome-dsh-plugin/awesome-dsh-plugin.git
synced 2026-09-28 13:23:16 +08:00
A plugin's thread is found by a search with two hard requirements: the discussion sits in the plugin category, and its body carries giscus's `sha1(term)` marker. A thread created by hand in the GitHub UI has neither, so the widget finds nothing, quietly opens a second thread, and the conversation is split across two discussions that each look fine alone. scripts/adopt-discussions.mjs appends the marker and moves the thread into the category. A workflow runs it on `discussion: created` and weekly over the backlog — which is also what re-links a thread whose repository has been renamed, since a rename moves the slug the widgets ask for. It never deletes, merges or retitles: the marker is the only thing the widgets read, and a title is what its author called their thread. Threads the plugin has left behind (moved into a subdirectory, or taken over) are reported instead: only a person can tell a rename from a different plugin at the same address. The same work fixes a second, quieter split. This builder lowercased the term from the day comments shipped until today, so every entry whose owner or repo name carries a capital letter filed its catalog thread under one case and its market thread under another — #5647 asks for `plugin:featherhunter/dsh-prompt` at giscus and gets a second thread from the market, which asks for `plugin:FeatherHunter/dsh-prompt`. The term now comes from one derivation shared with the sweep (scripts/lib/terms.mjs), and it is the slug as published, because that slug is also the detail page's path — a canonical URL, and the only other thing that would break with it. Backlog run, verified through giscus's own API (each was 404 before): 4 threads adopted — #5795 (hand-made, in the wrong category), #5779, #5647, #3520 (renamed repositories) — and 3 reported for a human.
128 lines
5.7 KiB
JavaScript
128 lines
5.7 KiB
JavaScript
// Deciding what an existing discussion needs to become readable again.
|
|
//
|
|
// A widget finds a plugin's thread by asking giscus for a term, and giscus
|
|
// answers with a search that has two hard requirements (both measured, see
|
|
// adopt-discussions.mjs): the discussion's CATEGORY has to be the configured
|
|
// one, and its BODY has to contain `<!-- sha1: <sha1(term)> -->` because
|
|
// `strict` matching hashes the term and looks for it in the body. A thread a
|
|
// human created in the GitHub UI satisfies neither, so every surface shows an
|
|
// empty box, giscus quietly opens a second thread, and the conversation is
|
|
// split in two without anyone being told.
|
|
//
|
|
// Nothing here talks to GitHub: the rules are pure so they can be tested
|
|
// against the shapes that actually occur (a hand-made thread, a thread the
|
|
// catalog's lowercasing era filed under a different case, one whose repository
|
|
// was renamed out from under it) rather than against a stub's idea of them.
|
|
|
|
import { createHash } from 'node:crypto'
|
|
|
|
/**
|
|
* A discussion title that names a plugin and nothing else.
|
|
*
|
|
* Human topics are the majority of this repository's discussions ("为什么左侧
|
|
* 的工作区无法显示壁纸"), and an adoption sweep must never touch them: it
|
|
* rewrites bodies, and a body is somebody's writing.
|
|
*/
|
|
export const PLUGIN_TERM_RE = /^plugin:[^\s/]+\/[^\s/]+(?:--\S+)?$/u
|
|
|
|
export const isPluginTerm = (title) => PLUGIN_TERM_RE.test(title)
|
|
|
|
/** giscus's own marker computation, byte for byte: SHA-1 of the term, hex. */
|
|
export const sha1Hex = (text) => createHash('sha1').update(text).digest('hex')
|
|
|
|
export const markerFor = (term) => `<!-- sha1: ${sha1Hex(term)} -->`
|
|
|
|
const MARKER_RE = /<!--\s*sha1:\s*([0-9a-f]{40})\s*-->/iu
|
|
|
|
/** The marker already in a body, lowercased, or null. */
|
|
export function markerIn(body) {
|
|
const m = MARKER_RE.exec(body ?? '')
|
|
return m === null ? null : m[1].toLowerCase()
|
|
}
|
|
|
|
/**
|
|
* The catalog indexes the sweep needs, built from entries as they are read.
|
|
*
|
|
* `byRepo` is keyed by the repository the entry is published from, because
|
|
* that is what survives a rename: a transferred or renamed repository answers
|
|
* to its old name with a redirect, and the entry's URL may still carry the old
|
|
* name long after GitHub stopped using it (`zhu1090093659/dsh-web-ui` lives on
|
|
* in one entry whose repo is now spelled `dsh-web`).
|
|
*/
|
|
export function catalogIndex(entries) {
|
|
const bySlug = new Map()
|
|
const byRepo = new Map()
|
|
for (const entry of entries) {
|
|
const repoPath = entry.url.replace('https://github.com/', '')
|
|
const repo = repoPath.split('/').slice(0, 2).join('/')
|
|
const sub = repoPath.includes('/tree/') ? repoPath.split('/tree/')[1].replace(/^[^/]+\//, '') : null
|
|
const slug = sub ? `${repo}--${sub.replaceAll('/', '-')}` : repo
|
|
const record = { slug, repo, sub }
|
|
bySlug.set(slug, record)
|
|
const list = byRepo.get(repo.toLowerCase()) ?? []
|
|
list.push(record)
|
|
byRepo.set(repo.toLowerCase(), list)
|
|
}
|
|
return { bySlug, byRepo }
|
|
}
|
|
|
|
/**
|
|
* The term a thread should carry, judged by its title alone.
|
|
*
|
|
* `exact` — the catalog publishes that slug today.
|
|
* `case` — the catalog publishes the same slug in another case. That is the
|
|
* lowercasing era: the entry is the same plugin, and the thread is
|
|
* the one its page should be reading.
|
|
*/
|
|
export function resolveTerm(title, { bySlug }) {
|
|
const slug = title.slice('plugin:'.length)
|
|
if (bySlug.has(slug)) return { slug, how: 'exact' }
|
|
for (const candidate of bySlug.keys()) {
|
|
if (candidate.toLowerCase() === slug.toLowerCase()) return { slug: candidate, how: 'case' }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* The term for a thread whose slug the catalog no longer publishes, given the
|
|
* repository name GitHub resolves that slug to today.
|
|
*
|
|
* Only a same-shaped entry counts: the repository must match, and so must the
|
|
* subdirectory (both absent, or the same one). A repository that was renamed
|
|
* and reorganised — `kelaohu/dsh-lowtide--packages-dsh`, now published at
|
|
* `packages/dsh-lowtide` — is deliberately NOT re-linked: attaching one
|
|
* plugin's comments to another plugin's page is worse than leaving them where
|
|
* they are, and only a human can tell whether the move was a rename or a
|
|
* different plugin taking the old one's place. Those threads are reported.
|
|
*/
|
|
export function resolveRenamed(title, currentRepo, { byRepo }) {
|
|
const slug = title.slice('plugin:'.length)
|
|
const prefix = `${currentRepo}--`
|
|
const sub = slug.toLowerCase().startsWith(prefix.toLowerCase()) ? slug.slice(prefix.length) : null
|
|
const matches = (byRepo.get(currentRepo.toLowerCase()) ?? []).filter((entry) => entry.sub === sub)
|
|
return matches.length === 1 ? { slug: matches[0].slug, how: 'renamed' } : null
|
|
}
|
|
|
|
/**
|
|
* What to write, or null when the thread is already readable.
|
|
*
|
|
* Both edits are additive or corrective, never destructive: an existing marker
|
|
* is replaced (only when it names a different term — a thread the lowercasing
|
|
* era filed under another case keeps its body otherwise), the marker is
|
|
* appended when there is none, and the body as the author wrote it is left
|
|
* alone. Idempotent by construction: re-running on an adopted thread plans
|
|
* nothing.
|
|
*/
|
|
export function planAdoption({ desiredTerm, body, categoryId, pluginsCategoryId }) {
|
|
const changes = {}
|
|
if (markerIn(body) !== sha1Hex(desiredTerm)) {
|
|
const marker = markerFor(desiredTerm)
|
|
const written = (body ?? '').replace(/\s+$/u, '')
|
|
changes.body = markerIn(body) === null
|
|
? `${written === '' ? '' : `${written}\n\n`}${marker}\n`
|
|
: (body ?? '').replace(MARKER_RE, marker)
|
|
}
|
|
if (categoryId !== pluginsCategoryId) changes.categoryId = pluginsCategoryId
|
|
return Object.keys(changes).length === 0 ? null : changes
|
|
}
|