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:
File diff suppressed because one or more lines are too long
73
apps/worker/src/jobs/github-sync.ts
Normal file
73
apps/worker/src/jobs/github-sync.ts
Normal 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 };
|
||||
}
|
||||
33
apps/worker/src/lib/github.ts
Normal file
33
apps/worker/src/lib/github.ts
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user