Project feed v1: synthesized digests from logbook + git history#12
Conversation
|
@Dexploarer is attempting to deploy a commit to the SYMBiEX's projects Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Code Review
This pull request implements a project feed feature, introducing a new project_feed_entries database table, an Atom 1.0 syndication route, and a dedicated UI for daily digests and milestones. The feed generation is integrated into the existing snapshot workflow. Feedback identifies a bug in the database conflict handling where a where clause is missing to match a partial index, suggests using environment variables instead of hardcoded domains, recommends more stable Atom entry IDs, and advises against using as never for better type safety.
|
|
||
| const { header } = data; | ||
| const slug = `${header.ghOwner}/${header.ghRepo}`; | ||
| const siteUrl = `https://gitshipt.com/r/${slug}`; |
There was a problem hiding this comment.
| subtitle: `Daily digests + milestones for ${slug}, synthesized from the leaderboard and indexed git history.`, | ||
| updated, | ||
| entries: entries.map((e) => ({ | ||
| id: `${selfUrl}#${e.id}`, |
There was a problem hiding this comment.
| return ( | ||
| <li key={row.id}> | ||
| <PeriodDigestCard | ||
| subjects={row.subjects as never} |
There was a problem hiding this comment.
|
Warning Rate limit exceeded
To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (19)
📝 WalkthroughWalkthroughThis PR introduces a project feed system that displays activity digests and contributor highlights. It adds a database table for feed entries, implements feed generation logic triggered by snapshots, creates a feed page with Atom syndication support, and integrates feed writing into the project snapshot workflow. Changes
Sequence DiagramsequenceDiagram
participant Workflow as Snapshot Workflow
participant Writer as Feed Writer
participant DB as Database
participant Cache as Cache Layer
participant Query as Feed Query
participant Page as Feed Page
Workflow->>Writer: writePeriodDigestForSnapshot(snapshotId)
Writer->>DB: fetch snapshot & project data
Writer->>Writer: buildPeriodDigestSubjects()
Writer->>Writer: renderPeriodDigestMarkdown()
Writer->>DB: INSERT feed entry ON CONFLICT DO NOTHING
alt Entry newly inserted
Writer->>Cache: revalidatePath() for project
end
DB-->>Writer: entryId
Writer-->>Workflow: {inserted, entryId}
Note over Workflow: Snapshot complete
Page->>Query: getProjectFeed(projectId, limit=50)
Query->>DB: SELECT entries ORDER BY pinnedUntil DESC, created_at DESC
DB-->>Query: feed entries with bodyMd
Query-->>Page: ProjectFeedRow[]
Page->>Page: render PeriodDigestCard for each entry
Page-->>Page: display feed to user
Estimated code review effort🎯 4 (Complex) | ⏱️ ~55 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 0/1 reviews remaining, refill in 48 minutes and 40 seconds.Comment |
Two HIGH-priority bugs and two MEDIUM-priority polish items from
gemini-code-assist's review:
HIGH — writer.ts onConflictDoNothing missing where clause for
partial unique index. Postgres requires the index predicate to be
repeated in the ON CONFLICT clause when the conflict target is a
partial index — without it the query throws "there is no unique or
exclusion constraint matching the ON CONFLICT specification" at
runtime, meaning the very first snapshot would crash. Added
`where: sql\`${period} IS NOT NULL\`` to match the partial index
defined in schema/project-feed-entries.ts.
HIGH — feed.atom hardcoded "https://gitshipt.com" broke local dev
and any non-production environment. Now reads NEXT_PUBLIC_APP_URL
via clientEnv() (already used elsewhere in the codebase, defaults
to http://localhost:3000 per env.ts schema).
MEDIUM — atom entry IDs were `{selfUrl}#{rowId}`, which is unstable
across domain renames or path changes (atom readers would treat
entries as new again). Switched to RFC 4151 tag URN
(`tag:gitshipt.com,2026:project-feed/entry/{rowId}`) — globally
unique, permanent across path changes. Same pattern for the feed
itself (`tag:gitshipt.com,2026:project-feed/{projectId}`).
MEDIUM — feed/page.tsx used `subjects as never` to silence the
union narrowing on FeedEntrySubjects. Replaced with explicit
`PeriodDigestSubjects` cast inside the kind === "period_digest"
branch + a comment noting the discriminator/payload contract is
maintained at insert time.
bun run typecheck clean across all 4 packages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d4f4a7634c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const result = await dbHttp | ||
| .update(projectFeedEntries) | ||
| .set({ pinnedUntil }) | ||
| .where(eq(projectFeedEntries.id, entryId)) | ||
| .returning({ id: projectFeedEntries.id }); | ||
| return { ok: result.length > 0 }; |
There was a problem hiding this comment.
Invalidate cached feed reads after pin updates
setFeedEntryPin updates project_feed_entries.pinned_until and returns immediately, but it does not revalidate any cache tags even though getProjectFeed/getProjectFeedAtomData are "use cache" reads tagged by project. As a result, pin/unpin operations can keep serving stale ordering until cache expiry, so admins won't reliably see their pin change reflected on the next render.
Useful? React with 👍 / 👎.
| cacheLife("browse"); | ||
| cacheTag(cacheTags.public); | ||
| cacheTag(cacheTags.project(projectId)); | ||
| const all = await getProjectFeedUncached(projectId, limit); |
There was a problem hiding this comment.
Keep Atom entry selection independent of pin ordering
getProjectFeedAtomData reuses getProjectFeedUncached, which is explicitly pin-first ordered. Because the Atom path then caps results (limit = 30) and uses that list directly, old pinned entries can displace newer unpinned entries from the syndication window, causing subscribers to miss the most recent updates even though they exist in the project feed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (7)
apps/web/app/r/[org]/[repo]/feed/_components/PeriodDigestCard.tsx (1)
129-135: Consider includingissuesin the detail line for consistency.The
detailLinefunction renders PRs, commits, and reviews but omits issues. The activity chips on lines 32-35 include issues, andtemplates.tsalso includes issues in the top contributors section. This inconsistency may be intentional (issues are less relevant at the individual contributor level), but worth confirming.♻️ Optional: Add issues for parity
function detailLine(inputs: PeriodDigestSubjects["topContributors"][number]["inputs"]): string { const parts: string[] = []; if (inputs.mergedPRs > 0) parts.push(`${inputs.mergedPRs} PR${inputs.mergedPRs === 1 ? "" : "s"}`); if (inputs.commits > 0) parts.push(`${inputs.commits} commit${inputs.commits === 1 ? "" : "s"}`); if (inputs.reviews > 0) parts.push(`${inputs.reviews} review${inputs.reviews === 1 ? "" : "s"}`); + if (inputs.issues > 0) parts.push(`${inputs.issues} issue${inputs.issues === 1 ? "" : "s"}`); return parts.length > 0 ? parts.join(", ") : "—"; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/app/r/`[org]/[repo]/feed/_components/PeriodDigestCard.tsx around lines 129 - 135, The detailLine function currently formats mergedPRs, commits, and reviews but omits issues; update detailLine(inputs: PeriodDigestSubjects["topContributors"][number]["inputs"]) to include inputs.issues following the same pattern (only show if > 0, pluralize "issue"/"issues"), and append it to the parts array so the returned string matches the activity chips and templates.ts topContributors structure; keep the fallback "—" when parts is empty and maintain existing pluralization logic.apps/web/app/r/[org]/[repo]/feed.atom/route.ts (2)
50-50: Consider extracting the tag authority domain to a constant or env variable.The tag URN uses hardcoded
gitshipt.comas the authority. While RFC 4151 tag URNs are designed to be stable regardless of domain changes, for different deployments (staging, self-hosted) this hardcoded value may be inappropriate. Consider extracting to a constant or using the configured app domain.♻️ Extract tag authority
+// RFC 4151 tag authority — locked to the production domain so tag URNs +// remain stable across deployments. Do NOT change once published. +const TAG_AUTHORITY = "gitshipt.com"; +const TAG_DATE = "2026"; + - const feedTagId = `tag:gitshipt.com,2026:project-feed/${header.id}`; + const feedTagId = `tag:${TAG_AUTHORITY},${TAG_DATE}:project-feed/${header.id}`; // ... - id: `tag:gitshipt.com,2026:project-feed/entry/${e.id}`, + id: `tag:${TAG_AUTHORITY},${TAG_DATE}:project-feed/entry/${e.id}`,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/app/r/`[org]/[repo]/feed.atom/route.ts at line 50, Extract the hardcoded authority "gitshipt.com" into a single configurable identifier and use it when constructing feedTagId; for example add a TAG_AUTHORITY constant or read from env/config (e.g., APP_DOMAIN or TAG_AUTHORITY) and replace the inline string in the feedTagId construction (`const feedTagId = \`tag:${authority},2026:project-feed/${header.id}\``) so different deployments (staging/self-hosted) can override the domain without changing code.
65-69: All entries link to the same feed URL.Each entry's
urlis set tofeedUrl(the HTML feed page), not an individual entry permalink. This means clicking any entry in a reader navigates to the same page. If this is intentional (digest entries don't have dedicated pages), consider adding a fragment identifier or accepting this as expected behavior.♻️ Optional: Add entry fragment for in-page navigation
entries: entries.map((e) => ({ id: `tag:gitshipt.com,2026:project-feed/entry/${e.id}`, - url: feedUrl, + url: `${feedUrl}#entry-${e.id}`, title: titleFor(e),Then in the feed page, add
id={entry-${row.id}}to each<li>.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/app/r/`[org]/[repo]/feed.atom/route.ts around lines 65 - 69, The entries mapping currently sets every entry.url to the same feedUrl, causing all items to link to the same page; change the mapping in route.ts (entries.map callback) to produce a per-entry URL (e.g., derive entryUrl from feedUrl and e.id such as adding a fragment like `${feedUrl}#entry-${e.id}` or a dedicated permalink) and set id to use e.id (the stable row id) so each entry links uniquely; if you add a fragment, also update the feed page render (the list item rendering the feed rows) to include an element id matching that fragment (e.g., id="entry-<row.id>") so the in-page anchor works.apps/web/lib/queries/project-feed.ts (1)
115-120: Consider extracting the 90-day retention constant.The 90-day filter (
90 * 86_400_000) is a magic number. Extracting it as a named constant would improve readability and make future adjustments easier.♻️ Extract constant
+/** Entries older than this are excluded from the Atom feed. */ +const ATOM_RETENTION_DAYS = 90; +const ATOM_RETENTION_MS = ATOM_RETENTION_DAYS * 86_400_000; + export async function getProjectFeedAtomData( // ... const valid = all.filter( (r) => r.bodyMd.trim().length > 0 && - // Keep entries from the last 90 days; everything older is archive. - Date.now() - r.createdAt.getTime() < 90 * 86_400_000, + Date.now() - r.createdAt.getTime() < ATOM_RETENTION_MS, );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/lib/queries/project-feed.ts` around lines 115 - 120, Extract the magic number used in the feed filter into a named constant (e.g., const FEED_RETENTION_DAYS = 90 or const FEED_RETENTION_MS = 90 * 86_400_000) and use that constant in the all.filter predicate that sets valid; specifically replace the expression Date.now() - r.createdAt.getTime() < 90 * 86_400_000 with a comparison that uses the new constant, and update the inline comment to reference the constant for clarity (symbols to update: valid, the all.filter callback, and r.createdAt).apps/web/db/schema/project-feed-entries.ts (1)
76-76: Consider defining placeholder interfaces for future milestone kinds.The
Record<string, unknown>fallback in the union provides flexibility but loses type safety. When milestone kinds (first_contributor,score_threshold,first_payout) are implemented, consider defining their subject interfaces upfront (even as empty placeholders) to enable exhaustive type checking.♻️ Example: typed union for future extensibility
+// Placeholder interfaces for v2 milestone kinds +export interface FirstContributorSubjects { + contributorId: string; + ghUsername: string; + // ... to be defined in v2 +} + +export interface ScoreThresholdSubjects { + contributorId: string; + threshold: number; + // ... to be defined in v2 +} + +export interface FirstPayoutSubjects { + payoutId: string; + // ... to be defined in v2 +} + -export type FeedEntrySubjects = PeriodDigestSubjects | Record<string, unknown>; +export type FeedEntrySubjects = + | PeriodDigestSubjects + | FirstContributorSubjects + | ScoreThresholdSubjects + | FirstPayoutSubjects;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/db/schema/project-feed-entries.ts` at line 76, Replace the broad Record<string, unknown> fallback in the FeedEntrySubjects union with explicit placeholder interfaces so future milestone kinds are typed and exhaustively checkable; define (export) empty or minimal interfaces such as FirstContributorSubjects, ScoreThresholdSubjects, and FirstPayoutSubjects and then change FeedEntrySubjects to PeriodDigestSubjects | FirstContributorSubjects | ScoreThresholdSubjects | FirstPayoutSubjects so the union references the milestone kinds (first_contributor, score_threshold, first_payout) by name and makes it easy to expand their fields later.apps/web/app/r/[org]/[repo]/feed/page.tsx (1)
98-114: Consider adding a guard for malformedsubjectsdata.The cast on line 104 assumes the
subjectspayload matchesPeriodDigestSubjectswhenkind === "period_digest". While the comment explains this is guaranteed by the writer, if any data corruption or schema drift occurs,PeriodDigestCardwill crash when accessingsubjects.totals(which has no null checks per the context snippet at lines 25-45 ofPeriodDigestCard.tsx).A lightweight guard could prevent render failures:
🛡️ Optional defensive check
if (row.kind === "period_digest") { - // The schema's `subjects` is the union FeedEntrySubjects; - // narrowing on `kind` lets us safely treat the payload as the - // PeriodDigestSubjects shape. Both the writer and the schema - // guarantee the discriminator agrees with the payload shape; - // any future drift surfaces at insert time, not at render. const subjects = row.subjects as PeriodDigestSubjects; + // Fallback if subjects is malformed (rare, but prevents crash) + if (!subjects.totals || !subjects.topContributors) { + return ( + <li key={row.id}> + <FallbackMarkdownCard bodyMd={row.bodyMd} /> + </li> + ); + } return (🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/app/r/`[org]/[repo]/feed/page.tsx around lines 98 - 114, Add a lightweight runtime guard before casting row.subjects to PeriodDigestSubjects in the feed renderer: check that row.subjects is an object and has the expected PeriodDigest shape (e.g., non-null totals and expected arrays/keys) before passing it to PeriodDigestCard; if the guard fails, avoid the cast and render a safe fallback (skip the item or render a placeholder/error card) so PeriodDigestCard (which accesses subjects.totals) cannot crash; update the block that currently does const subjects = row.subjects as PeriodDigestSubjects and the return for kind === "period_digest" to perform this validation and fallback.apps/web/db/migrations/0019_cloudy_sunset_bain.sql (1)
51-52: Consider project-scoped partial index to optimize pinned feed queries.The query in
getProjectFeedUncached()filters byproject_id, orders bycreated_at, and evaluatespinned_untilin the ORDER BY clause. A composite partial index on(project_id, pinned_until, created_at)withWHERE pinned_until IS NOT NULLwill reduce index size and scan cost by covering the filter predicate and sort columns.Suggested migration tweak
-CREATE INDEX IF NOT EXISTS "project_feed_entries_pinned_idx" - ON "project_feed_entries" USING btree ("pinned_until"); +CREATE INDEX IF NOT EXISTS "project_feed_entries_project_pinned_idx" + ON "project_feed_entries" USING btree ("project_id", "pinned_until", "created_at") + WHERE "pinned_until" IS NOT NULL;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/db/migrations/0019_cloudy_sunset_bain.sql` around lines 51 - 52, Add a project-scoped partial composite index to speed pinned feed queries used by getProjectFeedUncached(): replace or add to the existing "project_feed_entries_pinned_idx" a composite index on (project_id, pinned_until, created_at) (with pinned_until first to serve the WHERE pinned_until IS NOT NULL predicate and created_at to cover the ORDER BY) and include the partial predicate WHERE pinned_until IS NOT NULL so the index size is reduced and scans are narrower; create the index (e.g., project_feed_entries_project_pinned_created_idx) and remove or keep the previous single-column pinned_until index as appropriate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/lib/feed/writer.ts`:
- Around line 78-87: There is a duplicated .onConflictDoNothing({ call causing a
TypeScript parse error; remove the extra duplicate so there is a single
.onConflictDoNothing({ ... }) chained before .returning({ id:
projectFeedEntries.id }); ensure the remaining call keeps the target and where
clauses referencing projectFeedEntries.projectId, projectFeedEntries.kind,
projectFeedEntries.period and sql`${projectFeedEntries.period} IS NOT NULL` so
the conflict handling logic and the following .returning remain unchanged.
In `@apps/web/lib/queries/project-feed.ts`:
- Around line 81-93: The setFeedEntryPin function updates pinnedUntil but
doesn't invalidate the project feed cache; modify the dbHttp.update call on
projectFeedEntries to return the projectId (include projectFeedEntries.projectId
in .returning), check result is non-empty, and call
revalidateProjectCaches(projectId) from "@/lib/cache" after a successful update
(before returning); ensure you handle the empty-result path (return { ok: false
} without calling revalidation).
---
Nitpick comments:
In `@apps/web/app/r/`[org]/[repo]/feed.atom/route.ts:
- Line 50: Extract the hardcoded authority "gitshipt.com" into a single
configurable identifier and use it when constructing feedTagId; for example add
a TAG_AUTHORITY constant or read from env/config (e.g., APP_DOMAIN or
TAG_AUTHORITY) and replace the inline string in the feedTagId construction
(`const feedTagId = \`tag:${authority},2026:project-feed/${header.id}\``) so
different deployments (staging/self-hosted) can override the domain without
changing code.
- Around line 65-69: The entries mapping currently sets every entry.url to the
same feedUrl, causing all items to link to the same page; change the mapping in
route.ts (entries.map callback) to produce a per-entry URL (e.g., derive
entryUrl from feedUrl and e.id such as adding a fragment like
`${feedUrl}#entry-${e.id}` or a dedicated permalink) and set id to use e.id (the
stable row id) so each entry links uniquely; if you add a fragment, also update
the feed page render (the list item rendering the feed rows) to include an
element id matching that fragment (e.g., id="entry-<row.id>") so the in-page
anchor works.
In `@apps/web/app/r/`[org]/[repo]/feed/_components/PeriodDigestCard.tsx:
- Around line 129-135: The detailLine function currently formats mergedPRs,
commits, and reviews but omits issues; update detailLine(inputs:
PeriodDigestSubjects["topContributors"][number]["inputs"]) to include
inputs.issues following the same pattern (only show if > 0, pluralize
"issue"/"issues"), and append it to the parts array so the returned string
matches the activity chips and templates.ts topContributors structure; keep the
fallback "—" when parts is empty and maintain existing pluralization logic.
In `@apps/web/app/r/`[org]/[repo]/feed/page.tsx:
- Around line 98-114: Add a lightweight runtime guard before casting
row.subjects to PeriodDigestSubjects in the feed renderer: check that
row.subjects is an object and has the expected PeriodDigest shape (e.g.,
non-null totals and expected arrays/keys) before passing it to PeriodDigestCard;
if the guard fails, avoid the cast and render a safe fallback (skip the item or
render a placeholder/error card) so PeriodDigestCard (which accesses
subjects.totals) cannot crash; update the block that currently does const
subjects = row.subjects as PeriodDigestSubjects and the return for kind ===
"period_digest" to perform this validation and fallback.
In `@apps/web/db/migrations/0019_cloudy_sunset_bain.sql`:
- Around line 51-52: Add a project-scoped partial composite index to speed
pinned feed queries used by getProjectFeedUncached(): replace or add to the
existing "project_feed_entries_pinned_idx" a composite index on (project_id,
pinned_until, created_at) (with pinned_until first to serve the WHERE
pinned_until IS NOT NULL predicate and created_at to cover the ORDER BY) and
include the partial predicate WHERE pinned_until IS NOT NULL so the index size
is reduced and scans are narrower; create the index (e.g.,
project_feed_entries_project_pinned_created_idx) and remove or keep the previous
single-column pinned_until index as appropriate.
In `@apps/web/db/schema/project-feed-entries.ts`:
- Line 76: Replace the broad Record<string, unknown> fallback in the
FeedEntrySubjects union with explicit placeholder interfaces so future milestone
kinds are typed and exhaustively checkable; define (export) empty or minimal
interfaces such as FirstContributorSubjects, ScoreThresholdSubjects, and
FirstPayoutSubjects and then change FeedEntrySubjects to PeriodDigestSubjects |
FirstContributorSubjects | ScoreThresholdSubjects | FirstPayoutSubjects so the
union references the milestone kinds (first_contributor, score_threshold,
first_payout) by name and makes it easy to expand their fields later.
In `@apps/web/lib/queries/project-feed.ts`:
- Around line 115-120: Extract the magic number used in the feed filter into a
named constant (e.g., const FEED_RETENTION_DAYS = 90 or const FEED_RETENTION_MS
= 90 * 86_400_000) and use that constant in the all.filter predicate that sets
valid; specifically replace the expression Date.now() - r.createdAt.getTime() <
90 * 86_400_000 with a comparison that uses the new constant, and update the
inline comment to reference the constant for clarity (symbols to update: valid,
the all.filter callback, and r.createdAt).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9f675440-a4ec-4264-8faf-ec2a7244b8e4
📒 Files selected for processing (18)
apps/web/app/r/[org]/[repo]/_components/ProjectShell.tsxapps/web/app/r/[org]/[repo]/feed.atom/route.tsapps/web/app/r/[org]/[repo]/feed/_components/PeriodDigestCard.tsxapps/web/app/r/[org]/[repo]/feed/page.tsxapps/web/components/sidebar/AppSidebar.tsxapps/web/db/migrations/0019_cloudy_sunset_bain.sqlapps/web/db/migrations/meta/0019_snapshot.jsonapps/web/db/migrations/meta/_journal.jsonapps/web/db/schema/index.tsapps/web/db/schema/project-feed-entries.tsapps/web/lib/feed/inputs.test.tsapps/web/lib/feed/inputs.tsapps/web/lib/feed/templates.test.tsapps/web/lib/feed/templates.tsapps/web/lib/feed/writer.tsapps/web/lib/queries/project-feed.tsapps/web/workflows/steps/snapshot-helpers.tsapps/web/workflows/takeSnapshot.ts
Adds the table that backs the per-project feed (chronological summary
cards synthesized from logbook + git history). One row per
(project, snapshot_period) for digests; reserved enum values for
milestone kinds populated in v2.
Schema:
- feed_entry_kind enum: period_digest | first_contributor |
score_threshold | first_payout
- project_feed_entries table:
- id, project_id (FK cascade), kind, period (nullable for events),
subjects (jsonb), body_md, pinned_until, created_at
- Indexes:
- (project_id, created_at) for newest-first reads
- UNIQUE (project_id, kind, period) WHERE period IS NOT NULL —
dedupes period_digest per (project, period) without blocking
multiple null-period milestone rows
- (pinned_until) for pinned-cards-first ordering
Migration matches the team's hand-edited pattern (DO $$ BEGIN ...
EXCEPTION WHEN duplicate_object pattern from 0011, IF NOT EXISTS
guards). Auto-generated drift from missing 0003-0018 snapshots
removed by hand. db:generate reports "No schema changes, nothing
to migrate" — schema and snapshot in sync.
bun run typecheck clean across all 4 packages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure function buildPeriodDigestSubjects that turns a frozen snapshot row's (snapshotId, snapshotPeriod, leaderboard) into the PeriodDigestSubjects payload stored on project_feed_entries.subjects. Logic (deterministic, no I/O): - Defensive sort of the leaderboard by rank. - Top N=5 contributors (PERIOD_DIGEST_TOP_N) carried into the card. - Aggregate totals (contributors, mergedPRs, commits, reviews, issues, netLines) summed across the *entire* leaderboard, not just the top N — the card's "X contributors active" line should reflect everyone who scored, not just the cap. - snapshotId + period preserved verbatim. Six unit tests cover the empty case, the small case, top-N slicing, totals across the full leaderboard, defensive re-sort, and field preservation. This is the synthesis of the logbook (per-snapshot leaderboard) and git history (each entry's inputs sub-object). Future milestone synthesizers (first_contributor, score_threshold, first_payout) live in the same module — v1 ships only the period_digest path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new modules under lib/feed/:
- templates.ts: pure markdown renderer for period_digest body_md.
Format is deterministic given identical inputs (stable diffs,
cache-friendly). Singular vs plural counts handled, empty signals
skipped (no "0 reviews" noise on small repos), Top contributors
section omitted when empty. Eight unit tests cover edge cases.
- writer.ts: server-only writePeriodDigestForSnapshot(snapshotId).
Reads the snapshot row + project header, calls the pure synthesizer
+ template, INSERTs ON CONFLICT DO NOTHING. Idempotent on the
(project_id, kind, period) partial UNIQUE index defined in the
schema, so cron retries / force-snapshot / race conditions never
create duplicates. Returns { inserted, entryId } so the caller
(workflow step) can audit-log the right id.
Bumps the project's cache tags on insert so a freshly-rendered feed
page picks up the new card without waiting for the snapshot's own
revalidation (which currently runs after the digest write in the
workflow ordering).
13 tests across the feed module pass; full typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
snapshotWriteFeedDigestStep added to snapshot-helpers.ts: thin wrapper around lib/feed/writer that imports lazily inside the "use step" boundary. Errors are captured + swallowed via captureException — feed write failures must not gate the snapshot's success path. Wired into takeProjectSnapshot.ts between executeFeeShareUpdateStep and snapshotRevalidateProjectCachesStep so the first reader after the snapshot sees the new card. Idempotency lives in the writer's ON CONFLICT DO NOTHING — calling this step on the same snapshot twice is a noop. Per design, write order is: freeze → fee-share update → write feed digest → revalidate caches Future v2 steps (milestone detection in computeLeaderboard) follow the same pattern: pure synthesizer + writer + thin step wrapper + silent-on-error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the public feed surface — chronological cards rendered from
project_feed_entries. Pinned entries float to top via the partial
unique index + ORDER BY CASE pattern in the query.
New surface:
- /r/[org]/[repo]/feed page (server component) with reverse-chrono
list. PeriodDigestCard renders the structured subjects payload —
rank medals, avatars (GitHub stable redirect), score + input
breakdown per top contributor. Empty state explains the cadence
("first card lands at next snapshot").
- Sidebar nav: new "Feed" item (Newspaper icon) under the public
Project group, between Leaderboard and Payouts.
- ProjectSidebarActive type extended with "feed" so the layout's
active-state highlight works without a workaround.
Reads via lib/queries/project-feed.ts (cacheLife "browse"). The
ORDER BY pins entries with pinnedUntil > now() to top, then sorts
the rest by createdAt DESC.
Atom feed link in the page header points to /r/{slug}/feed.atom
(route lands in next commit). The page's metadata.alternates also
declares the application/atom+xml feed link so RSS readers
auto-discover.
Future kinds (first_contributor, score_threshold, first_payout)
fall through to FallbackMarkdownCard for now — body_md renders
in a <pre> block until each kind gets its own React card. v1 only
emits period_digest from the writer; the schema reserves the rest.
bun run typecheck clean across all 4 packages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standard Atom 1.0 feed serving the same project_feed_entries the React
page renders. body_md (the templated narrative from lib/feed/templates)
is the <content> element; readers that render markdown get a clean
digest, plain-text readers get readable structure.
Atom (over RSS) chosen for:
- Required `id` and unambiguous timestamps that align with our row
schema 1:1 (no ad-hoc GUIDs).
- Wide reader support (Reeder / NetNewsWire / Inoreader / RSS-aware
bots like the GitHub digest crawlers).
Format details:
- <id> = canonical feed URL
- <link rel="self"> = atom URL; <link rel="alternate"> = HTML feed page
- <updated> = newest entry's createdAt (or project createdAt as floor)
- Each <entry> has id "{feedUrl}#{entry.id}", title derived from kind
+ period, contentType=text (markdown fallback)
- Cache-Control matches cacheLife("browse"): 60s public + 120s s-maxage +
stale-while-revalidate 600
Cap of 30 entries pulled from getProjectFeedAtomData (same module as
the page query). Older entries are still on the HTML feed; atom shows
the most recent month or two.
bun run typecheck clean × 4 packages; bun run vitest run 176/176 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two HIGH-priority bugs and two MEDIUM-priority polish items from
gemini-code-assist's review:
HIGH — writer.ts onConflictDoNothing missing where clause for
partial unique index. Postgres requires the index predicate to be
repeated in the ON CONFLICT clause when the conflict target is a
partial index — without it the query throws "there is no unique or
exclusion constraint matching the ON CONFLICT specification" at
runtime, meaning the very first snapshot would crash. Added
`where: sql\`${period} IS NOT NULL\`` to match the partial index
defined in schema/project-feed-entries.ts.
HIGH — feed.atom hardcoded "https://gitshipt.com" broke local dev
and any non-production environment. Now reads NEXT_PUBLIC_APP_URL
via clientEnv() (already used elsewhere in the codebase, defaults
to http://localhost:3000 per env.ts schema).
MEDIUM — atom entry IDs were `{selfUrl}#{rowId}`, which is unstable
across domain renames or path changes (atom readers would treat
entries as new again). Switched to RFC 4151 tag URN
(`tag:gitshipt.com,2026:project-feed/entry/{rowId}`) — globally
unique, permanent across path changes. Same pattern for the feed
itself (`tag:gitshipt.com,2026:project-feed/{projectId}`).
MEDIUM — feed/page.tsx used `subjects as never` to silence the
union narrowing on FeedEntrySubjects. Replaced with explicit
`PeriodDigestSubjects` cast inside the kind === "period_digest"
branch + a comment noting the discriminator/payload contract is
maintained at insert time.
bun run typecheck clean across all 4 packages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2088ff9 to
43f970c
Compare
Summary
Adds a per-project chronological feed at
/r/[org]/[repo]/feedplus an Atom 1.0 syndication feed at/r/[org]/[repo]/feed.atom. Cards are synthesized from each frozen snapshot's leaderboard (logbook view) and the per-contributor input breakdown (git history view) — no new event ingestion pipeline.For non-developers, this is the surface that says "what's happening on this project" without making them parse GitHub's commit graph.
What ships
Phase 1 — period_digest only (v1):
takeProjectSnapshotafter the freeze step. Idempotent on(project_id, kind, period)UNIQUE — cron retries / force-snapshot / races never duplicate.Phase 2 — milestone kinds (deferred v2): schema reserves
first_contributor,score_threshold,first_payoutenum values. Writers + detection logic incomputeLeaderboardfollow the same pattern as the digest writer; not wired in v1.Architecture
lib/feed/inputs.ts— purebuildPeriodDigestSubjects(snapshotId, period, leaderboard). No I/O. 6 tests.lib/feed/templates.ts— pure markdown renderer forbody_md. 8 tests.lib/feed/writer.ts— server-only DB writer withON CONFLICT DO NOTHING.workflows/steps/snapshot-helpers.ts—snapshotWriteFeedDigestStepwraps the writer in a"use step"boundary. Errors captured viacaptureException, never re-thrown.workflows/takeSnapshot.ts— calls the new step between fee-share update and cache revalidation.lib/queries/project-feed.ts—getProjectFeed,getProjectFeedAtomData,setFeedEntryPin.app/r/[org]/[repo]/feed/page.tsx+_components/PeriodDigestCard.tsx— public UI.app/r/[org]/[repo]/feed.atom/route.ts— Atom 1.0 syndication.components/sidebar/AppSidebar.tsx— new "Feed" nav item under Project group.Schema
New table
project_feed_entries:id, project_id, kind, period, subjects (jsonb), body_md, pinned_until, created_at(project_id, kind, period) WHERE period IS NOT NULLfor digest dedup(project_id, created_at)for newest-first reads and(pinned_until)for pinned orderingMigration
0019_cloudy_sunset_bain.sqlhand-trimmed to match the team'sDO $$ EXCEPTION+IF NOT EXISTSpattern.db:generateconfirms snapshot in sync.Out-of-scope (v1)
Test plan
bun run typecheckclean across@repo/lib,@repo/shared,@repo/ui,apps/webbun run vitest run— 40 files, 176 tests, all green (14 new tests for inputs + templates)bun run db:generateconfirms schema/snapshot in sync (no spurious migrations)period_digestrow appears and renders on/r/{slug}/feed/r/{slug}/feed.atomagainst an Atom validatorsetFeedEntryPin; verify it floats to top of/feed🤖 Generated with Claude Code
Summary by CodeRabbit