Skip to content

Commit 41412ed

Browse files
zachdunnclaude
andauthored
feat(web,api): filter org release feed by source type, hide prereleases by default (#797)
* feat(web,api): filter org release feed by source type, hide prereleases by default Adds two filters to `/v1/orgs/:slug/releases` and the org Releases tab: - `?source_type=github,feed` (catalog-shaped tolerant parser; unknowns dropped) - `?include_prereleases=true` (default false) Prerelease detection runs at ingest. The GitHub adapter captures the authoritative `prerelease` field from the GitHub API; other adapters fall back to a SemVer-prerelease regex via `@buildinternet/releases-core/prerelease` covering -alpha/-beta/-rc/-pre/-preview/-nightly/-canary/-snapshot/-dev/-edge /-insider/-experimental/-early-access/-ea/-mN identifiers. Migration `20260507000000_releases_prerelease.sql` adds the column with default 0; `20260507000001_releases_prerelease_backfill.sql` retags existing rows by version pattern. The existing `idx_releases_source_suppressed_published` composite carries the per-org join — a standalone bool index would have near-zero selectivity and sit unused. Web frontend renders a filter row above the release list: source-type chips (auto-hidden when only one source type exists for the org) and a "Show prereleases" checkbox. Pre-release rows get a small "pre" pill. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address PR #797 review: error UI, decouple prerelease toggle, missed call sites - Surface non-abort fetch failures via a fetchError banner above the list (CodeRabbit catch was real — re-throwing escaped the promise chain) - Decouple "Show prereleases" checkbox from source-type tab visibility: feed/scrape/agent-only orgs need the toggle too (their adapters fall back to the version-pattern detector) - Add `-next` and `-milestone` to the SQL backfill (`-next.x` is common for pnpm/Supabase; the regex already covered both) - Add `prerelease` column to the `releasesVisible` Drizzle view declaration so type-safe queries can reference it; plumb through the GraphQL loader - Set `prerelease` on the single-release seed endpoint (POST /sources/:slug/releases) — it was hitting the column default of 0 - Bump cron releases-insert chunk from hardcoded 5 to RELEASES_BATCH_CHUNK_SIZE (7), aligning with the bind-budget convention used elsewhere Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9f94c96 commit 41412ed

24 files changed

Lines changed: 415 additions & 67 deletions

File tree

packages/adapters/src/github.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ interface GitHubRelease {
404404
body: string | null;
405405
html_url: string;
406406
published_at: string | null;
407+
prerelease: boolean;
407408
}
408409

409410
// Re-fetch protection: The UNIQUE constraints on releases (source_id, url)
@@ -467,6 +468,7 @@ export const github: Adapter = {
467468
content: rel.body || "",
468469
url: rel.html_url,
469470
publishedAt,
471+
prerelease: rel.prerelease === true,
470472
});
471473

472474
if (maxEntries && releases.length >= maxEntries) {

packages/adapters/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ export interface RawRelease {
99
isBreaking?: boolean;
1010
type?: ReleaseType;
1111
media?: Array<{ type: "image" | "video" | "gif"; url: string; alt?: string }>;
12+
/**
13+
* Whether this is a pre-release (beta, rc, nightly, preview, etc.). Set
14+
* authoritatively by adapters with first-class signals (GitHub releases
15+
* API exposes `prerelease`); other adapters leave it undefined and let
16+
* the upsert path fall back to a version-pattern heuristic via
17+
* `isPrereleaseVersion()`.
18+
*/
19+
prerelease?: boolean;
1220
}
1321

1422
export interface FetchOptions {

packages/api-types/src/schemas/shared.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export const ReleaseItemSchema = z.object({
5252
// `.optional()` — older API responses (mid-deploy or pinned old workers)
5353
// may omit `type`; consumers should treat `undefined` as `"feature"`.
5454
type: ReleaseTypeSchema.optional(),
55+
// Pre-release flag (alpha/beta/rc/preview/nightly). Computed at ingest —
56+
// GitHub uses the API's authoritative `prerelease` field; other adapters
57+
// fall back to a SemVer-prerelease regex. `.optional()` for the same
58+
// older-response degrade-gracefully reason as `type`.
59+
prerelease: z.boolean().optional(),
5560
});
5661

5762
export const ReleaseSummaryItemSchema = z.object({

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"./sources": "./src/sources.ts",
2626
"./reserved-slugs": "./src/reserved-slugs.ts",
2727
"./tokens": "./src/tokens.ts",
28+
"./prerelease": "./src/prerelease.ts",
2829
"./cli-contracts": "./src/cli-contracts.ts",
2930
"./cursor": "./src/cursor.ts"
3031
},

packages/core/src/prerelease.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Detect whether a release version string represents a pre-release.
3+
*
4+
* Used at ingest time as a fallback for non-GitHub adapters (the GitHub
5+
* adapter uses the API's authoritative `prerelease` field instead). Matches
6+
* the SemVer pre-release convention — anything after a `-` separator that
7+
* begins with a recognized identifier (`alpha`, `beta`, `rc`, `pre`,
8+
* `preview`, `nightly`, `dev`, `canary`, `next`, `snapshot`, `milestone`,
9+
* `m1`/`M1`, etc.). Date-suffixed nightlies (`-nightly.20260506`) and
10+
* commit-suffixed previews (`-preview.1.g80d269054`) match too.
11+
*
12+
* Conservative on purpose — when in doubt, return false so we don't
13+
* accidentally hide a real release. The flag is a UX nicety, not a
14+
* security boundary.
15+
*/
16+
const PRERELEASE_TAG_RE =
17+
/[-.](?:alpha|beta|rc|pre|preview|nightly|dev|canary|next|snapshot|milestone|m\d+|edge|insider|experimental|test|early-access|ea)\b/i;
18+
19+
export function isPrereleaseVersion(version: string | null | undefined): boolean {
20+
if (!version) return false;
21+
const trimmed = version.trim();
22+
if (!trimmed) return false;
23+
return PRERELEASE_TAG_RE.test(trimmed);
24+
}

packages/core/src/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ export const releases = sqliteTable(
259259
metadata: text("metadata").default("{}"),
260260
media: text("media").default("[]"),
261261
publishedAt: text("published_at"),
262+
prerelease: integer("prerelease", { mode: "boolean" }).notNull().default(false),
262263
suppressed: integer("suppressed", { mode: "boolean" }).default(false),
263264
suppressedReason: text("suppressed_reason"),
264265
fetchedAt: text("fetched_at")
@@ -820,6 +821,7 @@ export const releasesVisible = sqliteView("releases_visible", {
820821
metadata: text("metadata"),
821822
media: text("media"),
822823
publishedAt: text("published_at"),
824+
prerelease: integer("prerelease", { mode: "boolean" }).notNull(),
823825
suppressed: integer("suppressed", { mode: "boolean" }),
824826
suppressedReason: text("suppressed_reason"),
825827
fetchedAt: text("fetched_at").notNull(),

tests/api/releases-batch-binds.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const mockRow = (i: number) => ({
3636
url: `https://example.invalid/${i}`,
3737
contentHash: "h",
3838
publishedAt: "2026-01-01",
39+
prerelease: false,
3940
media: "[]",
4041
});
4142

tests/unit/prerelease.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { isPrereleaseVersion } from "@buildinternet/releases-core/prerelease";
3+
4+
describe("isPrereleaseVersion", () => {
5+
test.each([
6+
"v0.42.0-preview.1",
7+
"v0.42.0-preview.2",
8+
"v0.42.0-nightly.20260506.g80d269054",
9+
"v1.0.0-rc.2",
10+
"1.0.0-alpha",
11+
"1.0.0-alpha.3",
12+
"1.0.0-beta.1",
13+
"v2.0.0-pre.4",
14+
"0.5.0-canary.7",
15+
"5.0.0-next.42",
16+
"1.2.3-dev",
17+
"3.0.0-snapshot",
18+
"1.0.0-M1",
19+
"1.0.0-milestone.2",
20+
"v22.0.0-edge",
21+
"1.0.0-insider",
22+
"1.0.0-experimental",
23+
"1.0.0-early-access",
24+
])("flags %s as prerelease", (v) => {
25+
expect(isPrereleaseVersion(v)).toBe(true);
26+
});
27+
28+
test.each(["v1.0.0", "1.0.0", "v0.41.2", "2024.05.07", "v22-lts", "v1.0.0+build.123"])(
29+
"does not flag %s as prerelease",
30+
(v) => {
31+
expect(isPrereleaseVersion(v)).toBe(false);
32+
},
33+
);
34+
35+
test("handles null/undefined/empty", () => {
36+
expect(isPrereleaseVersion(null)).toBe(false);
37+
expect(isPrereleaseVersion(undefined)).toBe(false);
38+
expect(isPrereleaseVersion("")).toBe(false);
39+
expect(isPrereleaseVersion(" ")).toBe(false);
40+
});
41+
});

web/src/app/[orgSlug]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export default async function OrgPage({
180180
initialReleases={initialReleases.releases}
181181
initialCursor={initialReleases.pagination.nextCursor}
182182
multipleSourcesExist={org.sources.length > 1}
183+
availableSourceTypes={Array.from(new Set(org.sources.map((s) => s.type)))}
183184
/>
184185
) : (
185186
<div className="text-center py-12 text-stone-400 dark:text-stone-500 text-sm">

web/src/app/api/format/[orgSlug]/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export async function GET(
1919
try {
2020
[org, feed] = await Promise.all([
2121
api.orgDetail(orgSlug),
22-
api.orgReleases(orgSlug, undefined, ATOM_DEFAULT_MAX_ENTRIES),
22+
api.orgReleases(orgSlug, { limit: ATOM_DEFAULT_MAX_ENTRIES }),
2323
]);
2424
} catch {
2525
return NextResponse.json(
@@ -35,7 +35,7 @@ export async function GET(
3535
try {
3636
[org, feed] = await Promise.all([
3737
api.orgDetail(orgSlug),
38-
api.orgReleases(orgSlug, undefined, 10),
38+
api.orgReleases(orgSlug, { limit: 10 }),
3939
]);
4040
} catch {
4141
return NextResponse.json(

0 commit comments

Comments
 (0)