Skip to content

Project feed v1: synthesized digests from logbook + git history#12

Merged
SYMBaiEX merged 10 commits into
SYMBaiEX:mainfrom
Dexploarer:feat/project-feed-v1
Apr 29, 2026
Merged

Project feed v1: synthesized digests from logbook + git history#12
SYMBaiEX merged 10 commits into
SYMBaiEX:mainfrom
Dexploarer:feat/project-feed-v1

Conversation

@Dexploarer

@Dexploarer Dexploarer commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a per-project chronological feed at /r/[org]/[repo]/feed plus 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):

  • One card per snapshot period: top contributors with rank/score/inputs, aggregate totals (PRs, commits, reviews, issues, net lines), pinning support.
  • Auto-generated by takeProjectSnapshot after the freeze step. Idempotent on (project_id, kind, period) UNIQUE — cron retries / force-snapshot / races never duplicate.
  • Failures inside the writer are captured + swallowed — feed never gates the snapshot's success path.

Phase 2 — milestone kinds (deferred v2): schema reserves first_contributor, score_threshold, first_payout enum values. Writers + detection logic in computeLeaderboard follow the same pattern as the digest writer; not wired in v1.

Architecture

   git history   ─┐
                  ├─→ shared synthesizer  ─→  logbook (now-state)
   indexer/snap  ─┘                       ─→  feed (chronology)
  • lib/feed/inputs.ts — pure buildPeriodDigestSubjects(snapshotId, period, leaderboard). No I/O. 6 tests.
  • lib/feed/templates.ts — pure markdown renderer for body_md. 8 tests.
  • lib/feed/writer.ts — server-only DB writer with ON CONFLICT DO NOTHING.
  • workflows/steps/snapshot-helpers.tssnapshotWriteFeedDigestStep wraps the writer in a "use step" boundary. Errors captured via captureException, never re-thrown.
  • workflows/takeSnapshot.ts — calls the new step between fee-share update and cache revalidation.
  • lib/queries/project-feed.tsgetProjectFeed, 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
  • Partial UNIQUE on (project_id, kind, period) WHERE period IS NOT NULL for digest dedup
  • Indexes on (project_id, created_at) for newest-first reads and (pinned_until) for pinned ordering

Migration 0019_cloudy_sunset_bain.sql hand-trimmed to match the team's DO $$ EXCEPTION + IF NOT EXISTS pattern. db:generate confirms snapshot in sync.

Out-of-scope (v1)

  • Milestone detection (first_contributor, score_threshold, first_payout) — schema reserves the enum values; writers ship in v2.
  • Platform-wide aggregated feed.
  • LLM enrichment of card prose.
  • Per-PR cards (the digest cadence IS the product — quieter than Twitter, denser per card).
  • Real-time push (snapshot cadence is daily; that's the right beat).

Test plan

  • bun run typecheck clean across @repo/lib, @repo/shared, @repo/ui, apps/web
  • bun run vitest run — 40 files, 176 tests, all green (14 new tests for inputs + templates)
  • bun run db:generate confirms schema/snapshot in sync (no spurious migrations)
  • Run a snapshot manually on a seeded project; verify period_digest row appears and renders on /r/{slug}/feed
  • Validate /r/{slug}/feed.atom against an Atom validator
  • Pin a row via setFeedEntryPin; verify it floats to top of /feed

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Feed page displaying project period activity summaries with ranked top contributors, contribution metrics (merged PRs, commits, reviews, issues, line deltas), and activity indicators
    • Feed entries automatically generated and updated from project snapshots
    • Added Feed navigation item to the project sidebar
    • Introduced Atom feed format support enabling external consumption

@vercel

vercel Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

@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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread apps/web/lib/feed/writer.ts

const { header } = data;
const slug = `${header.ghOwner}/${header.ghRepo}`;
const siteUrl = `https://gitshipt.com/r/${slug}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Hardcoding the production domain https://gitshipt.com will break links and functionality in local development, staging, or other environments. It is recommended to use an environment variable (e.g., process.env.NEXT_PUBLIC_APP_URL) to resolve the base URL dynamically.

subtitle: `Daily digests + milestones for ${slug}, synthesized from the leaderboard and indexed git history.`,
updated,
entries: entries.map((e) => ({
id: `${selfUrl}#${e.id}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the feed URL as a prefix for entry IDs (${selfUrl}#${e.id}) makes the IDs unstable if the domain or feed path changes. Atom entry IDs should be globally unique and permanent. Consider using a more stable format like a tag: URI (e.g., tag:gitshipt.com,2024:feed-entry-${e.id}).

return (
<li key={row.id}>
<PeriodDigestCard
subjects={row.subjects as never}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using as never bypasses type safety and is generally discouraged. Since the row.kind === "period_digest" check already narrows the logic, you should cast the subjects to the specific expected type (e.g., PeriodDigestSubjects) to maintain better type coverage and readability.

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@SYMBaiEX has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 48 minutes and 40 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f3751875-1122-47cd-b4e4-86b34c8f6dcf

📥 Commits

Reviewing files that changed from the base of the PR and between 2088ff9 and be2e35d.

📒 Files selected for processing (19)
  • apps/web/app/auth/signin/signin-form.tsx
  • apps/web/app/r/[org]/[repo]/_components/ProjectShell.tsx
  • apps/web/app/r/[org]/[repo]/feed.atom/route.ts
  • apps/web/app/r/[org]/[repo]/feed/_components/PeriodDigestCard.tsx
  • apps/web/app/r/[org]/[repo]/feed/page.tsx
  • apps/web/components/sidebar/AppSidebar.tsx
  • apps/web/db/migrations/0019_cloudy_sunset_bain.sql
  • apps/web/db/migrations/meta/0019_snapshot.json
  • apps/web/db/migrations/meta/_journal.json
  • apps/web/db/schema/index.ts
  • apps/web/db/schema/project-feed-entries.ts
  • apps/web/lib/feed/inputs.test.ts
  • apps/web/lib/feed/inputs.ts
  • apps/web/lib/feed/templates.test.ts
  • apps/web/lib/feed/templates.ts
  • apps/web/lib/feed/writer.ts
  • apps/web/lib/queries/project-feed.ts
  • apps/web/workflows/steps/snapshot-helpers.ts
  • apps/web/workflows/takeSnapshot.ts
📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
Type and Sidebar Updates
apps/web/app/r/[org]/[repo]/_components/ProjectShell.tsx, apps/web/components/sidebar/AppSidebar.tsx
Updated ProjectSidebarActive type to include "feed" state; added "Feed" navigation item with Newspaper icon to project sidebar.
Feed Page and Components
apps/web/app/r/[org]/[repo]/feed/page.tsx, apps/web/app/r/[org]/[repo]/feed/_components/PeriodDigestCard.tsx, apps/web/app/r/[org]/[repo]/feed.atom/route.ts
Implemented project feed page with metadata generation, Atom 1.0 syndication endpoint, and PeriodDigestCard component displaying contributor stats, activity metrics, and ranked top contributors with pinning support.
Database Schema and Migration
apps/web/db/migrations/0019_cloudy_sunset_bain.sql, apps/web/db/migrations/meta/_journal.json, apps/web/db/schema/project-feed-entries.ts, apps/web/db/schema/index.ts
Added PostgreSQL feed_entry_kind enum and project_feed_entries table with indexes for chronological and pinned-first ordering; defined Drizzle ORM schema with typed JSONB support for PeriodDigestSubjects.
Feed Generation Logic
apps/web/lib/feed/inputs.ts, apps/web/lib/feed/templates.ts, apps/web/lib/feed/writer.ts
Implemented buildPeriodDigestSubjects to transform leaderboard snapshots into feed payloads, renderPeriodDigestMarkdown to generate deterministic markdown bodies, and writePeriodDigestForSnapshot to idempotently create/upsert feed entries with deduplication.
Feed Queries and Workflow
apps/web/lib/queries/project-feed.ts, apps/web/workflows/steps/snapshot-helpers.ts, apps/web/workflows/takeSnapshot.ts
Added server-only feed query functions for chronological and Atom data retrieval, implemented snapshotWriteFeedDigestStep workflow task, and integrated feed entry creation into snapshot finalization.
Test Coverage
apps/web/lib/feed/inputs.test.ts, apps/web/lib/feed/templates.test.ts
Added Vitest suites validating buildPeriodDigestSubjects payload construction and renderPeriodDigestMarkdown markdown generation including pluralization, score formatting, and edge cases.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Poem

🐰 A feed blooms in the garden bright,
With digests, contributors, and stats in sight,
The snapshot seeds are sown with care,
Atom feeds flutter through the air,
Top contributors take their bow—
Let the project story grow, and how! 🌱✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly captures the main feature: a project feed synthesizing activity digests from snapshot leaderboards and Git history, which is the core objective of this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 48 minutes and 40 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Dexploarer added a commit to Dexploarer/GitShipt that referenced this pull request Apr 29, 2026
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>
@Dexploarer Dexploarer marked this pull request as ready for review April 29, 2026 17:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/lib/queries/project-feed.ts Outdated
Comment on lines +87 to +92
const result = await dbHttp
.update(projectFeedEntries)
.set({ pinnedUntil })
.where(eq(projectFeedEntries.id, entryId))
.returning({ id: projectFeedEntries.id });
return { ok: result.length > 0 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread apps/web/lib/queries/project-feed.ts Outdated
cacheLife("browse");
cacheTag(cacheTags.public);
cacheTag(cacheTags.project(projectId));
const all = await getProjectFeedUncached(projectId, limit);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (7)
apps/web/app/r/[org]/[repo]/feed/_components/PeriodDigestCard.tsx (1)

129-135: Consider including issues in the detail line for consistency.

The detailLine function renders PRs, commits, and reviews but omits issues. The activity chips on lines 32-35 include issues, and templates.ts also 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.com as 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 url is set to feedUrl (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 malformed subjects data.

The cast on line 104 assumes the subjects payload matches PeriodDigestSubjects when kind === "period_digest". While the comment explains this is guaranteed by the writer, if any data corruption or schema drift occurs, PeriodDigestCard will crash when accessing subjects.totals (which has no null checks per the context snippet at lines 25-45 of PeriodDigestCard.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 by project_id, orders by created_at, and evaluates pinned_until in the ORDER BY clause. A composite partial index on (project_id, pinned_until, created_at) with WHERE pinned_until IS NOT NULL will 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

📥 Commits

Reviewing files that changed from the base of the PR and between 262ccc6 and 2088ff9.

📒 Files selected for processing (18)
  • apps/web/app/r/[org]/[repo]/_components/ProjectShell.tsx
  • apps/web/app/r/[org]/[repo]/feed.atom/route.ts
  • apps/web/app/r/[org]/[repo]/feed/_components/PeriodDigestCard.tsx
  • apps/web/app/r/[org]/[repo]/feed/page.tsx
  • apps/web/components/sidebar/AppSidebar.tsx
  • apps/web/db/migrations/0019_cloudy_sunset_bain.sql
  • apps/web/db/migrations/meta/0019_snapshot.json
  • apps/web/db/migrations/meta/_journal.json
  • apps/web/db/schema/index.ts
  • apps/web/db/schema/project-feed-entries.ts
  • apps/web/lib/feed/inputs.test.ts
  • apps/web/lib/feed/inputs.ts
  • apps/web/lib/feed/templates.test.ts
  • apps/web/lib/feed/templates.ts
  • apps/web/lib/feed/writer.ts
  • apps/web/lib/queries/project-feed.ts
  • apps/web/workflows/steps/snapshot-helpers.ts
  • apps/web/workflows/takeSnapshot.ts

Comment thread apps/web/lib/feed/writer.ts Outdated
Comment thread apps/web/lib/queries/project-feed.ts
Dexploarer and others added 7 commits April 29, 2026 14:24
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>
@Dexploarer Dexploarer force-pushed the feat/project-feed-v1 branch from 2088ff9 to 43f970c Compare April 29, 2026 18:28
@SYMBaiEX SYMBaiEX merged commit e3a3228 into SYMBaiEX:main Apr 29, 2026
5 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants