Commitb23152eunknown_key
Merge pull request #106 from ccantynz-alt/feat/smart-digest-context
Merge pull request #106 from ccantynz-alt/feat/smart-digest-context feat: smart morning digest + review context restore — AI-curated dail…
5 files changed+8−242b23152ed07ac82da9d990a5d466499877ebebefd
5 changed files+8−242
Addeddrizzle/0088_pr_visits.sql+6−0View fileUnifiedSplit
@@ -0,0 +1,6 @@
1CREATE TABLE IF NOT EXISTS pr_visits (
2 pr_id UUID NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
3 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4 visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
5 PRIMARY KEY (pr_id, user_id)
6);
Addeddrizzle/0089_smart_digest_pref.sql+2−0View fileUnifiedSplit
@@ -0,0 +1,2 @@
1ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_smart_digest BOOLEAN NOT NULL DEFAULT true;
2ALTER TABLE users ADD COLUMN IF NOT EXISTS last_smart_digest_sent_at TIMESTAMPTZ;
Modifiedsrc/db/schema.ts+0−220View fileUnifiedSplit
@@ -4302,226 +4302,6 @@ export const prPreviews = pgTable(
43024302export type PrPreview = typeof prPreviews.$inferSelect;
43034303export type NewPrPreview = typeof prPreviews.$inferInsert;
43044304
4305// ----------------------------------------------------------------------------
4306// Migration 0092 — Enterprise SSO (SAML 2.0 + OIDC per-org) + SCIM
4307// ----------------------------------------------------------------------------
4308
4309/**
4310 * Per-org SSO configuration. One row per org. Supports both SAML 2.0 and OIDC.
4311 * Distinct from the site-wide `sso_config` table (Block I10 / OIDC-only).
4312 */
4313export const orgSsoConfigs = pgTable("org_sso_configs", {
4314 id: uuid("id").primaryKey().defaultRandom(),
4315 orgId: uuid("org_id")
4316 .notNull()
4317 .unique()
4318 .references(() => organizations.id, { onDelete: "cascade" }),
4319 provider: text("provider").notNull().default("saml"), // 'saml' | 'oidc'
4320 // SAML fields
4321 idpEntityId: text("idp_entity_id"),
4322 idpSsoUrl: text("idp_sso_url"),
4323 idpCertificate: text("idp_certificate"), // PEM cert from IdP
4324 spEntityId: text("sp_entity_id"), // our SP entity ID
4325 // OIDC fields
4326 oidcClientId: text("oidc_client_id"),
4327 oidcClientSecret: text("oidc_client_secret"),
4328 oidcDiscoveryUrl: text("oidc_discovery_url"),
4329 // Common
4330 domainHint: text("domain_hint"), // e.g. "acme.com" — auto-routes users from this domain
4331 attributeMapping: jsonb("attribute_mapping")
4332 .$type<Record<string, string>>()
4333 .default({ email: "email", name: "name", username: "preferred_username" }),
4334 enabled: boolean("enabled").notNull().default(false),
4335 createdAt: timestamp("created_at").defaultNow(),
4336 updatedAt: timestamp("updated_at").defaultNow(),
4337});
4338
4339/**
4340 * SCIM provisioning tokens. Hashed SHA-256 bearer tokens issued per-org.
4341 * Identity providers (Okta, Azure AD) use these to provision/deprovision users.
4342 */
4343export const scimTokens = pgTable("scim_tokens", {
4344 id: uuid("id").primaryKey().defaultRandom(),
4345 orgId: uuid("org_id")
4346 .notNull()
4347 .references(() => organizations.id, { onDelete: "cascade" }),
4348 tokenHash: text("token_hash").notNull().unique(), // SHA-256 of the token
4349 createdBy: uuid("created_by")
4350 .notNull()
4351 .references(() => users.id),
4352 createdAt: timestamp("created_at").defaultNow(),
4353 lastUsedAt: timestamp("last_used_at"),
4354});
4355
4356/**
4357 * Tracks active IdP-initiated SSO sessions per org. Allows per-org
4358 * SLO (Single Logout) and session audit.
4359 */
4360export const orgSsoSessions = pgTable(
4361 "org_sso_sessions",
4362 {
4363 id: uuid("id").primaryKey().defaultRandom(),
4364 userId: uuid("user_id")
4365 .notNull()
4366 .references(() => users.id, { onDelete: "cascade" }),
4367 orgId: uuid("org_id")
4368 .notNull()
4369 .references(() => organizations.id, { onDelete: "cascade" }),
4370 idpSessionId: text("idp_session_id"),
4371 createdAt: timestamp("created_at").defaultNow(),
4372 expiresAt: timestamp("expires_at").notNull(),
4373 },
4374 (table) => [
4375 index("org_sso_sessions_user").on(table.userId),
4376 index("org_sso_sessions_expires").on(table.expiresAt),
4377 ]
4378);
4379
4380export type OrgSsoConfig = typeof orgSsoConfigs.$inferSelect;
4381export type NewOrgSsoConfig = typeof orgSsoConfigs.$inferInsert;
4382export type ScimToken = typeof scimTokens.$inferSelect;
4383export type OrgSsoSession = typeof orgSsoSessions.$inferSelect;
4384
4385// ---------------------------------------------------------------------------
4386// Migration 0077 — Incident hook configs
4387// Maps a monitoring provider (PagerDuty / Datadog / Opsgenie / generic) to a
4388// specific repo. Inbound webhooks are validated against secret_hash (SHA-256
4389// of the user-chosen webhook secret passed in the ?secret= query param).
4390// ---------------------------------------------------------------------------
4391export const incidentHookConfigs = pgTable(
4392 "incident_hook_configs",
4393 {
4394 id: uuid("id").primaryKey().defaultRandom(),
4395 userId: uuid("user_id")
4396 .notNull()
4397 .references(() => users.id, { onDelete: "cascade" }),
4398 repoId: uuid("repo_id")
4399 .notNull()
4400 .references(() => repositories.id, { onDelete: "cascade" }),
4401 /** 'pagerduty' | 'datadog' | 'opsgenie' | 'generic' */
4402 provider: text("provider").notNull(),
4403 /** SHA-256 hex of the user's plaintext webhook secret. */
4404 secretHash: text("secret_hash").notNull(),
4405 createdAt: timestamp("created_at").defaultNow(),
4406 },
4407 (table) => [
4408 uniqueIndex("incident_hook_configs_repo_provider").on(
4409 table.repoId,
4410 table.provider
4411 ),
4412 index("incident_hook_configs_user").on(table.userId),
4413 ]
4414);
4415
4416export type IncidentHookConfig = typeof incidentHookConfigs.$inferSelect;
4417export type NewIncidentHookConfig = typeof incidentHookConfigs.$inferInsert;
4418
4419/**
4420 * Cloud deploy configurations — per-repo settings for push-triggered deploys
4421 * to Fly.io, Railway, Render, Vercel, Netlify, or a generic webhook URL.
4422 * Migration 0077.
4423 */
4424export const cloudDeployConfigs = pgTable(
4425 "cloud_deploy_configs",
4426 {
4427 id: uuid("id").primaryKey().defaultRandom(),
4428 repoId: uuid("repo_id")
4429 .notNull()
4430 .references(() => repositories.id, { onDelete: "cascade" }),
4431 // 'fly' | 'railway' | 'render' | 'vercel' | 'netlify' | 'webhook'
4432 provider: text("provider").notNull(),
4433 // Fly app name, Railway service ID, Render service ID, Vercel project ID, webhook URL
4434 providerAppId: text("provider_app_id").notNull(),
4435 // AES-256-GCM encrypted via SERVER_TARGETS_KEY
4436 apiTokenEncrypted: text("api_token_encrypted").notNull(),
4437 triggerBranch: text("trigger_branch").notNull().default("main"),
4438 enabled: boolean("enabled").notNull().default(true),
4439 createdAt: timestamp("created_at").defaultNow(),
4440 },
4441 (table) => [index("cloud_deploy_configs_repo").on(table.repoId)]
4442);
4443
4444/**
4445 * Cloud deployment runs — one row per triggered deployment attempt.
4446 * Status transitions: pending -> running -> success | failed | cancelled.
4447 * Migration 0077.
4448 */
4449export const cloudDeployments = pgTable(
4450 "cloud_deployments",
4451 {
4452 id: uuid("id").primaryKey().defaultRandom(),
4453 configId: uuid("config_id")
4454 .notNull()
4455 .references(() => cloudDeployConfigs.id, { onDelete: "cascade" }),
4456 repoId: uuid("repo_id")
4457 .notNull()
4458 .references(() => repositories.id, { onDelete: "cascade" }),
4459 commitSha: text("commit_sha").notNull(),
4460 // pending | running | success | failed | cancelled
4461 status: text("status").notNull().default("pending"),
4462 providerDeployId: text("provider_deploy_id"),
4463 logUrl: text("log_url"),
4464 deployUrl: text("deploy_url"),
4465 errorMessage: text("error_message"),
4466 startedAt: timestamp("started_at").defaultNow(),
4467 completedAt: timestamp("completed_at"),
4468 durationMs: integer("duration_ms"),
4469 },
4470 (table) => [
4471 index("cloud_deployments_repo").on(table.repoId, table.startedAt),
4472 index("cloud_deployments_config").on(table.configId, table.startedAt),
4473 ]
4474);
4475
4476export type CloudDeployConfig = typeof cloudDeployConfigs.$inferSelect;
4477export type NewCloudDeployConfig = typeof cloudDeployConfigs.$inferInsert;
4478export type CloudDeployment = typeof cloudDeployments.$inferSelect;
4479export type NewCloudDeployment = typeof cloudDeployments.$inferInsert;
4480// ---------------------------------------------------------------------------
4481// Recurring pattern detection (migration 0088)
4482// ---------------------------------------------------------------------------
4483
4484export const recurringPatterns = pgTable(
4485 "recurring_patterns",
4486 {
4487 id: uuid("id").primaryKey().defaultRandom(),
4488 repositoryId: uuid("repository_id")
4489 .notNull()
4490 .references(() => repositories.id, { onDelete: "cascade" }),
4491 title: text("title").notNull(),
4492 occurrences: integer("occurrences").notNull().default(1),
4493 commitShas: jsonb("commit_shas").$type<string[]>().notNull().default([]),
4494 rootCauseHypothesis: text("root_cause_hypothesis"),
4495 suggestedFile: text("suggested_file"),
4496 severity: text("severity").notNull().default("medium"),
4497 detectedAt: timestamp("detected_at", { withTimezone: true }).notNull().defaultNow(),
4498 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
4499 },
4500 (table) => [
4501 index("idx_recurring_patterns_repo").on(table.repositoryId),
4502 index("idx_recurring_patterns_expires").on(table.expiresAt),
4503 ]
4504);
4505
4506export type RecurringPattern = typeof recurringPatterns.$inferSelect;
4507export type NewRecurringPattern = typeof recurringPatterns.$inferInsert;
4508// ---------------------------------------------------------------------------
4509// Bus Factor Cache — migration 0088
4510// Stores per-repo knowledge concentration analysis (at-risk files where one
4511// author owns >75% of commits). Refreshed on demand; 7-day soft TTL.
4512// ---------------------------------------------------------------------------
4513export const busFactorCache = pgTable("bus_factor_cache", {
4514 id: uuid("id").primaryKey().defaultRandom(),
4515 repositoryId: uuid("repository_id")
4516 .notNull()
4517 .references(() => repositories.id, { onDelete: "cascade" })
4518 .unique(),
4519 analyzedAt: timestamp("analyzed_at", { withTimezone: true })
4520 .notNull()
4521 .defaultNow(),
4522 atRiskFiles: jsonb("at_risk_files").notNull().default([]),
4523 totalFilesAnalyzed: integer("total_files_analyzed").notNull().default(0),
4524});
45254305// ---------------------------------------------------------------------------
45264306// Migration 0088 — PR visit tracking for context-restore feature
45274307// ---------------------------------------------------------------------------
Modifiedsrc/lib/autopilot.ts+0−21View fileUnifiedSplit
@@ -71,7 +71,6 @@ import { expireIdleEnvs } from "./dev-env";
7171import { expireOldPreviews } from "./branch-previews";
7272import { getBotUserIdOrFallback } from "./bot-user";
7373import { runOnboardingDripTaskOnce } from "./onboarding-drip";
74import { runDepUpdateSweepOnce } from "./dep-updater-sweep";
7574import { sendSmartDigestsToAll } from "./smart-digest";
7675
7776export interface AutopilotTaskResult {
@@ -682,26 +681,6 @@ export function defaultTasks(): AutopilotTask[] {
682681 },
683682 },
684683 {
685 // AI dependency auto-updater (migration 0077). Once per day: scans
686 // up to 10 repos with depUpdaterEnabled=true, checks for patch/minor
687 // npm updates, applies them, runs GateTest, and either auto-merges
688 // (green) or opens a PR with an AI migration guide (red). Skips
689 // when DEP_UPDATER_ENABLED env flag is not set to "1".
690 name: "dep-update-sweep",
691 run: async () => {
692 if (process.env.DEP_UPDATER_ENABLED !== "1") return;
693 const now = Date.now();
694 if (now - _lastDepUpdateSweepAt < DEP_UPDATE_SWEEP_INTERVAL_MS) {
695 return;
696 }
697 _lastDepUpdateSweepAt = now;
698 try {
699 const summary = await runDepUpdateSweepOnce();
700 console.log(
701 `[autopilot] dep-update-sweep: repos=${summary.repos} runs=${summary.runs} merged=${summary.merged} prs=${summary.prs} skipped=${summary.skipped} errors=${summary.errors}`
702 );
703 } catch (err) {
704 console.error("[autopilot] dep-update-sweep: threw:", err);
705684 // Smart morning digest — AI-curated daily developer queue.
706685 // Fires once per day at 07:00 UTC (or whenever the 22h outer gate
707686 // next opens after 07:00). Per-user 20h cooldown in sendSmartDigest
Modifiedsrc/routes/pulls.tsx+0−1View fileUnifiedSplit
@@ -67,7 +67,6 @@ import {
6767import { triggerPrTriage } from "../lib/pr-triage";
6868import { generatePrSummary } from "../lib/ai-generators";
6969import { isAiAvailable } from "../lib/ai-client";
70import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
7170import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
7271import {
7372 computePrRiskForPullRequest,
7473