Commitb7b5f75
fix(activity): wire activity_feed writes into push/PR/merge paths
fix(activity): wire activity_feed writes into push/PR/merge paths
Confirmed via scripts/agent-journey.ts (2026-07-15): activity_feed was
only ever written by fork/use-template/claude-connect/reviewer-suggest.
Git push, PR create/comment/merge on both the web routes and the MCP
write tools, and the shared auto-merge performMerge() path, never
wrote a row — so the dashboard "recent activity" feed, the repo page's
"recent push" live indicator (src/routes/web.tsx getRecentPush), and
the AI-merged-PRs-this-week stat all silently under-reported or showed
nothing for real usage, even though the demo seed faked matching rows
via demo-activity-seed.ts (which is why the demo looked alive).
Adds `logActivity()` to src/lib/notify.ts — a fire-and-forget helper
mirroring audit()'s fail-open contract (never throws, swallows DB
errors) — and wires it at every real entry point:
- src/hooks/post-receive.ts: action="push", targetId=new SHA (the
existing contract getRecentPush/push-watch.tsx already query by)
- src/routes/pulls.tsx: PR create (pr_open), approved PR comment
(comment), web merge handler (pr_merge)
- src/lib/pr-merge.ts performMerge(): pr_merge, covers auto-merge.ts
/autopilot.ts/ai-loop.ts in one place since they all route through
this shared mechanics function
- src/lib/mcp-tools.ts: gluecron_create_pr, gluecron_comment_pr,
gluecron_merge_pr — the exact surface external platforms use
Action vocabulary matches the pre-existing contract in schema.ts's
activityFeed comment and dashboard.tsx's formatAction map (push,
pr_open, pr_merge, comment). No schema change — the table already
existed and was already read from in 6+ places; it just wasn't being
written to for the paths that matter most.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>6 files changed+212−3b7b5f750af39c726512998a7828b501a1381f05f
6 changed files+212−3
Addedsrc/__tests__/notify-activity-feed.test.ts+94−0View fileUnifiedSplit
@@ -0,0 +1,94 @@
1/**
2 * logActivity() — the activity_feed writer wired into push/PR/merge paths
3 * on 2026-07-15 (see src/hooks/post-receive.ts, src/routes/pulls.tsx,
4 * src/lib/pr-merge.ts, src/lib/mcp-tools.ts). Before this, activity_feed
5 * was only ever written by fork/use-template/claude-connect/reviewer-suggest,
6 * so dashboards and the repo-page "recent push" indicator were blind to
7 * anything done through git push, the web PR flow, or the MCP write tools.
8 *
9 * Uses the same mock.module("../db", ...) fake-db pattern as push.test.ts —
10 * `logActivity` must never throw (mirrors `audit()`'s contract), and it
11 * must write the exact shape callers rely on (action, targetType, targetId,
12 * metadata as a JSON string).
13 */
14
15import { describe, it, expect, beforeEach, afterAll, mock } from "bun:test";
16
17const _real_db = await import("../db");
18
19const _inserted: Array<{ table: string; values: any }> = [];
20let _shouldThrow = false;
21
22const tableName = (t: any): string => {
23 if (!t || typeof t !== "object") return "?";
24 if ("action" in t && "repositoryId" in t && "targetType" in t) return "activity_feed";
25 return "?";
26};
27
28const _fakeDb = {
29 db: {
30 insert: (t: any) => ({
31 values: (vals: any) => {
32 if (_shouldThrow) return Promise.reject(new Error("db unavailable"));
33 _inserted.push({ table: tableName(t), values: vals });
34 return Promise.resolve();
35 },
36 }),
37 },
38 getDb: () => _fakeDb.db,
39};
40
41mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
42
43const { logActivity } = await import("../lib/notify");
44
45afterAll(() => {
46 mock.module("../db", () => _real_db);
47});
48
49beforeEach(() => {
50 _inserted.length = 0;
51 _shouldThrow = false;
52});
53
54describe("logActivity", () => {
55 it("writes an activity_feed row with the given action/target/metadata", async () => {
56 await logActivity({
57 repositoryId: "repo-1",
58 userId: "user-1",
59 action: "push",
60 targetType: "commit",
61 targetId: "a".repeat(40),
62 metadata: { branch: "main" },
63 });
64
65 expect(_inserted).toHaveLength(1);
66 expect(_inserted[0]!.table).toBe("activity_feed");
67 expect(_inserted[0]!.values).toMatchObject({
68 repositoryId: "repo-1",
69 userId: "user-1",
70 action: "push",
71 targetType: "commit",
72 targetId: "a".repeat(40),
73 });
74 expect(_inserted[0]!.values.metadata).toBe(JSON.stringify({ branch: "main" }));
75 });
76
77 it("defaults userId to null when omitted", async () => {
78 await logActivity({ repositoryId: "repo-1", action: "pr_open" });
79 expect(_inserted[0]!.values.userId).toBeNull();
80 });
81
82 it("never throws when the DB insert fails — same fail-open contract as audit()", async () => {
83 _shouldThrow = true;
84 await expect(
85 logActivity({ repositoryId: "repo-1", action: "pr_merge" })
86 ).resolves.toBeUndefined();
87 expect(_inserted).toHaveLength(0);
88 });
89
90 it("sets metadata to null when not provided", async () => {
91 await logActivity({ repositoryId: "repo-1", action: "comment", targetType: "pull_request", targetId: "42" });
92 expect(_inserted[0]!.values.metadata).toBeNull();
93 });
94});
Modifiedsrc/hooks/post-receive.ts+16−1View fileUnifiedSplit
@@ -39,7 +39,7 @@ import {
3939import { deployToTarget } from "../lib/server-targets";
4040import { fireCloudDeploys } from "../lib/cloud-deploy";
4141import { ensureRepoOnboarding } from "../lib/repo-onboarding";
42import { audit } from "../lib/notify";
42import { audit, logActivity } from "../lib/notify";
4343
4444// ---------------------------------------------------------------------------
4545// Test isolation layer — mount /__tests__/ paths read-only during repair loops
@@ -139,6 +139,21 @@ export async function onPostReceive(
139139 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
140140 const branchName = ref.refName.replace("refs/heads/", "");
141141
142 // 0. Activity feed — record the push so the repo page's "recent push"
143 // indicator and dashboard/pulse feeds pick it up. targetId=newSha is a
144 // load-bearing contract: src/routes/web.tsx getRecentPush and
145 // src/routes/push-watch.tsx both query action="push" by SHA.
146 if (repoId) {
147 void logActivity({
148 repositoryId: repoId,
149 userId: pusherUserId || null,
150 action: "push",
151 targetType: "commit",
152 targetId: ref.newSha,
153 metadata: { branch: branchName, oldSha: ref.oldSha },
154 });
155 }
156
142157 // 1. Auto-repair — gated on per-repo autoRepairMode setting.
143158 if (!automationSettings || isAutomationOn(automationSettings.autoRepairMode)) {
144159 try {
Modifiedsrc/lib/mcp-tools.ts+28−1View fileUnifiedSplit
@@ -32,7 +32,7 @@ import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp";
3232import type { McpContext } from "./mcp";
3333import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
3434import type { RepoAccessLevel } from "../middleware/repo-access";
35import { notify, audit } from "./notify";
35import { notify, audit, logActivity } from "./notify";
3636import { runAllGateChecks } from "./gate";
3737import {
3838 matchProtection,
@@ -869,6 +869,15 @@ const createPr: McpToolHandler = {
869869 metadata: { source: "mcp", number: pr.number, baseBranch, headBranch },
870870 });
871871
872 void logActivity({
873 repositoryId: gate.repoId,
874 userId: gate.userId,
875 action: "pr_open",
876 targetType: "pull_request",
877 targetId: String(pr.number),
878 metadata: { source: "mcp", baseBranch, headBranch },
879 });
880
872881 if (gate.ownerId !== gate.userId) {
873882 notify(gate.ownerId, {
874883 kind: "pr_opened",
@@ -1107,6 +1116,15 @@ const commentPr: McpToolHandler = {
11071116 metadata: { source: "mcp", number },
11081117 });
11091118
1119 void logActivity({
1120 repositoryId: gate.repoId,
1121 userId: gate.userId,
1122 action: "comment",
1123 targetType: "pull_request",
1124 targetId: String(number),
1125 metadata: { source: "mcp", commentId: inserted.id },
1126 });
1127
11101128 return { commentId: inserted.id };
11111129 },
11121130};
@@ -1337,6 +1355,15 @@ const mergePr: McpToolHandler = {
13371355 metadata: { source: "mcp", number, sha: headSha },
13381356 });
13391357
1358 void logActivity({
1359 repositoryId: gate.repoId,
1360 userId: gate.userId,
1361 action: "pr_merge",
1362 targetType: "pull_request",
1363 targetId: String(number),
1364 metadata: { source: "mcp", sha: headSha },
1365 });
1366
13401367 // Resolved post-merge SHA (best effort).
13411368 let mergedSha: string | null = null;
13421369 try {
Modifiedsrc/lib/notify.ts+36−1View fileUnifiedSplit
@@ -10,7 +10,7 @@
1010
1111import { inArray, eq, and, desc, sql } from "drizzle-orm";
1212import { db } from "../db";
13import { notifications as notificationsMain, auditLog, users } from "../db/schema";
13import { notifications as notificationsMain, auditLog, activityFeed, users } from "../db/schema";
1414import { notifications as notificationsExt } from "../db/schema-extensions";
1515import { sendEmail, absoluteUrl } from "./email";
1616
@@ -300,6 +300,41 @@ export async function audit(opts: {
300300 }
301301}
302302
303/**
304 * Log a repo-scoped activity_feed row — the per-repo timeline rendered on
305 * the dashboard, repo page, and pulse view (distinct from `audit()`'s
306 * compliance-oriented audit_log). Canonical `action` vocabulary (see
307 * `formatAction` in src/routes/dashboard.tsx and the activityFeed table
308 * comment in schema.ts): push, pr_open, pr_merge, comment, issue_open,
309 * issue_close, star, fork. `targetId` conventions some routes already
310 * depend on: action="push" reads targetId as the commit SHA
311 * (src/routes/web.tsx getRecentPush, src/routes/push-watch.tsx).
312 *
313 * Fire-and-forget safe: swallows errors, same contract as `audit()`.
314 */
315export async function logActivity(opts: {
316 repositoryId: string;
317 userId?: string | null;
318 action: string;
319 targetType?: string;
320 targetId?: string;
321 metadata?: Record<string, unknown>;
322}): Promise<void> {
323 try {
324 await db.insert(activityFeed).values({
325 repositoryId: opts.repositoryId,
326 userId: opts.userId ?? null,
327 action: opts.action,
328 targetType: opts.targetType,
329 targetId: opts.targetId,
330 metadata: opts.metadata ? JSON.stringify(opts.metadata) : null,
331 });
332 } catch (err) {
333 // Activity feed must never break the primary flow
334 console.error("[activity] failed:", err);
335 }
336}
337
303338/** Test-only hook so unit tests can assert the kind→pref mapping. */
304339export const __internal = {
305340 EMAIL_ELIGIBLE,
Modifiedsrc/lib/pr-merge.ts+10−0View fileUnifiedSplit
@@ -38,6 +38,7 @@ import { getRepoPath } from "../git/repository";
3838import { mergeWithAutoResolve } from "./merge-resolver";
3939import { isAiReviewEnabled } from "./ai-review";
4040import { extractClosingRefsMulti } from "./close-keywords";
41import { logActivity } from "./notify";
4142
4243export interface PerformMergeArgs {
4344 /** Full PR row — we need title/body/baseBranch/headBranch/repositoryId. */
@@ -256,6 +257,15 @@ export async function performMerge(
256257 actorUserId: args.actorUserId,
257258 });
258259
260 void logActivity({
261 repositoryId: args.pr.repositoryId,
262 userId: args.actorUserId,
263 action: "pr_merge",
264 targetType: "pull_request",
265 targetId: String(args.pr.number),
266 metadata: { baseBranch: args.pr.baseBranch, headBranch: args.pr.headBranch },
267 });
268
259269 return {
260270 ok: true,
261271 closedIssueNumbers,
Modifiedsrc/routes/pulls.tsx+28−0View fileUnifiedSplit
@@ -67,6 +67,7 @@ import {
6767 AI_TESTS_MARKER,
6868} from "../lib/ai-test-generator";
6969import { triggerPrTriage } from "../lib/pr-triage";
70import { logActivity } from "../lib/notify";
7071import { generatePrSummary } from "../lib/ai-generators";
7172import { isAiAvailable } from "../lib/ai-client";
7273import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
@@ -3839,6 +3840,15 @@ pulls.post(
38393840 })
38403841 .returning();
38413842
3843 void logActivity({
3844 repositoryId: resolved.repo.id,
3845 userId: user.id,
3846 action: "pr_open",
3847 targetType: "pull_request",
3848 targetId: String(pr.number),
3849 metadata: { baseBranch, headBranch, isDraft },
3850 });
3851
38423852 // CODEOWNERS — auto-request reviewers based on changed files.
38433853 // Fire-and-forget; errors never block PR creation.
38443854 (async () => {
@@ -5641,6 +5651,15 @@ pulls.post(
56415651
56425652 // Live update: only when the comment is actually visible.
56435653 if (inserted && decision.status === "approved") {
5654 void logActivity({
5655 repositoryId: resolved.repo.id,
5656 userId: user.id,
5657 action: "comment",
5658 targetType: "pull_request",
5659 targetId: String(prNum),
5660 metadata: { commentId: inserted.id },
5661 });
5662
56445663 try {
56455664 const { publish } = await import("../lib/sse");
56465665 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
@@ -6418,6 +6437,15 @@ pulls.post(
64186437 })
64196438 .where(eq(pullRequests.id, pr.id));
64206439
6440 void logActivity({
6441 repositoryId: resolved.repo.id,
6442 userId: user.id,
6443 action: "pr_merge",
6444 targetType: "pull_request",
6445 targetId: String(pr.number),
6446 metadata: { baseBranch: pr.baseBranch, headBranch: pr.headBranch, mergeStrategy },
6447 });
6448
64216449 // Chat notifier — fan out merge event to Slack/Discord/Teams.
64226450 import("../lib/chat-notifier")
64236451 .then((m) =>
64246452