From 449be4e0eacc7f36848ce9142ea1182f5087a78c Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 11:43:36 +0300 Subject: [PATCH 1/2] Show unseen Server commits after login. Bake first-parent git log at Server build and remember the SHA in a browser cookie. Co-authored-by: Cursor --- docs/design/off-queue.md | 25 +++ docs/phases/54-whats-new.md | 42 +++++ docs/phases/README.md | 2 +- docs/protocol.md | 20 +++ src/HSchool.Client/src/i18n/strings.ts | 4 + src/HSchool.Client/src/main.ts | 3 + src/HSchool.Client/src/net/api.ts | 18 +++ src/HSchool.Client/src/style.css | 33 ++++ .../src/ui/seenRevision.test.ts | 23 +++ src/HSchool.Client/src/ui/seenRevision.ts | 40 +++++ src/HSchool.Client/src/ui/whatsNew.test.ts | 83 ++++++++++ src/HSchool.Client/src/ui/whatsNew.ts | 70 +++++++++ src/HSchool.Server/Api/ChangelogEndpoints.cs | 27 ++++ .../Changelog/BuildChangelog.cs | 145 ++++++++++++++++++ src/HSchool.Server/HSchool.Server.csproj | 22 +++ src/HSchool.Server/Program.cs | 3 + .../ChangelogApiTests.cs | 69 +++++++++ .../BuildChangelogTests.cs | 60 ++++++++ tools/embed-changelog.ps1 | 40 +++++ tools/embed-changelog.sh | 23 +++ 20 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 docs/phases/54-whats-new.md create mode 100644 src/HSchool.Client/src/ui/seenRevision.test.ts create mode 100644 src/HSchool.Client/src/ui/seenRevision.ts create mode 100644 src/HSchool.Client/src/ui/whatsNew.test.ts create mode 100644 src/HSchool.Client/src/ui/whatsNew.ts create mode 100644 src/HSchool.Server/Api/ChangelogEndpoints.cs create mode 100644 src/HSchool.Server/Changelog/BuildChangelog.cs create mode 100644 tests/HSchool.AppHost.Tests/ChangelogApiTests.cs create mode 100644 tests/HSchool.Server.Tests/BuildChangelogTests.cs create mode 100644 tools/embed-changelog.ps1 create mode 100644 tools/embed-changelog.sh diff --git a/docs/design/off-queue.md b/docs/design/off-queue.md index d8b8fe5..405de3c 100644 --- a/docs/design/off-queue.md +++ b/docs/design/off-queue.md @@ -118,3 +118,28 @@ Голый `dotnet run --project src/HSchool.AppHost` по-прежнему собирает — так удобнее агентам с грязным деревом. Linux-обёртки нет: точка входа Windows — `.cmd`. Протокол, HTTP и сейв не трогаем. + +## Что нового после входа + +### Зачем + +Альфа обновляется часто. Игрок не смотрит git: если с прошлого захода в этот браузер накатили +другую сборку Server, один раз показать, что вошло в неё. + +### Было / Стало / Почему + +**Было.** Нет версии сборки на экране. Сессионная кука HttpOnly и про вход. + +**Стало.** Сборка Server вшивает `HEAD` и `git log --first-parent`. `GET /api/changelog?since=` +отдаёт текущий SHA и коммиты после якоря. Клиент держит `hschool.seen-rev` (год, `Path=/`, +`SameSite=Lax`, не HttpOnly). Первый заход без куки окно не показывает — только запоминает +текущий SHA. Окно — после входа, не при возврате из школы. + +**Почему.** «Накатанный» — то, что в dll, а не грязный HEAD при `--no-build`. First-parent после +`--no-ff` — это слитые фазы, не внутренняя нарезка ветки. `Mark phase N` оставляем в цепочке +якорем, в список не рисуем: иначе кука на таком HEAD потеряла бы следующее слияние. + +### Что не входит + +Ручной `CHANGELOG.md`. Запись на пользователе в `users.json`. Вызов git из бегущего процесса. +Бамп версии сокета. Окно внутри открытой школы. diff --git a/docs/phases/54-whats-new.md b/docs/phases/54-whats-new.md new file mode 100644 index 0000000..2531336 --- /dev/null +++ b/docs/phases/54-whats-new.md @@ -0,0 +1,42 @@ +# Фаза 54. Что нового + +## Зависимости + +Нет. Сессия (фаза 38) уже ставит куку входа; это отдельная кука и отдельное окно. + +## Зачем + +После наката игрок не обязан читать git. Окно один раз показывает, что изменилось с прошлого +захода в этот браузер. + +## Задачи + +- [x] Сборка Server вшивает SHA и `git log --first-parent` (не вызов git в рантайме) +- [x] `GET /api/changelog?since=` с сессией: текущий SHA и коммиты строго после `since` +- [x] Нет `since`, неизвестный SHA, `since` = текущий — `commits` пустой +- [x] Subject `Mark phase N…` в окне нет; в цепочке якоря они остаются +- [x] Кука `hschool.seen-rev` (не HttpOnly, не сессионная): первый заход молча запоминает HEAD +- [x] Модалка только после `ensureSession` (вход и повторный вход после logout), не с меню школы +- [x] [`protocol.md`](../protocol.md) HTTP, версию сокета не бампить + +## Тесты, без которых фаза не закрыта + +- [x] Срез после известного SHA — видимые коммиты старше→новее, без `Mark phase` +- [x] Якорь на `Mark phase` — следующие видимые всё равно отдаются +- [x] Нет `since` / неизвестный SHA / `since` = текущий — пустой список +- [x] `GET /api/changelog` без сессии — `401` +- [x] С сессией без `since` — 40 hex в `current`, `commits` пустой +- [x] Нет куки — запрос без `since`, кука = `current`, диалога нет +- [x] Кука старше, сервер вернул коммиты — диалог, по закрытию кука = `current` + +## Критерий готовности + +- Первый заход после деплоя с новой сборкой Server показывает окно; повторный заход в том же + браузере — нет +- `run-aspire` без `--rebuild` после коммита только в доках может показать старый SHA: вшито + на сборке, не HEAD рабочего дерева + +## Стоп + +Не писать last-seen в `users.json`. Не бампить сокет. Не вызывать git из бегущего сервера. +Не показывать окно при возврате из школы на меню. diff --git a/docs/phases/README.md b/docs/phases/README.md index 2322b43..8951451 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -231,7 +231,7 @@ Склейка уже живых систем и мелкий DX, не новый кусок игры. Номера не спорят со срезом 10. После каждой игровой фазы школа целая. 50–52 стоят на 48; 53 — на 18 и 32, можно параллельно -с 48. 49 от них не зависит. Баг в уже сделанном — [`../bugs/README.md`](../bugs/README.md). +с 48. 49 и 54 от них не зависят. Баг в уже сделанном — [`../bugs/README.md`](../bugs/README.md). | Фаза | Статус | Зачем | | --- | --- | --- | diff --git a/docs/protocol.md b/docs/protocol.md index 659176e..bceecee 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -68,6 +68,26 @@ closes the connection with a policy violation and never sends Welcome. A second that already has a live connection is closed the same way; `POST /api/session` for that name returns `409` `name-online`. Hello is unchanged (version + locale only). +### `GET /api/changelog` + +What landed in the running Server build since an earlier commit. Requires a session cookie. +`current` is the SHA baked at compile time (`git rev-parse HEAD` during that build), not the live +working tree. `commits` is `git log --first-parent` after `since`, oldest first, subjects that +match `Mark phase ` omitted. Without `since`, with an unknown SHA, or when `since` equals +`current`, `commits` is empty — a first visit must not dump the whole history. + +```json +{ + "current": "0123456789abcdef0123456789abcdef01234567", + "commits": [ + { "sha": "89abcdef0123456789abcdef0123456789abcdef", "date": "2026-08-20T08:21:00+00:00", "subject": "Merge branch 'phase/53-weather-commute'" } + ] +} +``` + +Optional `?since=` is a 40-character hex SHA from the client's `hschool.seen-rev` cookie. That +cookie is not HttpOnly and is not the session cookie. Protocol version is unchanged. + ### `GET /api/schools` Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index ce96daf..91aa25b 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -21,6 +21,8 @@ const ru = { sessionInvalidName: 'Имя должно быть от 1 до 40 символов.', sessionFailed: 'Не удалось войти. Попробуйте ещё раз.', sessionLogout: 'Выйти', + changelogTitle: 'Что нового', + changelogOk: 'Понятно', schoolsTitle: 'Школы', schoolsMine: 'Мои', @@ -392,6 +394,8 @@ const en: Messages = { sessionInvalidName: 'The name must be 1–40 characters.', sessionFailed: 'Could not sign in. Try again.', sessionLogout: 'Sign out', + changelogTitle: "What's new", + changelogOk: 'OK', schoolsTitle: 'Schools', schoolsMine: 'Mine', diff --git a/src/HSchool.Client/src/main.ts b/src/HSchool.Client/src/main.ts index aceb488..e2592a4 100644 --- a/src/HSchool.Client/src/main.ts +++ b/src/HSchool.Client/src/main.ts @@ -6,6 +6,7 @@ import { GameScreen } from './ui/gameScreen.ts'; import { localeSwitch } from './ui/localeSwitch.ts'; import { MainMenu } from './ui/mainMenu.ts'; import { ensureSession } from './ui/sessionGate.ts'; +import { showWhatsNew } from './ui/whatsNew.ts'; import type { School } from './net/api.ts'; import './style.css'; @@ -26,6 +27,7 @@ async function bootstrap(): Promise { footer.prepend(localeSwitch()); await ensureSession(); + await showWhatsNew(); let openSchool: School | null = null; let connectionStatus: ConnectionStatus = 'connecting'; @@ -109,6 +111,7 @@ async function bootstrap(): Promise { menu.stop(); await logoutSession(); await ensureSession(); + await showWhatsNew(); connection.connect(); showMenu(); } diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 3aec48c..abb09c1 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -476,6 +476,24 @@ export async function logoutSession(): Promise { await request('/api/session', { method: 'DELETE' }, { expectBody: false }); } +export interface ChangelogCommit { + readonly sha: string; + readonly date: string; + readonly subject: string; +} + +export interface ChangelogResponse { + readonly current: string; + readonly commits: readonly ChangelogCommit[]; +} + +export async function fetchChangelog(since?: string | null): Promise { + const query = since !== null && since !== undefined && since.length > 0 + ? `?since=${encodeURIComponent(since)}` + : ''; + return request(`/api/changelog${query}`); +} + export interface SwarmUiKindPreset { width: number; height: number; diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 8397ef5..767df10 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -1425,6 +1425,39 @@ body { margin-top: 20px; } +.dialog--changelog { + max-width: 520px; +} + +.changelog__list { + max-height: min(50dvh, 360px); + margin: 0; + padding: 0; + overflow: auto; + list-style: none; +} + +.changelog__item { + padding: 8px 0; + border-bottom: 1px solid var(--border); +} + +.changelog__item:last-child { + border-bottom: none; +} + +.changelog__date { + display: block; + color: var(--text-muted); + font-size: 12px; +} + +.changelog__subject { + display: block; + margin-top: 2px; + line-height: 1.35; +} + .form { display: flex; flex-direction: column; diff --git a/src/HSchool.Client/src/ui/seenRevision.test.ts b/src/HSchool.Client/src/ui/seenRevision.test.ts new file mode 100644 index 0000000..f1b2054 --- /dev/null +++ b/src/HSchool.Client/src/ui/seenRevision.test.ts @@ -0,0 +1,23 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { SEEN_REVISION_COOKIE, readSeenRevision, writeSeenRevision } from './seenRevision.ts'; + +const current = '0123456789abcdef0123456789abcdef01234567'; + +describe('seen revision cookie', () => { + afterEach(() => { + document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`; + }); + + it('round-trips a 40-character SHA', () => { + writeSeenRevision(current); + expect(readSeenRevision()).toBe(current); + }); + + it('ignores a value that is not a SHA', () => { + writeSeenRevision('not-a-sha'); + expect(readSeenRevision()).toBeNull(); + }); +}); diff --git a/src/HSchool.Client/src/ui/seenRevision.ts b/src/HSchool.Client/src/ui/seenRevision.ts new file mode 100644 index 0000000..2c21e5b --- /dev/null +++ b/src/HSchool.Client/src/ui/seenRevision.ts @@ -0,0 +1,40 @@ +/** Last Server build SHA this browser already showed. Not the HttpOnly session cookie. */ + +export const SEEN_REVISION_COOKIE = 'hschool.seen-rev'; + +const SHA = /^[0-9a-f]{40}$/i; +const MAX_AGE_SECONDS = 365 * 24 * 60 * 60; + +export function readSeenRevision(): string | null { + const prefix = `${SEEN_REVISION_COOKIE}=`; + const parts = document.cookie.split(';'); + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed.startsWith(prefix)) { + continue; + } + + const value = decodeURIComponent(trimmed.slice(prefix.length)); + return SHA.test(value) ? value.toLowerCase() : null; + } + + return null; +} + +export function writeSeenRevision(sha: string): void { + if (!SHA.test(sha)) { + return; + } + + const pieces = [ + `${SEEN_REVISION_COOKIE}=${encodeURIComponent(sha.toLowerCase())}`, + 'Path=/', + 'SameSite=Lax', + `Max-Age=${MAX_AGE_SECONDS}`, + ]; + if (globalThis.location?.protocol === 'https:') { + pieces.push('Secure'); + } + + document.cookie = pieces.join('; '); +} diff --git a/src/HSchool.Client/src/ui/whatsNew.test.ts b/src/HSchool.Client/src/ui/whatsNew.test.ts new file mode 100644 index 0000000..a31a107 --- /dev/null +++ b/src/HSchool.Client/src/ui/whatsNew.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetchChangelog } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; +import { SEEN_REVISION_COOKIE, readSeenRevision, writeSeenRevision } from './seenRevision.ts'; +import { showWhatsNew } from './whatsNew.ts'; + +vi.mock('../net/api.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchChangelog: vi.fn(), + fetchSession: vi.fn(), + fetchSchools: vi.fn(), + }; +}); + +const current = '0123456789abcdef0123456789abcdef01234567'; +const previous = '89abcdef0123456789abcdef0123456789abcdef'; + +describe('whats-new after login', () => { + beforeEach(() => { + document.body.innerHTML = ''; + document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`; + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.querySelector('dialog')?.remove(); + document.cookie = `${SEEN_REVISION_COOKIE}=; Path=/; Max-Age=0`; + }); + + it('stores current without a dialog when there is no cookie', async () => { + vi.mocked(fetchChangelog).mockResolvedValue({ current, commits: [] }); + + await showWhatsNew(); + + expect(fetchChangelog).toHaveBeenCalledWith(null); + expect(readSeenRevision()).toBe(current); + expect(document.querySelector('dialog')).toBeNull(); + }); + + it('does not open a dialog when the cookie already matches current', async () => { + writeSeenRevision(current); + vi.mocked(fetchChangelog).mockResolvedValue({ current, commits: [] }); + + await showWhatsNew(); + + expect(fetchChangelog).toHaveBeenCalledWith(current); + expect(document.querySelector('dialog')).toBeNull(); + }); + + it('opens the dialog for unseen commits and writes current on dismiss', async () => { + writeSeenRevision(previous); + vi.mocked(fetchChangelog).mockResolvedValue({ + current, + commits: [ + { + sha: current, + date: '2026-08-20T08:21:00+00:00', + subject: "Merge branch 'phase/53-weather-commute'", + }, + ], + }); + + const pending = showWhatsNew(); + await Promise.resolve(); + await Promise.resolve(); + + const dialog = document.querySelector('dialog'); + expect(dialog?.textContent).toContain(t('changelogTitle')); + expect(dialog?.textContent).toContain("Merge branch 'phase/53-weather-commute'"); + + const ok = dialog?.querySelector('button'); + ok?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await pending; + + expect(readSeenRevision()).toBe(current); + expect(document.querySelector('dialog')).toBeNull(); + }); +}); diff --git a/src/HSchool.Client/src/ui/whatsNew.ts b/src/HSchool.Client/src/ui/whatsNew.ts new file mode 100644 index 0000000..cb1c4f7 --- /dev/null +++ b/src/HSchool.Client/src/ui/whatsNew.ts @@ -0,0 +1,70 @@ +import { fetchChangelog, type ChangelogCommit } from '../net/api.ts'; +import { intlTag } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { el } from './dom.ts'; +import { Modal } from './modal.ts'; +import { readSeenRevision, writeSeenRevision } from './seenRevision.ts'; + +/** + * After login: first visit stores the baked SHA; a newer build opens the changelog once. + * Returning from a school to the menu does not call this. + */ +export async function showWhatsNew(): Promise { + try { + const changelog = await fetchChangelog(readSeenRevision()); + if (!/^[0-9a-f]{40}$/i.test(changelog.current)) { + return; + } + + if (changelog.commits.length > 0) { + await whatsNewDialog(changelog.commits); + } + + writeSeenRevision(changelog.current); + } catch { + // Menu still works if the host skipped the embed or the request failed. + } +} + +function whatsNewDialog(commits: readonly ChangelogCommit[]): Promise { + const modal = new Modal(undefined); + const ok = el('button', { + class: 'button button--primary', + type: 'button', + text: t('changelogOk'), + onClick: () => modal.close(undefined), + }); + + const items = commits.map((commit) => { + const item = el('li', { class: 'changelog__item' }); + const date = formatCommitDate(commit.date); + if (date !== null) { + item.append(el('time', { class: 'changelog__date', text: date })); + } + + item.append(el('span', { class: 'changelog__subject', text: commit.subject })); + return item; + }); + + modal.element.classList.add('dialog--changelog'); + modal.element.append( + el('h2', { class: 'dialog__title', text: t('changelogTitle') }), + el('ul', { class: 'changelog__list' }, ...items), + el('div', { class: 'dialog__actions' }, ok), + ); + + return modal.open(ok); +} + +function formatCommitDate(iso: string): string | null { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return null; + } + + return new Intl.DateTimeFormat(intlTag(), { + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(date); +} diff --git a/src/HSchool.Server/Api/ChangelogEndpoints.cs b/src/HSchool.Server/Api/ChangelogEndpoints.cs new file mode 100644 index 0000000..e7b17c2 --- /dev/null +++ b/src/HSchool.Server/Api/ChangelogEndpoints.cs @@ -0,0 +1,27 @@ +using HSchool.Server.Changelog; + +namespace HSchool.Server.Api; + +internal static class ChangelogEndpoints +{ + public static void MapChangelogEndpoints(this IEndpointRouteBuilder builder) + { + builder.MapGet("/api/changelog", GetAsync); + } + + private static IResult GetAsync(string? since, BuildChangelog changelog) + { + var commits = changelog.VisibleSince(since) + .Select(commit => new ChangelogCommitResponse( + commit.Sha, + commit.Date.ToUniversalTime(), + commit.Subject)) + .ToArray(); + + return Results.Json(new ChangelogResponse(changelog.CurrentSha, commits)); + } + + private sealed record ChangelogResponse(string Current, IReadOnlyList Commits); + + private sealed record ChangelogCommitResponse(string Sha, DateTimeOffset Date, string Subject); +} diff --git a/src/HSchool.Server/Changelog/BuildChangelog.cs b/src/HSchool.Server/Changelog/BuildChangelog.cs new file mode 100644 index 0000000..5b27d67 --- /dev/null +++ b/src/HSchool.Server/Changelog/BuildChangelog.cs @@ -0,0 +1,145 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace HSchool.Server.Changelog; + +internal sealed record ChangelogCommit(string Sha, DateTimeOffset Date, string Subject); + +/// First-parent git log baked into the Server assembly at compile time. +internal sealed partial class BuildChangelog +{ + internal const string ResourceName = "HSchool.Server.changelog.tsv"; + internal const int MaxVisibleCommits = 100; + + private static readonly UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false); + + public BuildChangelog(string currentSha, IReadOnlyList commits) + { + CurrentSha = currentSha; + Commits = commits; + } + + public string CurrentSha { get; } + + /// Newest first, including Mark phase commits used as slice anchors. + public IReadOnlyList Commits { get; } + + public static BuildChangelog LoadEmbedded() + { + var assembly = typeof(BuildChangelog).Assembly; + using var stream = assembly.GetManifestResourceStream(ResourceName); + if (stream is null) + { + return new BuildChangelog("", []); + } + + using var reader = new StreamReader(stream, Utf8); + return Parse(reader.ReadToEnd()); + } + + public static BuildChangelog Parse(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return new BuildChangelog("", []); + } + + var lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (lines.Length == 0) + { + return new BuildChangelog("", []); + } + + var current = IsSha(lines[0]) ? lines[0].ToLowerInvariant() : ""; + var commits = new List(); + foreach (var line in lines.Skip(1)) + { + if (TryParseCommit(line, out var commit)) + { + commits.Add(commit); + } + } + + return new BuildChangelog(current, commits); + } + + public IReadOnlyList VisibleSince(string? since) + { + if (string.IsNullOrWhiteSpace(since) + || string.Equals(since, CurrentSha, StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + var newer = new List(); + var found = false; + foreach (var commit in Commits) + { + if (string.Equals(commit.Sha, since, StringComparison.OrdinalIgnoreCase)) + { + found = true; + break; + } + + newer.Add(commit); + } + + if (!found) + { + return []; + } + + newer.Reverse(); + var visible = new List(); + foreach (var commit in newer) + { + if (IsMarkPhase(commit.Subject)) + { + continue; + } + + visible.Add(commit); + if (visible.Count >= MaxVisibleCommits) + { + break; + } + } + + return visible; + } + + internal static bool IsMarkPhase(string subject) => MarkPhasePattern().IsMatch(subject); + + private static bool TryParseCommit(string line, out ChangelogCommit commit) + { + commit = null!; + var parts = line.Split('\x1f'); + if (parts.Length < 3 || !IsSha(parts[0])) + { + return false; + } + + if (!DateTimeOffset.TryParse(parts[1], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date)) + { + return false; + } + + var subject = string.Join('\x1f', parts.Skip(2)).Trim(); + if (subject.Length == 0) + { + return false; + } + + commit = new ChangelogCommit(parts[0].ToLowerInvariant(), date, subject); + return true; + } + + private static bool IsSha(string value) => ShaPattern().IsMatch(value); + + [GeneratedRegex(@"^[0-9a-fA-F]{40}$", RegexOptions.CultureInvariant)] + private static partial Regex ShaPattern(); + + [GeneratedRegex(@"^Mark phase \d+", RegexOptions.CultureInvariant)] + private static partial Regex MarkPhasePattern(); +} diff --git a/src/HSchool.Server/HSchool.Server.csproj b/src/HSchool.Server/HSchool.Server.csproj index 725dca6..b3202f2 100644 --- a/src/HSchool.Server/HSchool.Server.csproj +++ b/src/HSchool.Server/HSchool.Server.csproj @@ -32,4 +32,26 @@ + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..')) + + + + + + + <_ChangelogTsv>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\$(IntermediateOutputPath)changelog.tsv')) + + + + + + HSchool.Server.changelog.tsv + Non-Resx + false + + + + + diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index 0c862cd..50f1027 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -1,6 +1,7 @@ using System.Net.WebSockets; using HSchool.Server; using HSchool.Server.Api; +using HSchool.Server.Changelog; using HSchool.Server.Game; using HSchool.Server.Net; using HSchool.Server.Session; @@ -42,6 +43,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(_ => BuildChangelog.LoadEmbedded()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -79,6 +81,7 @@ app.UseWebSockets(new WebSocketOptions app.UseMiddleware(); app.MapSessionEndpoints(); +app.MapChangelogEndpoints(); app.MapSchoolEndpoints(); app.MapSettingsEndpoints(); app.MapTimetableEndpoints(); diff --git a/tests/HSchool.AppHost.Tests/ChangelogApiTests.cs b/tests/HSchool.AppHost.Tests/ChangelogApiTests.cs new file mode 100644 index 0000000..3f2aee8 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/ChangelogApiTests.cs @@ -0,0 +1,69 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace HSchool.AppHost.Tests; + +[Collection(AppHostCollection.Name)] +public class ChangelogApiTests(AppHostFixture fixture) +{ + private static readonly Regex Sha = new("^[0-9a-f]{40}$", RegexOptions.CultureInvariant); + + [Fact] + public async Task Changelog_WithoutSession_ReturnsUnauthorized() + { + var http = fixture.App.GetEndpoint("server", "http").ToString(); + using var client = new HttpClient { BaseAddress = new Uri(http) }; + + using var response = await client.GetAsync("/api/changelog", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Changelog_WithoutSince_ReturnsCurrentAndEmptyCommits() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.LoginAsync(client); + + var body = await client.GetFromJsonAsync( + "/api/changelog", + JsonOptions, + TestContext.Current.CancellationToken); + + Assert.NotNull(body); + Assert.Matches(Sha, body.Current); + Assert.Empty(body.Commits); + } + + [Fact] + public async Task Changelog_SinceCurrent_ReturnsEmptyCommits() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.LoginAsync(client); + + var first = await client.GetFromJsonAsync( + "/api/changelog", + JsonOptions, + TestContext.Current.CancellationToken); + Assert.NotNull(first); + + var again = await client.GetFromJsonAsync( + $"/api/changelog?since={first.Current}", + JsonOptions, + TestContext.Current.CancellationToken); + + Assert.NotNull(again); + Assert.Equal(first.Current, again.Current); + Assert.Empty(again.Commits); + } + + private static JsonSerializerOptions JsonOptions { get; } = new() + { + PropertyNameCaseInsensitive = true, + }; + + private sealed record ChangelogDto(string Current, ChangelogCommitDto[] Commits); + + private sealed record ChangelogCommitDto(string Sha, DateTimeOffset Date, string Subject); +} diff --git a/tests/HSchool.Server.Tests/BuildChangelogTests.cs b/tests/HSchool.Server.Tests/BuildChangelogTests.cs new file mode 100644 index 0000000..6732403 --- /dev/null +++ b/tests/HSchool.Server.Tests/BuildChangelogTests.cs @@ -0,0 +1,60 @@ +using HSchool.Server.Changelog; + +namespace HSchool.Server.Tests; + +public class BuildChangelogTests +{ + private const string Head = "cccccccccccccccccccccccccccccccccccccccc"; + private const string Mark = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + private const string Merge = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private const string Unknown = "dddddddddddddddddddddddddddddddddddddddd"; + + private static readonly string Sample = string.Join('\n', + Head, + $"{Head}\u001f2026-01-03T12:00:00Z\u001fAdd changelog window", + $"{Mark}\u001f2026-01-02T12:00:00Z\u001fMark phase 54 in progress.", + $"{Merge}\u001f2026-01-01T12:00:00Z\u001fMerge branch 'phase/53-weather-commute'"); + + [Fact] + public void VisibleSince_KnownSha_ReturnsOlderToNewerWithoutMarkPhase() + { + var changelog = BuildChangelog.Parse(Sample); + + var visible = changelog.VisibleSince(Merge); + + var commit = Assert.Single(visible); + Assert.Equal(Head, commit.Sha); + Assert.Equal("Add changelog window", commit.Subject); + } + + [Fact] + public void VisibleSince_MarkPhaseAnchor_StillReturnsLaterVisibleCommits() + { + var changelog = BuildChangelog.Parse(Sample); + + var visible = changelog.VisibleSince(Mark); + + var commit = Assert.Single(visible); + Assert.Equal("Add changelog window", commit.Subject); + } + + [Fact] + public void VisibleSince_MissingUnknownOrCurrent_ReturnsEmpty() + { + var changelog = BuildChangelog.Parse(Sample); + + Assert.Empty(changelog.VisibleSince(null)); + Assert.Empty(changelog.VisibleSince("")); + Assert.Empty(changelog.VisibleSince(Unknown)); + Assert.Empty(changelog.VisibleSince(Head)); + Assert.Empty(changelog.VisibleSince(Head.ToUpperInvariant())); + } + + [Fact] + public void IsMarkPhase_MatchesStatusLineSubjects() + { + Assert.True(BuildChangelog.IsMarkPhase("Mark phase 54 in progress.")); + Assert.True(BuildChangelog.IsMarkPhase("Mark phase 47 romance pack as complete.")); + Assert.False(BuildChangelog.IsMarkPhase("Merge branch 'phase/54-whats-new'")); + } +} diff --git a/tools/embed-changelog.ps1 b/tools/embed-changelog.ps1 new file mode 100644 index 0000000..87a0e0f --- /dev/null +++ b/tools/embed-changelog.ps1 @@ -0,0 +1,40 @@ +# UTF-8 first-parent log for HSchool.Server. Keep in sync with embed-changelog.sh. +param( + [Parameter(Mandatory = $true)] + [string] $RepoRoot, + [Parameter(Mandatory = $true)] + [string] $Output +) + +$ErrorActionPreference = "Stop" + +$dir = Split-Path -Parent $Output +if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir | Out-Null +} + +$sha = "" +$log = "" +Push-Location -LiteralPath $RepoRoot +try { + $sha = (git rev-parse HEAD 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $sha.Length -ne 40) { + $sha = "" + } + elseif ($sha.Length -eq 40) { + $log = git -c core.quotepath=false log --first-parent --encoding=UTF-8 --pretty=format:%H%x1f%cI%x1f%s + if ($LASTEXITCODE -ne 0) { + $log = "" + } + } +} +finally { + Pop-Location +} + +$text = $sha + "`n" +if (-not [string]::IsNullOrEmpty($log)) { + $text += $log.TrimEnd() + "`n" +} + +[System.IO.File]::WriteAllText($Output, $text, [System.Text.UTF8Encoding]::new($false)) diff --git a/tools/embed-changelog.sh b/tools/embed-changelog.sh new file mode 100644 index 0000000..ba57284 --- /dev/null +++ b/tools/embed-changelog.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# UTF-8 first-parent log for HSchool.Server. Keep in sync with embed-changelog.ps1. +set -e +RepoRoot=$1 +Output=$2 +if [ -z "$RepoRoot" ] || [ -z "$Output" ]; then + echo "usage: embed-changelog.sh " >&2 + exit 1 +fi +mkdir -p "$(dirname "$Output")" +sha= +log= +if sha=$(git -C "$RepoRoot" rev-parse HEAD 2>/dev/null) && [ "${#sha}" -eq 40 ]; then + log=$(git -C "$RepoRoot" -c core.quotepath=false log --first-parent --encoding=UTF-8 --pretty=format:%H%x1f%cI%x1f%s) || log= +else + sha= +fi +{ + printf '%s\n' "$sha" + if [ -n "$log" ]; then + printf '%s\n' "$log" + fi +} > "$Output" From 069c58194dd61c17f35c51a167dacf9b720bc448 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 11:44:11 +0300 Subject: [PATCH 2/2] Mark phase 54 whats-new as complete. Co-authored-by: Cursor --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index 8951451..09a1c77 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -241,4 +241,4 @@ | [51. Тепло на уроке](51-warmth-lesson.md) | ✅ | Замёрзший учится хуже, как голодный | | [52. Учебник на уроке](52-textbook-lesson.md) | ✅ | Нет в сумке — половинный рост | | [53. Погода на дороге](53-weather-commute.md) | ✅ | Снег и дождь добавляют минуты к приходу | -| [54. Что нового](54-whats-new.md) | 🔄 | После входа — окно коммитов с прошлого визита | +| [54. Что нового](54-whats-new.md) | ✅ | После входа — окно коммитов с прошлого визита |