perf(slug): point-lookup instead of full discovery for single-repo pages and 404 #27

Closed
opened 2026-08-07 19:14:17 +03:00 by slaid098 · 0 comments
Owner

Контекст

Зачем: страницы одиночного репо (/[locale]/[slug]) и 404 на неизвестном slug медленные (cold cache ~1.5-2с) из-за того, что для разрешения одного slug вызывается list-функция getShowcaseRepos(), делающая 7 последовательных запросов к Forgejo API. Для 404 (несуществующий slug) страница сначала делает все 7 запросов, и только потом вызывает notFound().

Контекст (текущая архитектура, подтверждено чтением файлов):

src/lib/readme-discovery.tsdiscoverShowcaseRepos(owner, token, fetchOptions) (строка 188):

  1. GET /users/{owner}/repos?limit=50 → список всех репо (1 запрос, строка 196)
  2. Фильтр кандидатов: !r.fork && !r.private && r.name !== "slaid098-dev" (строка 202)
  3. Последовательный цикл for (const repo of candidates) (строка 205, for...of + await), для КАЖДОГО кандидата:
    • GET /repos/{owner}/{repo.name} — detail (строки 207-217)
    • fetchReadme(...) — readme (строка 220)
    • fetchCoverMeta(...) — cover meta (строка 221)
  4. Возвращает DiscoveredRepo[]. Вспомогательные функции: fetchReadme (строка 155), fetchCoverMeta (строка 135), parseReadme (строка 104), makeErrorRepo (строка 169).

Реальные числа (Forgejo, аккаунт slaid098): 22 репо всего, 2 public non-fork кандидата (opencode-config, opencode-voice-dictation), 19 private (отфильтрованы). Cold call: 1 (list) + 2×3 (detail+readme+cover) = 7 последовательных запросов, ~1.5-2с (7 RTT × ~80-100ms). Warm cache: 0 запросов (revalidate=3600, cache: "force-cache", tag showcase). Инвалидация: webhook (revalidateTag("showcase") on push) и cron (/api/cron-refresh).

src/lib/showcase.ts (43 строки):

  • getShowcaseRepos = cache(_getShowcaseRepos) (строка 19) — обёртка React cache() поверх discoverShowcaseRepos. Дедуплит per-request (generateMetadata + Page шарят один вызов), НЕ персистит между запросами.
  • findRepo(repos, slug) (строка 25) — линейный поиск по slug в массиве.
  • Точечной функции поиска одного репо НЕТ.

src/app/[locale]/[slug]/page.tsx (194 строки):

  • generateStaticParams (строки 20-27) — возвращает известные slug'и build-time через getShowcaseRepos() (try/catch fallback []).
  • generateMetadata (строки 29-59): getShowcaseRepos() (строка 35) + findRepo(repos, slug) (строка 36).
  • Page (строки 61-163):
    const repos = await getShowcaseRepos();   // строка 69 — 7 последовательных fetch
    const repo = findRepo(repos, slug);        // строка 70
    if (!repo) {                               // строка 71
      notFound();                             // строка 72 — только ПОСЛЕ всех 7 запросов
    }
    

src/app/[locale]/apps/page.tsx (119 строк):

  • readyRepos(await getShowcaseRepos()) (строка 39) — КОРРЕКТНОЕ использование list-функции (нужны все репо). БЕЗ изменений.

Задача

  1. В src/lib/readme-discovery.ts — вынести тело цикла (строки 206-261) в отдельную экспортируемую функцию точечного поиска одного репо, например discoverSingleRepo(owner, repoName, token, fetchOptions): Promise<DiscoveredRepo | null>. Логика:

    • GET /repos/{owner}/{repoName} — один запрос detail. 404 → return null (slug не существует). 200 → репо есть.
    • Если репо fork/private/name === "slaid098-dev"return null (не подходит для showcase, трактуется как 404).
    • Если detail OK и eligible: fetchReadme + fetchCoverMeta + parseReadme (те же 3-4 запроса: detail + readme + cover, как в цикле).
    • Те же cache: "force-cache" + next: { revalidate: 3600, tags: ["showcase"] } опции кеширования.
    • Переиспользовать существующие fetchReadme (строка 155), fetchCoverMeta (строка 135), parseReadme (строка 104), makeErrorRepo (строка 169).
    • Тело цикла в discoverShowcaseRepos (строки 206-261) должно вызывать новую discoverSingleRepo (или общую логику) — чтобы list и single разделяли per-repo логику, без дублирования.
  2. В src/lib/showcase.ts — добавить getShowcaseRepo(slug): Promise<ShowcaseRepo | null>:

    • Обёрнут в React cache() (дедуп per-request между Page и generateMetadata).
    • Вызывает discoverSingleRepo(SHOWCASE_OWNER, slug, FORGEJO_TOKEN, { next: { tags: ["showcase"], revalidate: 3600 } }).
    • Возвращает null если репо не найдено/не eligible.
  3. В src/app/[locale]/[slug]/page.tsx:

    • Page (строка 69-73): заменить getShowcaseRepos() + findRepo() на getShowcaseRepo(slug). Если nullnotFound(). Иначе рендер с одним репо.
    • generateMetadata (строки 35-37): заменить getShowcaseRepos() + findRepo() на getShowcaseRepo(slug). Если nullreturn {}.
    • generateStaticParams (строки 20-27): БЕЗ изменений — оставляет getShowcaseRepos() (нужны все slug'и build-time, это корректно).
    • Импорты: убрать findRepo, getShowcaseRepos (если не используются после замены в Page/generateMetadata; generateStaticParams всё ещё использует getShowcaseRepos — оставить импорт), добавить getShowcaseRepo.
  4. src/app/[locale]/apps/page.tsx — БЕЗ изменений (продолжает использовать getShowcaseRepos() для списка).

Контракты

  • getShowcaseRepo(slug: string): Promise<ShowcaseRepo | null> — новая публичная функция в src/lib/showcase.ts.
  • discoverSingleRepo(owner: string, repoName: string, token: string, fetchOptions): Promise<DiscoveredRepo | null> — новая экспортируемая функция в src/lib/readme-discovery.ts.
  • getShowcaseRepos(): Promise<ShowcaseRepo[]> — БЕЗ изменений сигнатуры и поведения.
  • findRepo(repos, slug) — может остаться (используется тестами/совместимость) или быть удалён, если больше не используется; реализатор решает.
  • Маршруты /[locale]/[slug] и /[locale]/apps — без изменений в URL/API.
  • Кеширование: getShowcaseRepo использует те же опции (cache: "force-cache", next: { revalidate: 3600, tags: ["showcase"] }), инвалидируется тем же tag showcase (webhook/cron).

Ожидаемое количество запросов (cold cache):

Сценарий URL До После
404 без локали /nonexistent 8 (redirect + 7 discovery) 2 (redirect + 1: GET /repos/{owner}/{slug} → 404 → notFound)
404 валидная локаль /ru/nonexistent 7 1 (GET /repos/{owner}/{slug} → 404 → notFound)
Валидный slug cold /ru/opencode-config 7 3-4 (detail + readme + cover для ОДНОГО репо)
/apps список /ru/apps 7 7 (без изменений)
Warm (любой) 0 0 (cache)

Инварианты

  • React cache() обёртка для getShowcaseRepo — дедуп per-request между Page и generateMetadata.
  • Опции кеширования идентичны getShowcaseRepos: cache: "force-cache", next: { revalidate: 3600, tags: ["showcase"] }.
  • Точечный поиск возвращает null (не бросает) для: несуществующий slug (404), fork, private, slaid098-dev.
  • generateStaticParams продолжать использовать getShowcaseRepos() — нужны все slug'и build-time.
  • apps/page.tsx продолжать использовать getShowcaseRepos() — нужен полный список.
  • Per-repo логика (detail+readme+cover+parse) не дублируется между list и single — общая функция.

Граничные случаи

  • Forgejo API 404 на GET /repos/{owner}/{slug}getShowcaseRepo возвращает nullnotFound().
  • Forgejo API 500/network error на detail → discoverSingleRepo возвращает null (или error-репо? — согласовать: null проще для 404-логики; ошибка сети — null, т.к. не можем отличить от 404 без проверки статуса).
  • Репо fork/private/excluded (slaid098-dev) → nullnotFound() (трактуется как 404, не как error).
  • README отсутствует → detail OK, readme null → DiscoveredRepo с readmeValid: false (как в текущем цикле, строки 225-243).
  • Cover отсутствует → hasCover: false, coverSha: null (как fetchCoverMeta строки 146-149).
  • Несовпадение slug (case-sensitivity): Forgejo имена репо case-sensitive — slug должен точно совпадать. Текущее поведение findRepo — точное совпадение, сохранить.
  • Параллельные запросы на один slug → React cache() дедуплит в пределах запроса; data-cache Next.js дедуплит между запросами (warm).

Влияние на связанные компоненты

  • src/lib/showcase.ts — добавляется getShowcaseRepo; getShowcaseRepos/findRepo без изменений (или findRepo удаляется если неиспользуем).
  • src/lib/readme-discovery.ts — добавляется discoverSingleRepo; discoverShowcaseRepos рефакторится вызывать общую per-repo логику. Существующие экспорты (parseReadme, extractBetween, parseFeaturesTable, типы) без изменений.
  • src/app/[locale]/[slug]/page.tsxPage и generateMetadata переключаются на getShowcaseRepo; generateStaticParams без изменений.
  • src/app/[locale]/apps/page.tsx — БЕЗ изменений.
  • src/app/[locale]/%5F%5F404%5F%5F/page.tsx — БЕЗ изменений (статичный 404 для /ru/__404__, не покрывает unknown-slug — это отдельный concern, см. "Вне scope").
  • src/middleware.ts — БЕЗ изменений.
  • Webhook (revalidateTag("showcase")) и cron (/api/cron-refresh) — БЕЗ изменений (тот же tag showcase инвалидирует и list, и single).
  • Тесты: src/__tests__/slug-static-params.test.ts — БЕЗ изменений (покрывает generateStaticParams, который не меняется). not-found.test.tsx, middleware.test.ts, loading.test.tsx — БЕЗ изменений.

Вне scope

  • Перенос unknown-slug 404 на статический роут (как /ru/__404__) — отдельная задача; здесь только ускоряем discovery.
  • Параллелизация запросов внутри discoverShowcaseRepos (Promise.all вместо for...of) — list-функция для /apps, вне scope.
  • Пагинация list endpoint (limit=50) — сейчас 22 репо, за пределами.
  • Изменение кеш-политики (revalidate/TTL) — сохранить текущие значения.
  • Удаление findRepo — опционально, на усмотрение реализатора, не отдельная задача.

Критерии приемки

  • getShowcaseRepo("nonexistent-slug") возвращает null (mock Forgejo: GET /repos/{owner}/nonexistent-slug → 404).
  • getShowcaseRepo("opencode-config") возвращает валидный DiscoveredRepo (mock: detail 200 + readme 200 + cover 200).
  • src/app/[locale]/[slug]/page.tsx Page для unknown slug вызывает notFound() БЕЗ вызова getShowcaseRepos() (тест-регрессия: assert list-функция НЕ вызывается на single-slug path).
  • generateStaticParams в src/app/[locale]/[slug]/page.tsx всё ещё использует getShowcaseRepos() (не сломан).
  • src/app/[locale]/apps/page.tsx рендерит список через getShowcaseRepos() без изменений (существующий тест/ручная проверка).
  • Cold-запрос /ru/nonexistent → 1 запрос к Forgejo (GET /repos/{owner}/nonexistent → 404), не 7.
  • Cold-запрос /ru/opencode-config → 3-4 запроса (detail+readme+cover), не 7.
  • Warm-запрос любого slug → 0 запросов (cache hit).
  • npm run lint (ruff/eslint аналог) и npm run typecheck (tsc --noEmit) проходят без ошибок.
  • npm test (vitest) проходит, включая новые тесты для getShowcaseRepo.
  • Проверка: curl -sI "$FORGEJO_URL/api/v1/repos/slaid098/nonexistent" -H "Authorization: token $FORGEJO_TOKEN" → 404 подтверждает, что точечный запрос детектит несуществующий slug одним запросом.
## Контекст Зачем: страницы одиночного репо (`/[locale]/[slug]`) и 404 на неизвестном slug медленные (cold cache ~1.5-2с) из-за того, что для разрешения одного slug вызывается list-функция `getShowcaseRepos()`, делающая 7 последовательных запросов к Forgejo API. Для 404 (несуществующий slug) страница сначала делает все 7 запросов, и только потом вызывает `notFound()`. Контекст (текущая архитектура, подтверждено чтением файлов): **`src/lib/readme-discovery.ts`** — `discoverShowcaseRepos(owner, token, fetchOptions)` (строка 188): 1. `GET /users/{owner}/repos?limit=50` → список всех репо (1 запрос, строка 196) 2. Фильтр кандидатов: `!r.fork && !r.private && r.name !== "slaid098-dev"` (строка 202) 3. Последовательный цикл `for (const repo of candidates)` (строка 205, `for...of` + `await`), для КАЖДОГО кандидата: - `GET /repos/{owner}/{repo.name}` — detail (строки 207-217) - `fetchReadme(...)` — readme (строка 220) - `fetchCoverMeta(...)` — cover meta (строка 221) 4. Возвращает `DiscoveredRepo[]`. Вспомогательные функции: `fetchReadme` (строка 155), `fetchCoverMeta` (строка 135), `parseReadme` (строка 104), `makeErrorRepo` (строка 169). Реальные числа (Forgejo, аккаунт slaid098): 22 репо всего, 2 public non-fork кандидата (`opencode-config`, `opencode-voice-dictation`), 19 private (отфильтрованы). Cold call: 1 (list) + 2×3 (detail+readme+cover) = **7 последовательных запросов**, ~1.5-2с (7 RTT × ~80-100ms). Warm cache: 0 запросов (`revalidate=3600`, `cache: "force-cache"`, tag `showcase`). Инвалидация: webhook (`revalidateTag("showcase")` on push) и cron (`/api/cron-refresh`). **`src/lib/showcase.ts`** (43 строки): - `getShowcaseRepos` = `cache(_getShowcaseRepos)` (строка 19) — обёртка React `cache()` поверх `discoverShowcaseRepos`. Дедуплит per-request (generateMetadata + Page шарят один вызов), НЕ персистит между запросами. - `findRepo(repos, slug)` (строка 25) — линейный поиск по slug в массиве. - Точечной функции поиска одного репо НЕТ. **`src/app/[locale]/[slug]/page.tsx`** (194 строки): - `generateStaticParams` (строки 20-27) — возвращает известные slug'и build-time через `getShowcaseRepos()` (try/catch fallback `[]`). - `generateMetadata` (строки 29-59): `getShowcaseRepos()` (строка 35) + `findRepo(repos, slug)` (строка 36). - `Page` (строки 61-163): ```ts const repos = await getShowcaseRepos(); // строка 69 — 7 последовательных fetch const repo = findRepo(repos, slug); // строка 70 if (!repo) { // строка 71 notFound(); // строка 72 — только ПОСЛЕ всех 7 запросов } ``` **`src/app/[locale]/apps/page.tsx`** (119 строк): - `readyRepos(await getShowcaseRepos())` (строка 39) — КОРРЕКТНОЕ использование list-функции (нужны все репо). БЕЗ изменений. ## Задача 1. В `src/lib/readme-discovery.ts` — вынести тело цикла (строки 206-261) в отдельную экспортируемую функцию точечного поиска одного репо, например `discoverSingleRepo(owner, repoName, token, fetchOptions): Promise<DiscoveredRepo | null>`. Логика: - `GET /repos/{owner}/{repoName}` — один запрос detail. 404 → `return null` (slug не существует). 200 → репо есть. - Если репо fork/private/`name === "slaid098-dev"` → `return null` (не подходит для showcase, трактуется как 404). - Если detail OK и eligible: `fetchReadme` + `fetchCoverMeta` + `parseReadme` (те же 3-4 запроса: detail + readme + cover, как в цикле). - Те же `cache: "force-cache"` + `next: { revalidate: 3600, tags: ["showcase"] }` опции кеширования. - Переиспользовать существующие `fetchReadme` (строка 155), `fetchCoverMeta` (строка 135), `parseReadme` (строка 104), `makeErrorRepo` (строка 169). - Тело цикла в `discoverShowcaseRepos` (строки 206-261) должно вызывать новую `discoverSingleRepo` (или общую логику) — чтобы list и single разделяли per-repo логику, без дублирования. 2. В `src/lib/showcase.ts` — добавить `getShowcaseRepo(slug): Promise<ShowcaseRepo | null>`: - Обёрнут в React `cache()` (дедуп per-request между `Page` и `generateMetadata`). - Вызывает `discoverSingleRepo(SHOWCASE_OWNER, slug, FORGEJO_TOKEN, { next: { tags: ["showcase"], revalidate: 3600 } })`. - Возвращает `null` если репо не найдено/не eligible. 3. В `src/app/[locale]/[slug]/page.tsx`: - `Page` (строка 69-73): заменить `getShowcaseRepos()` + `findRepo()` на `getShowcaseRepo(slug)`. Если `null` → `notFound()`. Иначе рендер с одним репо. - `generateMetadata` (строки 35-37): заменить `getShowcaseRepos()` + `findRepo()` на `getShowcaseRepo(slug)`. Если `null` → `return {}`. - `generateStaticParams` (строки 20-27): БЕЗ изменений — оставляет `getShowcaseRepos()` (нужны все slug'и build-time, это корректно). - Импорты: убрать `findRepo`, `getShowcaseRepos` (если не используются после замены в Page/generateMetadata; `generateStaticParams` всё ещё использует `getShowcaseRepos` — оставить импорт), добавить `getShowcaseRepo`. 4. `src/app/[locale]/apps/page.tsx` — БЕЗ изменений (продолжает использовать `getShowcaseRepos()` для списка). ## Контракты - `getShowcaseRepo(slug: string): Promise<ShowcaseRepo | null>` — новая публичная функция в `src/lib/showcase.ts`. - `discoverSingleRepo(owner: string, repoName: string, token: string, fetchOptions): Promise<DiscoveredRepo | null>` — новая экспортируемая функция в `src/lib/readme-discovery.ts`. - `getShowcaseRepos(): Promise<ShowcaseRepo[]>` — БЕЗ изменений сигнатуры и поведения. - `findRepo(repos, slug)` — может остаться (используется тестами/совместимость) или быть удалён, если больше не используется; реализатор решает. - Маршруты `/[locale]/[slug]` и `/[locale]/apps` — без изменений в URL/API. - Кеширование: `getShowcaseRepo` использует те же опции (`cache: "force-cache"`, `next: { revalidate: 3600, tags: ["showcase"] }`), инвалидируется тем же tag `showcase` (webhook/cron). Ожидаемое количество запросов (cold cache): | Сценарий | URL | До | После | |----------|-----|----|------| | 404 без локали | `/nonexistent` | 8 (redirect + 7 discovery) | 2 (redirect + 1: `GET /repos/{owner}/{slug}` → 404 → notFound) | | 404 валидная локаль | `/ru/nonexistent` | 7 | 1 (`GET /repos/{owner}/{slug}` → 404 → notFound) | | Валидный slug cold | `/ru/opencode-config` | 7 | 3-4 (detail + readme + cover для ОДНОГО репо) | | `/apps` список | `/ru/apps` | 7 | 7 (без изменений) | | Warm (любой) | — | 0 | 0 (cache) | ## Инварианты - React `cache()` обёртка для `getShowcaseRepo` — дедуп per-request между `Page` и `generateMetadata`. - Опции кеширования идентичны `getShowcaseRepos`: `cache: "force-cache"`, `next: { revalidate: 3600, tags: ["showcase"] }`. - Точечный поиск возвращает `null` (не бросает) для: несуществующий slug (404), fork, private, `slaid098-dev`. - `generateStaticParams` продолжать использовать `getShowcaseRepos()` — нужны все slug'и build-time. - `apps/page.tsx` продолжать использовать `getShowcaseRepos()` — нужен полный список. - Per-repo логика (detail+readme+cover+parse) не дублируется между list и single — общая функция. ## Граничные случаи - Forgejo API 404 на `GET /repos/{owner}/{slug}` → `getShowcaseRepo` возвращает `null` → `notFound()`. - Forgejo API 500/network error на detail → `discoverSingleRepo` возвращает `null` (или error-репо? — согласовать: `null` проще для 404-логики; ошибка сети — `null`, т.к. не можем отличить от 404 без проверки статуса). - Репо fork/private/excluded (`slaid098-dev`) → `null` → `notFound()` (трактуется как 404, не как error). - README отсутствует → detail OK, readme null → `DiscoveredRepo` с `readmeValid: false` (как в текущем цикле, строки 225-243). - Cover отсутствует → `hasCover: false`, `coverSha: null` (как `fetchCoverMeta` строки 146-149). - Несовпадение slug (case-sensitivity): Forgejo имена репо case-sensitive — slug должен точно совпадать. Текущее поведение `findRepo` — точное совпадение, сохранить. - Параллельные запросы на один slug → React `cache()` дедуплит в пределах запроса; data-cache Next.js дедуплит между запросами (warm). ## Влияние на связанные компоненты - `src/lib/showcase.ts` — добавляется `getShowcaseRepo`; `getShowcaseRepos`/`findRepo` без изменений (или `findRepo` удаляется если неиспользуем). - `src/lib/readme-discovery.ts` — добавляется `discoverSingleRepo`; `discoverShowcaseRepos` рефакторится вызывать общую per-repo логику. Существующие экспорты (`parseReadme`, `extractBetween`, `parseFeaturesTable`, типы) без изменений. - `src/app/[locale]/[slug]/page.tsx` — `Page` и `generateMetadata` переключаются на `getShowcaseRepo`; `generateStaticParams` без изменений. - `src/app/[locale]/apps/page.tsx` — БЕЗ изменений. - `src/app/[locale]/%5F%5F404%5F%5F/page.tsx` — БЕЗ изменений (статичный 404 для `/ru/__404__`, не покрывает unknown-slug — это отдельный concern, см. "Вне scope"). - `src/middleware.ts` — БЕЗ изменений. - Webhook (`revalidateTag("showcase")`) и cron (`/api/cron-refresh`) — БЕЗ изменений (тот же tag `showcase` инвалидирует и list, и single). - Тесты: `src/__tests__/slug-static-params.test.ts` — БЕЗ изменений (покрывает `generateStaticParams`, который не меняется). `not-found.test.tsx`, `middleware.test.ts`, `loading.test.tsx` — БЕЗ изменений. ## Вне scope - ❌ Перенос unknown-slug 404 на статический роут (как `/ru/__404__`) — отдельная задача; здесь только ускоряем discovery. - ❌ Параллелизация запросов внутри `discoverShowcaseRepos` (Promise.all вместо for...of) — list-функция для `/apps`, вне scope. - ❌ Пагинация list endpoint (`limit=50`) — сейчас 22 репо, за пределами. - ❌ Изменение кеш-политики (revalidate/TTL) — сохранить текущие значения. - ❌ Удаление `findRepo` — опционально, на усмотрение реализатора, не отдельная задача. ## Критерии приемки - [ ] `getShowcaseRepo("nonexistent-slug")` возвращает `null` (mock Forgejo: `GET /repos/{owner}/nonexistent-slug` → 404). - [ ] `getShowcaseRepo("opencode-config")` возвращает валидный `DiscoveredRepo` (mock: detail 200 + readme 200 + cover 200). - [ ] `src/app/[locale]/[slug]/page.tsx` `Page` для unknown slug вызывает `notFound()` БЕЗ вызова `getShowcaseRepos()` (тест-регрессия: assert list-функция НЕ вызывается на single-slug path). - [ ] `generateStaticParams` в `src/app/[locale]/[slug]/page.tsx` всё ещё использует `getShowcaseRepos()` (не сломан). - [ ] `src/app/[locale]/apps/page.tsx` рендерит список через `getShowcaseRepos()` без изменений (существующий тест/ручная проверка). - [ ] Cold-запрос `/ru/nonexistent` → 1 запрос к Forgejo (`GET /repos/{owner}/nonexistent` → 404), не 7. - [ ] Cold-запрос `/ru/opencode-config` → 3-4 запроса (detail+readme+cover), не 7. - [ ] Warm-запрос любого slug → 0 запросов (cache hit). - [ ] `npm run lint` (ruff/eslint аналог) и `npm run typecheck` (tsc --noEmit) проходят без ошибок. - [ ] `npm test` (vitest) проходит, включая новые тесты для `getShowcaseRepo`. - [ ] Проверка: `curl -sI "$FORGEJO_URL/api/v1/repos/slaid098/nonexistent" -H "Authorization: token $FORGEJO_TOKEN"` → 404 подтверждает, что точечный запрос детектит несуществующий slug одним запросом.
Sign in to join this conversation.
No labels
bug
performance
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
slaid098/slaid098-dev#27
No description provided.