feat(phase6d): GitHub polling sync — outbound-only state mirror (Tailscale-only panel)

Webhook can't reach sp.semih.ai from internet (Tailscale-only DNS). Switched to a
worker job that polls GitHub for tracked issue states every 10 minutes.

apps/worker/src/lib/github.ts: read-only getIssue() client
apps/worker/src/jobs/github-sync.ts: scan insights with githubIssueNumber + not validated/dismissed/duplicate,
  fetch remote state, reflect transitions:
    open → closed = shipped + shippedAt + validationStartedAt
    closed → open = in_progress + clear validation state

Scheduler: github-sync@*/10min added.

The /api/webhooks/github route stays (works if reachable in future) but is no longer
the source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-14 05:54:58 +00:00
parent 0c6350db2d
commit 9c7682ae63
4 changed files with 123 additions and 2 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,73 @@
import { prisma } from "../db";
import { getIssue } from "../lib/github";
const POLL_LIMIT = Number(process.env.GITHUB_SYNC_LIMIT ?? "50");
export type GithubSyncResult = {
polled: number;
updated: number;
notFound: number;
};
// Poll GitHub for the current state of tracked issues and reflect into insight.status.
// Used because the panel is Tailscale-only and can't receive webhooks.
export async function runGithubSync(): Promise<GithubSyncResult> {
// Only poll insights that have an issue and could still change state.
const tracked = await prisma.insight.findMany({
where: {
githubIssueNumber: { not: null },
status: { notIn: ["validated", "dismissed", "duplicate"] },
},
orderBy: { updatedAt: "asc" },
take: POLL_LIMIT,
});
let updated = 0;
let notFound = 0;
for (const insight of tracked) {
if (!insight.githubIssueNumber) continue;
let remote;
try {
remote = await getIssue(insight.projectKey, insight.githubIssueNumber);
} catch (e) {
console.warn(`[github-sync] error #${insight.githubIssueNumber}: ${(e as Error).message}`);
continue;
}
if (!remote) {
notFound++;
continue;
}
const remoteState = remote.state;
const wasOpen = insight.githubIssueState !== "closed";
const isClosed = remoteState === "closed";
if (insight.githubIssueState === remoteState) continue;
const updateData: Record<string, unknown> = { githubIssueState: remoteState };
// Transition logic:
// open → closed = "shipped" (start validation period)
// closed → open = "in_progress" (cleared validation)
if (wasOpen && isClosed) {
const closedAt = remote.closed_at ? new Date(remote.closed_at) : new Date();
updateData.status = "shipped";
updateData.shippedAt = closedAt;
updateData.validationStartedAt = closedAt;
updateData.regressionDetected = false;
} else if (!wasOpen && remoteState === "open") {
updateData.status = "in_progress";
updateData.shippedAt = null;
updateData.validationStartedAt = null;
updateData.regressionDetected = false;
updateData.validatedAt = null;
}
await prisma.insight.update({ where: { id: insight.id }, data: updateData });
console.log(
`[github-sync] #${insight.githubIssueNumber} ${insight.githubIssueState ?? "?"}${remoteState} insight=${insight.id} status=${updateData.status ?? "unchanged"}`,
);
updated++;
}
return { polled: tracked.length, updated, notFound };
}

View File

@@ -0,0 +1,33 @@
// Minimal GitHub REST client for the worker (read-only).
// Outbound only — used because Tailscale-only panel can't receive GitHub webhooks.
const TOKEN = process.env.GITHUB_TOKEN ?? "";
function repoFor(projectKey: string): { owner: string; name: string } | null {
const env = process.env[`GITHUB_REPO_${projectKey.toUpperCase()}`];
if (!env) return null;
const [owner, name] = env.split("/");
if (!owner || !name) return null;
return { owner, name };
}
export type IssueState = "open" | "closed";
export async function getIssue(
projectKey: string,
number: number,
): Promise<{ state: IssueState; closed_at: string | null } | null> {
if (!TOKEN) return null;
const repo = repoFor(projectKey);
if (!repo) return null;
const res = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/issues/${number}`, {
headers: {
Authorization: `Bearer ${TOKEN}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!res.ok) return null;
const data = (await res.json()) as { state: IssueState; closed_at: string | null };
return { state: data.state, closed_at: data.closed_at };
}

View File

@@ -5,6 +5,7 @@ import { runTagSessions } from "../jobs/tag-sessions";
import { runCompressSessions } from "../jobs/compress-sessions";
import { runAnalyze } from "../jobs/analyze";
import { runValidation } from "../jobs/validation";
import { runGithubSync } from "../jobs/github-sync";
const QUEUE = "insight-pipeline";
@@ -47,6 +48,15 @@ async function runJob(job: Job) {
}
return res;
}
case "github-sync": {
const res = await runGithubSync();
if (res.polled > 0) {
console.log(
`[pipeline] github-sync polled=${res.polled} updated=${res.updated} not_found=${res.notFound}`,
);
}
return res;
}
default:
return { ok: false, error: `unknown job ${job.name}` };
}
@@ -78,6 +88,11 @@ export async function startInsightPipeline() {
{ pattern: "0 5 * * *" },
{ name: "validation", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
await queue.upsertJobScheduler(
"github-sync",
{ pattern: "*/10 * * * *" },
{ name: "github-sync", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
new Worker(QUEUE, runJob, {
connection: redis,
@@ -86,6 +101,6 @@ export async function startInsightPipeline() {
stalledInterval: 60_000,
});
console.log(
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00",
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min",
);
}