mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 07:21:33 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af67c53dd6 | |||
|
ea79e331fa
|
|||
| ee89b0c8a9 | |||
| f2fd58cd2b |
@@ -31,6 +31,6 @@ If docs-site/ changed, also: bun run docs:test && bun run docs:build
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Added a changelog fragment, or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
|
||||
- [ ] Reconciled current-outcome changelog fragment(s), or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
|
||||
- [ ] Docs updated in the same PR if behavior, defaults, flags, shortcuts, ports, or APIs changed
|
||||
- [ ] Relevant checks pass locally (typecheck, tests, build)
|
||||
|
||||
@@ -68,7 +68,7 @@ Start here, then leave this file.
|
||||
|
||||
## Release / PR Notes
|
||||
|
||||
- User-visible PRs need one fragment in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; apply the `skip-changelog` label to opt out)
|
||||
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out)
|
||||
- User-visible docs changes get a `type: docs` fragment
|
||||
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
|
||||
- PR review helpers:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
|
||||
.PHONY: help submodules deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
|
||||
|
||||
APP_NAME := subminer
|
||||
THEME_SOURCE := assets/themes/subminer.rasi
|
||||
@@ -72,7 +72,8 @@ help:
|
||||
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
||||
"" \
|
||||
"Other targets:" \
|
||||
" deps Install JS dependencies (root + stats + texthooker-ui)" \
|
||||
" submodules Initialize/update git submodules" \
|
||||
" deps Initialize submodules and install JS dependencies (root + stats + texthooker-ui)" \
|
||||
" uninstall-linux Remove Linux install artifacts" \
|
||||
" uninstall-macos Remove macOS install artifacts" \
|
||||
" uninstall-windows Remove Windows mpv plugin artifacts" \
|
||||
@@ -105,8 +106,10 @@ print-dirs:
|
||||
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \
|
||||
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
|
||||
|
||||
deps:
|
||||
@$(MAKE) --no-print-directory ensure-bun
|
||||
submodules:
|
||||
@git submodule update --init --recursive
|
||||
|
||||
deps: submodules ensure-bun
|
||||
@bun install
|
||||
@cd stats && bun install --frozen-lockfile
|
||||
@cd vendor/texthooker-ui && bun install --frozen-lockfile
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: release
|
||||
|
||||
- Release notes now credit contributors with a `What's Changed` list (`by @author in #pr`) and a `New Contributors` section for first-time authors, resolved from changelog fragments via git and the GitHub API.
|
||||
@@ -31,6 +31,13 @@ Rules:
|
||||
- `README.md` is ignored by the generator
|
||||
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
|
||||
|
||||
PR branch workflow:
|
||||
|
||||
- Before adding a fragment or bullet, inspect the `changes/*.md` files already changed in the PR
|
||||
- If the new work fixes, modifies, renames, or supersedes behavior introduced or referenced by that fragment, edit or remove the stale bullet instead of adding follow-up churn
|
||||
- Add a new bullet only when it describes a truly separate user-visible outcome
|
||||
- Multiple fragment files are allowed when one PR has genuinely separate release-note outcomes, but keep them minimized and current
|
||||
|
||||
How fragments turn into a release:
|
||||
|
||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Changed PR changelog guidance to preserve multiple fragments for genuinely separate outcomes while directing contributors to update, remove, or merge same-PR fragment notes before adding follow-up churn.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Restarted the Jellyfin remote session after successful setup login so websocket reconnects use the freshly saved credentials.
|
||||
- Stopped the Jellyfin remote session on setup logout.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: build
|
||||
|
||||
- Updated `make deps` so a fresh source checkout initializes submodules before installing root, stats, and texthooker-ui dependencies.
|
||||
@@ -11,15 +11,10 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
# if you cloned without --recurse-submodules:
|
||||
git submodule update --init --recursive
|
||||
|
||||
bun install
|
||||
(cd stats && bun install --frozen-lockfile)
|
||||
(cd vendor/texthooker-ui && bun install --frozen-lockfile)
|
||||
make deps
|
||||
```
|
||||
|
||||
`make deps` is still available as a convenience wrapper around the same dependency install flow.
|
||||
`make deps` initializes submodules and installs root, `stats/`, and `vendor/texthooker-ui` dependencies. The Yomitan submodule installs its own dependencies on demand during `bun run build`.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -216,7 +211,7 @@ Run `make help` for a full list of targets. Key ones:
|
||||
| `make build` | Build platform package for detected OS |
|
||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||
| `make deps` | Init submodules and install root/stats/texthooker-ui deps |
|
||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||
| `make generate-config` | Generate default config from centralized registry |
|
||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||
|
||||
@@ -185,7 +185,7 @@ Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
bun install
|
||||
make deps
|
||||
bun run build
|
||||
|
||||
# Optional: build AppImage
|
||||
@@ -202,7 +202,7 @@ Bundled Yomitan is built during `bun run build`.
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
git submodule update --init --recursive
|
||||
make deps
|
||||
make build-macos
|
||||
```
|
||||
|
||||
@@ -216,14 +216,14 @@ The built app will be in the `release` directory (`.dmg` and `.zip`). For unsign
|
||||
```powershell
|
||||
git clone https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
git submodule update --init --recursive
|
||||
bun install
|
||||
|
||||
# Windows requires building texthooker-ui manually before the main build
|
||||
Set-Location vendor/texthooker-ui
|
||||
Set-Location stats
|
||||
bun install --frozen-lockfile
|
||||
Set-Location ../vendor/texthooker-ui
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
Set-Location ../..
|
||||
|
||||
bun run build:win
|
||||
```
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ Notes:
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## What’s Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
||||
|
||||
@@ -488,7 +488,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
||||
changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }],
|
||||
changedLabels: [],
|
||||
}),
|
||||
/requires a changelog fragment/,
|
||||
/requires a reconciled changelog fragment/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
@@ -514,7 +514,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
||||
],
|
||||
changedLabels: [],
|
||||
}),
|
||||
/requires a changelog fragment/,
|
||||
/requires a reconciled changelog fragment/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
@@ -526,6 +526,27 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
||||
changedLabels: [],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
verifyPullRequestChangelog({
|
||||
changedEntries: [
|
||||
{ path: 'src/main-entry.ts', status: 'M' },
|
||||
{ path: 'changes/001.md', status: 'M' },
|
||||
],
|
||||
changedLabels: [],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
verifyPullRequestChangelog({
|
||||
changedEntries: [
|
||||
{ path: 'src/main-entry.ts', status: 'M' },
|
||||
{ path: 'changes/001.md', status: 'A' },
|
||||
{ path: 'changes/002.md', status: 'A' },
|
||||
],
|
||||
changedLabels: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
|
||||
@@ -1044,6 +1065,85 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts appends contributor attribution and a new-contributors section to release notes', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('release-notes-contributors');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
['type: added', 'area: overlay', '', '- Added a feature.'].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '002.md'),
|
||||
['type: fixed', 'area: jellyfin', '', '- Fixed a bug.'].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = defaultStubClaude();
|
||||
const resolveContributionsCalls: string[][] = [];
|
||||
writeChangelogArtifacts({
|
||||
cwd: projectRoot,
|
||||
version: '0.6.0',
|
||||
date: '2026-05-06',
|
||||
deps: {
|
||||
runClaude: stub.runClaude,
|
||||
resolveContributions: (fragmentPaths) => {
|
||||
resolveContributionsCalls.push(fragmentPaths);
|
||||
return [
|
||||
{
|
||||
prNumber: 110,
|
||||
login: 'ksyasuda',
|
||||
title: 'feat(overlay): add a feature',
|
||||
isFirstContribution: false,
|
||||
},
|
||||
{
|
||||
prNumber: 112,
|
||||
login: 'bee-san',
|
||||
title: 'fix(jellyfin): restart remote session',
|
||||
isFirstContribution: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolveContributionsCalls.length, 1, 'resolves contributions once per release');
|
||||
assert.deepEqual(resolveContributionsCalls[0], [
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
path.join(projectRoot, 'changes', '002.md'),
|
||||
]);
|
||||
|
||||
const releaseNotes = fs.readFileSync(
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(releaseNotes, /## What’s Changed\n\n/);
|
||||
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
|
||||
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
releaseNotes,
|
||||
/ksyasuda made their first contribution/,
|
||||
'returning contributors are not listed under New Contributors',
|
||||
);
|
||||
|
||||
// Attribution is a release-notes concern only; the CHANGELOG stays clean.
|
||||
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
|
||||
assert.doesNotMatch(changelog, /What’s Changed/);
|
||||
assert.doesNotMatch(changelog, /New Contributors/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('reuse-existing-section');
|
||||
|
||||
+174
-3
@@ -4,6 +4,20 @@ import { execFileSync } from 'node:child_process';
|
||||
|
||||
type RunClaude = (input: string, args: string[]) => string;
|
||||
|
||||
// A single PR's contribution, resolved from the fragment files released in this
|
||||
// cycle. Used to append GitHub-style attribution to the release notes.
|
||||
type Contribution = {
|
||||
prNumber: number;
|
||||
login: string;
|
||||
title: string;
|
||||
isFirstContribution: boolean;
|
||||
};
|
||||
|
||||
// Resolves the contributions behind a set of changelog fragment paths. Injected
|
||||
// in tests so we never hit git/gh; the default implementation walks git history
|
||||
// and the GitHub API.
|
||||
type ResolveContributions = (fragmentPaths: string[], cwd: string) => Contribution[];
|
||||
|
||||
type ChangelogFsDeps = {
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
@@ -13,6 +27,7 @@ type ChangelogFsDeps = {
|
||||
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
||||
log?: (message: string) => void;
|
||||
runClaude?: RunClaude;
|
||||
resolveContributions?: ResolveContributions;
|
||||
};
|
||||
|
||||
type PolishMode = 'changelog' | 'release-notes';
|
||||
@@ -296,6 +311,152 @@ function defaultRunClaude(input: string, args: string[]): string {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFragmentRelativePath(fragmentPath: string, cwd: string): string {
|
||||
return path.relative(cwd, fragmentPath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
// Walks git history + the GitHub API to attribute each released fragment to the
|
||||
// PR (and author) that introduced it. One git call and one gh call per fragment,
|
||||
// plus one gh call per unique author for the first-contribution check. Best
|
||||
// effort: if gh is unavailable/unauthenticated or any lookup fails, we warn and
|
||||
// drop attribution rather than failing the release.
|
||||
function defaultResolveContributions(fragmentPaths: string[], cwd: string): Contribution[] {
|
||||
if (fragmentPaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const slug = execFileSync(
|
||||
'gh',
|
||||
['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
).trim();
|
||||
if (!slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const byPr = new Map<number, Contribution>();
|
||||
for (const fragmentPath of fragmentPaths) {
|
||||
const relativePath = resolveFragmentRelativePath(fragmentPath, cwd);
|
||||
// git log lists newest first, so the commit that *added* the file is the
|
||||
// last line of the --diff-filter=A history.
|
||||
const addingSha = execFileSync(
|
||||
'git',
|
||||
['log', '--diff-filter=A', '--follow', '--format=%H', '--', relativePath],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
)
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
if (!addingSha) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prRaw = execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
`repos/${slug}/commits/${addingSha}/pulls`,
|
||||
'--jq',
|
||||
'.[0] // empty | {number, login: .user.login, title}',
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
).trim();
|
||||
if (!prRaw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pr = JSON.parse(prRaw) as { number?: number; login?: string; title?: string };
|
||||
if (typeof pr.number !== 'number' || !pr.login || !pr.title) {
|
||||
continue;
|
||||
}
|
||||
if (!byPr.has(pr.number)) {
|
||||
byPr.set(pr.number, {
|
||||
prNumber: pr.number,
|
||||
login: pr.login,
|
||||
title: pr.title,
|
||||
isFirstContribution: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const firstPrByAuthor = new Map<string, number | null>();
|
||||
for (const contribution of byPr.values()) {
|
||||
if (!firstPrByAuthor.has(contribution.login)) {
|
||||
const firstRaw = execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
'search/issues',
|
||||
'-f',
|
||||
`q=repo:${slug} is:pr is:merged author:${contribution.login}`,
|
||||
'-f',
|
||||
'sort=created',
|
||||
'-f',
|
||||
'order=asc',
|
||||
'-f',
|
||||
'per_page=1',
|
||||
'--jq',
|
||||
'.items[0].number // empty',
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
).trim();
|
||||
firstPrByAuthor.set(contribution.login, firstRaw ? Number.parseInt(firstRaw, 10) : null);
|
||||
}
|
||||
const firstPr = firstPrByAuthor.get(contribution.login) ?? null;
|
||||
contribution.isFirstContribution = firstPr !== null && firstPr === contribution.prNumber;
|
||||
}
|
||||
|
||||
return [...byPr.values()].sort((a, b) => a.prNumber - b.prNumber);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Skipping contributor attribution: ${message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveContributionsForFragments(
|
||||
fragments: ChangeFragment[],
|
||||
cwd: string,
|
||||
deps?: ChangelogFsDeps,
|
||||
): Contribution[] {
|
||||
const resolve = deps?.resolveContributions ?? defaultResolveContributions;
|
||||
return resolve(
|
||||
fragments.filter((fragment) => fragment.type !== 'internal').map((fragment) => fragment.path),
|
||||
cwd,
|
||||
);
|
||||
}
|
||||
|
||||
function renderContributorsSections(contributions: Contribution[]): string[] {
|
||||
if (contributions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines: string[] = ['## What’s Changed', ''];
|
||||
for (const contribution of contributions) {
|
||||
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
|
||||
}
|
||||
|
||||
const firstTimers = contributions.filter((contribution) => contribution.isFirstContribution);
|
||||
if (firstTimers.length > 0) {
|
||||
lines.push('', '## New Contributors', '');
|
||||
for (const contribution of firstTimers) {
|
||||
lines.push(
|
||||
`- @${contribution.login} made their first contribution in #${contribution.prNumber}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines;
|
||||
}
|
||||
|
||||
function serializeFragmentsForPrompt(
|
||||
fragments: ChangeFragment[],
|
||||
mode: PolishMode,
|
||||
@@ -473,6 +634,7 @@ function renderReleaseNotes(
|
||||
changes: string,
|
||||
options?: {
|
||||
disclaimer?: string;
|
||||
contributions?: Contribution[];
|
||||
},
|
||||
): string {
|
||||
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
||||
@@ -494,6 +656,7 @@ function renderReleaseNotes(
|
||||
'',
|
||||
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
|
||||
'',
|
||||
...renderContributorsSections(options?.contributions ?? []),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -504,6 +667,7 @@ function writeReleaseNotesFile(
|
||||
options?: {
|
||||
disclaimer?: string;
|
||||
outputPath?: string;
|
||||
contributions?: Contribution[];
|
||||
},
|
||||
): string {
|
||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||
@@ -530,6 +694,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
const version = resolveVersion(options ?? {});
|
||||
const date = resolveDate(options?.date);
|
||||
const fragments = readChangeFragments(cwd, options?.deps);
|
||||
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
|
||||
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
|
||||
const existingChangelog = existsSync(existingChangelogPath)
|
||||
? readFileSync(existingChangelogPath, 'utf8')
|
||||
@@ -547,6 +712,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
cwd,
|
||||
stripDetailsBlocks(existingReleaseSection),
|
||||
options?.deps,
|
||||
{ contributions },
|
||||
);
|
||||
log(`Generated ${releaseNotesPath}`);
|
||||
|
||||
@@ -572,7 +738,9 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
date,
|
||||
deps: options?.deps,
|
||||
});
|
||||
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps);
|
||||
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps, {
|
||||
contributions,
|
||||
});
|
||||
log(`Generated ${releaseNotesPath}`);
|
||||
|
||||
for (const fragment of fragments) {
|
||||
@@ -661,14 +829,15 @@ export function verifyPullRequestChangelog(options: PullRequestChangelogOptions)
|
||||
return;
|
||||
}
|
||||
|
||||
const hasFragment = normalizedEntries.some(
|
||||
const fragmentEntries = normalizedEntries.filter(
|
||||
(entry) => entry.status !== 'D' && isFragmentPath(entry.path),
|
||||
);
|
||||
const hasFragment = fragmentEntries.length > 0;
|
||||
const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path));
|
||||
|
||||
if (requiresFragment && !hasFragment) {
|
||||
throw new Error(
|
||||
`This pull request changes release-relevant files and requires a changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label.`,
|
||||
`This pull request changes release-relevant files and requires a reconciled changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label. Before adding a new fragment, update the existing PR fragment when the new work modifies, fixes, or supersedes behavior already described there.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -832,10 +1001,12 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
||||
existingReleaseNotes,
|
||||
deps: options?.deps,
|
||||
});
|
||||
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
|
||||
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
||||
disclaimer:
|
||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||
outputPath: PRERELEASE_NOTES_PATH,
|
||||
contributions,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeJellyfinRuntimeHandlers } from './jellyfin-runtime-composer';
|
||||
import {
|
||||
composeJellyfinRuntimeHandlers,
|
||||
createRestartJellyfinRemoteSessionAfterSetupLoginHandler,
|
||||
} from './jellyfin-runtime-composer';
|
||||
|
||||
test('setup login restart uses auto-connect path without an active remote session', async () => {
|
||||
const startOptions: Array<{ explicit?: boolean } | undefined> = [];
|
||||
const restart = createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
|
||||
getCurrentSession: () => null,
|
||||
startJellyfinRemoteSession: async (options) => {
|
||||
startOptions.push(options);
|
||||
},
|
||||
});
|
||||
|
||||
await restart();
|
||||
|
||||
assert.deepEqual(startOptions, [undefined]);
|
||||
});
|
||||
|
||||
test('setup login restart explicitly refreshes an active remote session', async () => {
|
||||
const startOptions: Array<{ explicit?: boolean } | undefined> = [];
|
||||
const restart = createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
|
||||
getCurrentSession: () => ({ stop: () => {} }),
|
||||
startJellyfinRemoteSession: async (options) => {
|
||||
startOptions.push(options);
|
||||
},
|
||||
});
|
||||
|
||||
await restart();
|
||||
|
||||
assert.deepEqual(startOptions, [{ explicit: true }]);
|
||||
});
|
||||
|
||||
test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => {
|
||||
let activePlayback: unknown = null;
|
||||
|
||||
@@ -153,6 +153,16 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
||||
openJellyfinSetupWindow: ReturnType<typeof createOpenJellyfinSetupWindowHandler>;
|
||||
}>;
|
||||
|
||||
export function createRestartJellyfinRemoteSessionAfterSetupLoginHandler(deps: {
|
||||
getCurrentSession: () => unknown | null;
|
||||
startJellyfinRemoteSession: (options?: { explicit?: boolean }) => Promise<void>;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const hasActiveSession = deps.getCurrentSession() !== null;
|
||||
await deps.startJellyfinRemoteSession(hasActiveSession ? { explicit: true } : undefined);
|
||||
};
|
||||
}
|
||||
|
||||
export function composeJellyfinRuntimeHandlers(
|
||||
options: JellyfinRuntimeComposerOptions,
|
||||
): JellyfinRuntimeComposerResult {
|
||||
@@ -268,12 +278,19 @@ export function composeJellyfinRuntimeHandlers(
|
||||
const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler(
|
||||
options.maybeFocusExistingJellyfinSetupWindowMainDeps,
|
||||
);
|
||||
const restartJellyfinRemoteSessionAfterSetupLogin =
|
||||
createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
|
||||
getCurrentSession: () => options.startJellyfinRemoteSessionMainDeps.getCurrentSession(),
|
||||
startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
|
||||
});
|
||||
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
|
||||
createBuildOpenJellyfinSetupWindowMainDepsHandler({
|
||||
...options.openJellyfinSetupWindowMainDeps,
|
||||
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
|
||||
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||
getJellyfinClientInfo: () => getJellyfinClientInfo(),
|
||||
restartRemoteSession: () => restartJellyfinRemoteSessionAfterSetupLogin(),
|
||||
stopRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
})(),
|
||||
);
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
|
||||
clearStoredSession: () => calls.push('clear-session'),
|
||||
patchJellyfinConfig: () => calls.push('patch'),
|
||||
persistAuthenticatedSession: () => calls.push('persist'),
|
||||
restartRemoteSession: () => {
|
||||
calls.push('restart-remote');
|
||||
},
|
||||
stopRemoteSession: () => calls.push('stop-remote'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
@@ -95,6 +99,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
|
||||
},
|
||||
deps.getJellyfinClientInfo(),
|
||||
);
|
||||
await deps.restartRemoteSession?.();
|
||||
deps.stopRemoteSession?.();
|
||||
deps.logInfo('ok');
|
||||
deps.logError('bad', null);
|
||||
deps.showMpvOsd('toast');
|
||||
@@ -110,6 +116,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
|
||||
'clear-session',
|
||||
'patch',
|
||||
'persist',
|
||||
'restart-remote',
|
||||
'stop-remote',
|
||||
'info:ok',
|
||||
'error:bad',
|
||||
'osd:toast',
|
||||
|
||||
@@ -20,6 +20,10 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
|
||||
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
||||
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
||||
: undefined,
|
||||
restartRemoteSession: deps.restartRemoteSession
|
||||
? () => deps.restartRemoteSession?.()
|
||||
: undefined,
|
||||
stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined,
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logError: (message: string, error: unknown) => deps.logError(message, error),
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
|
||||
@@ -160,6 +160,9 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
|
||||
patchPayload = session;
|
||||
calls.push('patch');
|
||||
},
|
||||
restartRemoteSession: async () => {
|
||||
calls.push('restart-remote');
|
||||
},
|
||||
logInfo: () => calls.push('info'),
|
||||
logError: () => calls.push('error'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
@@ -172,7 +175,14 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
|
||||
'b',
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, ['save', 'patch', 'info', 'osd:Jellyfin login success', 'reload']);
|
||||
assert.deepEqual(calls, [
|
||||
'save',
|
||||
'patch',
|
||||
'restart-remote',
|
||||
'info',
|
||||
'osd:Jellyfin login success',
|
||||
'reload',
|
||||
]);
|
||||
assert.equal(authPassword, 'b');
|
||||
assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' });
|
||||
assert.deepEqual(patchPayload, {
|
||||
@@ -329,6 +339,7 @@ test('createHandleJellyfinSetupSubmissionHandler handles logout and done', async
|
||||
saveStoredSession: () => calls.push('save'),
|
||||
clearStoredSession: () => calls.push('clear'),
|
||||
patchJellyfinConfig: () => calls.push('patch'),
|
||||
stopRemoteSession: () => calls.push('stop-remote'),
|
||||
logInfo: (message) => calls.push(message),
|
||||
logError: () => calls.push('error'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
@@ -340,6 +351,7 @@ test('createHandleJellyfinSetupSubmissionHandler handles logout and done', async
|
||||
assert.equal(await handler('subminer://jellyfin-setup?action=done'), true);
|
||||
assert.deepEqual(calls, [
|
||||
'clear',
|
||||
'stop-remote',
|
||||
'Cleared stored Jellyfin auth session.',
|
||||
'osd:Jellyfin logged out',
|
||||
'reload',
|
||||
|
||||
@@ -425,6 +425,8 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
||||
clearStoredSession: () => void;
|
||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
|
||||
restartRemoteSession?: () => Promise<void> | void;
|
||||
stopRemoteSession?: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
@@ -447,6 +449,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
||||
if (submission.action === 'logout') {
|
||||
try {
|
||||
deps.clearStoredSession();
|
||||
deps.stopRemoteSession?.();
|
||||
deps.logInfo('Cleared stored Jellyfin auth session.');
|
||||
deps.showMpvOsd('Jellyfin logged out');
|
||||
deps.reloadSetupWindow({
|
||||
@@ -491,6 +494,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
||||
deps.saveStoredSession({ accessToken: session.accessToken, userId: session.userId });
|
||||
deps.patchJellyfinConfig(session);
|
||||
}
|
||||
await deps.restartRemoteSession?.();
|
||||
deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
|
||||
deps.showMpvOsd('Jellyfin login success');
|
||||
deps.reloadSetupWindow({
|
||||
@@ -593,6 +597,8 @@ export function createOpenJellyfinSetupWindowHandler<
|
||||
clearStoredSession: () => void;
|
||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
|
||||
restartRemoteSession?: () => Promise<void> | void;
|
||||
stopRemoteSession?: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
@@ -633,6 +639,10 @@ export function createOpenJellyfinSetupWindowHandler<
|
||||
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
||||
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
||||
: undefined,
|
||||
restartRemoteSession: deps.restartRemoteSession
|
||||
? () => deps.restartRemoteSession?.()
|
||||
: undefined,
|
||||
stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined,
|
||||
logInfo: (message) => deps.logInfo(message),
|
||||
logError: (message, error) => deps.logError(message, error),
|
||||
showMpvOsd: (message) => deps.showMpvOsd(message),
|
||||
|
||||
Reference in New Issue
Block a user