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 <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 11:43:36 +03:00
co-authored by Cursor
parent 1e7014cabd
commit 449be4e0ea
20 changed files with 751 additions and 1 deletions
+25
View File
@@ -118,3 +118,28 @@
Голый `dotnet run --project src/HSchool.AppHost` по-прежнему собирает — так удобнее агентам с Голый `dotnet run --project src/HSchool.AppHost` по-прежнему собирает — так удобнее агентам с
грязным деревом. Linux-обёртки нет: точка входа Windows — `.cmd`. Протокол, HTTP и сейв не грязным деревом. 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 из бегущего процесса.
Бамп версии сокета. Окно внутри открытой школы.
+42
View File
@@ -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 из бегущего сервера.
Не показывать окно при возврате из школы на меню.
+1 -1
View File
@@ -231,7 +231,7 @@
Склейка уже живых систем и мелкий DX, не новый кусок игры. Номера не спорят со срезом 10. Склейка уже живых систем и мелкий DX, не новый кусок игры. Номера не спорят со срезом 10.
После каждой игровой фазы школа целая. 50–52 стоят на 48; 53 — на 18 и 32, можно параллельно После каждой игровой фазы школа целая. 50–52 стоят на 48; 53 — на 18 и 32, можно параллельно
с 48. 49 от них не зависит. Баг в уже сделанном — [`../bugs/README.md`](../bugs/README.md). с 48. 49 и 54 от них не зависят. Баг в уже сделанном — [`../bugs/README.md`](../bugs/README.md).
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
+20
View File
@@ -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 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). 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 <digits>` 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` ### `GET /api/schools`
Everything the main menu needs in one request. `schoolWeekDays` is 57 working days counted Everything the main menu needs in one request. `schoolWeekDays` is 57 working days counted
+4
View File
@@ -21,6 +21,8 @@ const ru = {
sessionInvalidName: 'Имя должно быть от 1 до 40 символов.', sessionInvalidName: 'Имя должно быть от 1 до 40 символов.',
sessionFailed: 'Не удалось войти. Попробуйте ещё раз.', sessionFailed: 'Не удалось войти. Попробуйте ещё раз.',
sessionLogout: 'Выйти', sessionLogout: 'Выйти',
changelogTitle: 'Что нового',
changelogOk: 'Понятно',
schoolsTitle: 'Школы', schoolsTitle: 'Школы',
schoolsMine: 'Мои', schoolsMine: 'Мои',
@@ -392,6 +394,8 @@ const en: Messages = {
sessionInvalidName: 'The name must be 140 characters.', sessionInvalidName: 'The name must be 140 characters.',
sessionFailed: 'Could not sign in. Try again.', sessionFailed: 'Could not sign in. Try again.',
sessionLogout: 'Sign out', sessionLogout: 'Sign out',
changelogTitle: "What's new",
changelogOk: 'OK',
schoolsTitle: 'Schools', schoolsTitle: 'Schools',
schoolsMine: 'Mine', schoolsMine: 'Mine',
+3
View File
@@ -6,6 +6,7 @@ import { GameScreen } from './ui/gameScreen.ts';
import { localeSwitch } from './ui/localeSwitch.ts'; import { localeSwitch } from './ui/localeSwitch.ts';
import { MainMenu } from './ui/mainMenu.ts'; import { MainMenu } from './ui/mainMenu.ts';
import { ensureSession } from './ui/sessionGate.ts'; import { ensureSession } from './ui/sessionGate.ts';
import { showWhatsNew } from './ui/whatsNew.ts';
import type { School } from './net/api.ts'; import type { School } from './net/api.ts';
import './style.css'; import './style.css';
@@ -26,6 +27,7 @@ async function bootstrap(): Promise<void> {
footer.prepend(localeSwitch()); footer.prepend(localeSwitch());
await ensureSession(); await ensureSession();
await showWhatsNew();
let openSchool: School | null = null; let openSchool: School | null = null;
let connectionStatus: ConnectionStatus = 'connecting'; let connectionStatus: ConnectionStatus = 'connecting';
@@ -109,6 +111,7 @@ async function bootstrap(): Promise<void> {
menu.stop(); menu.stop();
await logoutSession(); await logoutSession();
await ensureSession(); await ensureSession();
await showWhatsNew();
connection.connect(); connection.connect();
showMenu(); showMenu();
} }
+18
View File
@@ -476,6 +476,24 @@ export async function logoutSession(): Promise<void> {
await request<void>('/api/session', { method: 'DELETE' }, { expectBody: false }); await request<void>('/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<ChangelogResponse> {
const query = since !== null && since !== undefined && since.length > 0
? `?since=${encodeURIComponent(since)}`
: '';
return request<ChangelogResponse>(`/api/changelog${query}`);
}
export interface SwarmUiKindPreset { export interface SwarmUiKindPreset {
width: number; width: number;
height: number; height: number;
+33
View File
@@ -1425,6 +1425,39 @@ body {
margin-top: 20px; 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 { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -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();
});
});
+40
View File
@@ -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('; ');
}
@@ -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<typeof import('../net/api.ts')>();
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();
});
});
+70
View File
@@ -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<void> {
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<void> {
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);
}
@@ -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<ChangelogCommitResponse> Commits);
private sealed record ChangelogCommitResponse(string Sha, DateTimeOffset Date, string Subject);
}
@@ -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);
/// <summary>First-parent git log baked into the Server assembly at compile time.</summary>
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<ChangelogCommit> commits)
{
CurrentSha = currentSha;
Commits = commits;
}
public string CurrentSha { get; }
/// <summary>Newest first, including <c>Mark phase</c> commits used as slice anchors.</summary>
public IReadOnlyList<ChangelogCommit> 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<ChangelogCommit>();
foreach (var line in lines.Skip(1))
{
if (TryParseCommit(line, out var commit))
{
commits.Add(commit);
}
}
return new BuildChangelog(current, commits);
}
public IReadOnlyList<ChangelogCommit> VisibleSince(string? since)
{
if (string.IsNullOrWhiteSpace(since)
|| string.Equals(since, CurrentSha, StringComparison.OrdinalIgnoreCase))
{
return [];
}
var newer = new List<ChangelogCommit>();
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<ChangelogCommit>();
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();
}
+22
View File
@@ -32,4 +32,26 @@
<InternalsVisibleTo Include="HSchool.Server.Tests" /> <InternalsVisibleTo Include="HSchool.Server.Tests" />
</ItemGroup> </ItemGroup>
<PropertyGroup>
<HSchoolRepoRoot>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..'))</HSchoolRepoRoot>
</PropertyGroup>
<!-- Bake git HEAD + first-parent log; the running process does not call git. -->
<Target Name="EmbedGitChangelog" BeforeTargets="CreateManifestResourceNames" Condition="'$(DesignTimeBuild)' != 'true'">
<MakeDir Directories="$(IntermediateOutputPath)" />
<PropertyGroup>
<_ChangelogTsv>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\$(IntermediateOutputPath)changelog.tsv'))</_ChangelogTsv>
</PropertyGroup>
<Exec Condition="$([MSBuild]::IsOSPlatform('Windows'))" Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(HSchoolRepoRoot)\tools\embed-changelog.ps1&quot; -RepoRoot &quot;$(HSchoolRepoRoot)&quot; -Output &quot;$(_ChangelogTsv)&quot;" />
<Exec Condition="!$([MSBuild]::IsOSPlatform('Windows'))" Command="sh &quot;$(HSchoolRepoRoot)/tools/embed-changelog.sh&quot; &quot;$(HSchoolRepoRoot)&quot; &quot;$(_ChangelogTsv)&quot;" />
<ItemGroup>
<EmbeddedResource Include="$(_ChangelogTsv)" Condition="Exists('$(_ChangelogTsv)')">
<LogicalName>HSchool.Server.changelog.tsv</LogicalName>
<Type>Non-Resx</Type>
<WithCulture>false</WithCulture>
</EmbeddedResource>
<FileWrites Include="$(_ChangelogTsv)" />
</ItemGroup>
</Target>
</Project> </Project>
+3
View File
@@ -1,6 +1,7 @@
using System.Net.WebSockets; using System.Net.WebSockets;
using HSchool.Server; using HSchool.Server;
using HSchool.Server.Api; using HSchool.Server.Api;
using HSchool.Server.Changelog;
using HSchool.Server.Game; using HSchool.Server.Game;
using HSchool.Server.Net; using HSchool.Server.Net;
using HSchool.Server.Session; using HSchool.Server.Session;
@@ -42,6 +43,7 @@ builder.Services.AddSingleton<GameCommandQueue>();
builder.Services.AddSingleton<ClientRegistry>(); builder.Services.AddSingleton<ClientRegistry>();
builder.Services.AddSingleton<UserStore>(); builder.Services.AddSingleton<UserStore>();
builder.Services.AddSingleton<SessionService>(); builder.Services.AddSingleton<SessionService>();
builder.Services.AddSingleton(_ => BuildChangelog.LoadEmbedded());
builder.Services.AddSingleton<GameMetrics>(); builder.Services.AddSingleton<GameMetrics>();
builder.Services.AddSingleton<SchoolStore>(); builder.Services.AddSingleton<SchoolStore>();
builder.Services.AddSingleton<ModContent>(); builder.Services.AddSingleton<ModContent>();
@@ -79,6 +81,7 @@ app.UseWebSockets(new WebSocketOptions
app.UseMiddleware<SessionAuthMiddleware>(); app.UseMiddleware<SessionAuthMiddleware>();
app.MapSessionEndpoints(); app.MapSessionEndpoints();
app.MapChangelogEndpoints();
app.MapSchoolEndpoints(); app.MapSchoolEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapTimetableEndpoints(); app.MapTimetableEndpoints();
@@ -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<ChangelogDto>(
"/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<ChangelogDto>(
"/api/changelog",
JsonOptions,
TestContext.Current.CancellationToken);
Assert.NotNull(first);
var again = await client.GetFromJsonAsync<ChangelogDto>(
$"/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);
}
@@ -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'"));
}
}
+40
View File
@@ -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))
+23
View File
@@ -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 <repo-root> <output>" >&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"