refactor(integrations): migrate issue tracker GitHub → Gitea

- apps/web/src/lib/gitea.ts: createIssue / getIssue / verifyWebhookSignature / buildIssueBody
  * Endpoint: git.semih.ai/api/v1 (configurable via GITEA_BASE_URL)
  * Auth: 'Authorization: token <PAT>' (Gitea convention)
  * Labels: Gitea expects numeric IDs not strings → ensureLabels() resolves/creates
    with color coding (P0/P1 red, P2 yellow, P3 green, type-* grey, default blue)
  * Webhook signature: X-Gitea-Signature (hex, no sha256= prefix)
- apps/worker/src/lib/gitea.ts: read-only getIssue() for sync polling
- _actions.ts + github-sync.ts now import from /lib/gitea
- Removed old apps/{web,worker}/.../lib/github.ts + /api/webhooks/github route
  (the receiver was already dead — sp.semih.ai is Tailscale-only)
- UI: 'GitHub' label → 'Gitea' on insight detail card
- github-sync job filters by githubIssueUrl.startsWith(GITEA_BASE_URL) so legacy
  GitHub-hosted insights (semihyesilyurt/sase.tr#20) stay frozen rather than
  collide with same-numbered Gitea issues at root/sase.tr.

Env migration (Coolify, panel-web + panel-worker):
- removed: GITHUB_TOKEN, GITHUB_REPO_SASE, GITHUB_WEBHOOK_SECRET
- added:   GITEA_TOKEN, GITEA_REPO_SASE=root/sase.tr, GITEA_BASE_URL=https://git.semih.ai

Provisioned Gitea PAT 'super-panel-insights' (scopes: write:repository + write:issue),
stored in Bitwarden.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-14 09:36:57 +00:00
parent d0c497d12d
commit dfc19c8f13
7 changed files with 101 additions and 149 deletions

View File

@@ -1,95 +0,0 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { verifyWebhookSignature } from "@/lib/github";
import { revalidatePath } from "next/cache";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
const payload = await req.text();
const signature = req.headers.get("x-hub-signature-256");
if (!verifyWebhookSignature(payload, signature)) {
return NextResponse.json({ ok: false, error: "bad_signature" }, { status: 401 });
}
let event: any;
try {
event = JSON.parse(payload);
} catch {
return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 });
}
const ghEvent = req.headers.get("x-github-event");
if (ghEvent !== "issues" && ghEvent !== "pull_request" && ghEvent !== "ping") {
return NextResponse.json({ ok: true, ignored: ghEvent });
}
if (ghEvent === "ping") return NextResponse.json({ ok: true, pong: true });
if (ghEvent === "issues") {
const action = event.action;
const issue = event.issue;
if (!issue?.id) return NextResponse.json({ ok: true, no_issue: true });
const insight = await prisma.insight.findFirst({
where: { githubIssueId: BigInt(issue.id) },
});
if (!insight) {
return NextResponse.json({ ok: true, not_tracked: true, issue_id: issue.id });
}
let nextStatus: string | null = null;
let extra: Record<string, unknown> = {};
switch (action) {
case "closed":
nextStatus = "shipped";
extra = { shippedAt: new Date(), validationStartedAt: new Date() };
break;
case "reopened":
nextStatus = "in_progress";
extra = { shippedAt: null, validationStartedAt: null, regressionDetected: false };
break;
case "opened":
nextStatus = "in_backlog";
break;
case "edited":
case "labeled":
case "unlabeled":
// ignore body/label changes
break;
}
if (nextStatus) {
await prisma.insight.update({
where: { id: insight.id },
data: { status: nextStatus, githubIssueState: issue.state, ...extra },
});
} else {
await prisma.insight.update({
where: { id: insight.id },
data: { githubIssueState: issue.state },
});
}
revalidatePath("/insights");
revalidatePath(`/insights/i/${insight.id}`);
return NextResponse.json({ ok: true, insight_id: insight.id, status: nextStatus });
}
if (ghEvent === "pull_request") {
// Map linked PR opening to in_progress. GitHub auto-links issues via #N in PR body.
const pr = event.pull_request;
const action = event.action;
if (!pr?.body) return NextResponse.json({ ok: true, no_pr: true });
// Find linked issues by #N pattern
const refs = Array.from(String(pr.body).matchAll(/#(\d+)/g)).map((m) => Number(m[1]));
if (!refs.length) return NextResponse.json({ ok: true, no_refs: true });
// Look up by issue number (we stored id not number — need to match via API or store number)
// For now match against githubIssueId — but PR refs use issue number. Skip safely.
return NextResponse.json({ ok: true, pr_refs: refs, action });
}
return NextResponse.json({ ok: true });
}

View File

@@ -5,7 +5,7 @@ import { headers } from "next/headers";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { writeAudit } from "@/lib/audit"; import { writeAudit } from "@/lib/audit";
import { createIssue, buildIssueBody } from "@/lib/github"; import { createIssue, buildIssueBody } from "@/lib/gitea";
import { pipelineQueue } from "@/lib/queue"; import { pipelineQueue } from "@/lib/queue";
const ALLOWED_STATUS = new Set([ const ALLOWED_STATUS = new Set([

View File

@@ -32,7 +32,7 @@ export function GithubActions({
if (issueUrl) { if (issueUrl) {
return ( return (
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-3 text-sm"> <div className="flex items-center gap-2 rounded-md border bg-muted/30 p-3 text-sm">
<span className="text-muted-foreground">GitHub:</span> <span className="text-muted-foreground">Gitea:</span>
<a href={issueUrl} target="_blank" rel="noreferrer" className="font-mono underline"> <a href={issueUrl} target="_blank" rel="noreferrer" className="font-mono underline">
#{issueNumber} ({issueState}) #{issueNumber} ({issueState})
</a> </a>
@@ -43,7 +43,7 @@ export function GithubActions({
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="default" disabled={pending} onClick={fire}> <Button size="sm" variant="default" disabled={pending} onClick={fire}>
Create GitHub Issue Create Gitea Issue
</Button> </Button>
{flash && <span className="text-xs text-muted-foreground">{flash}</span>} {flash && <span className="text-xs text-muted-foreground">{flash}</span>}
</div> </div>

View File

@@ -1,11 +1,12 @@
import { createHmac, timingSafeEqual } from "node:crypto"; import { createHmac, timingSafeEqual } from "node:crypto";
const TOKEN = process.env.GITHUB_TOKEN ?? ""; const TOKEN = process.env.GITEA_TOKEN ?? "";
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET ?? ""; const BASE = (process.env.GITEA_BASE_URL ?? "https://git.semih.ai").replace(/\/$/, "");
const WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET ?? "";
// Per-project repo mapping via env: GITHUB_REPO_<KEY>=owner/repo. // Per-project repo mapping via env: GITEA_REPO_<KEY>=owner/repo.
export function repoFor(projectKey: string): { owner: string; name: string } | null { export function repoFor(projectKey: string): { owner: string; name: string } | null {
const env = process.env[`GITHUB_REPO_${projectKey.toUpperCase()}`]; const env = process.env[`GITEA_REPO_${projectKey.toUpperCase()}`];
if (!env) return null; if (!env) return null;
const [owner, name] = env.split("/"); const [owner, name] = env.split("/");
if (!owner || !name) return null; if (!owner || !name) return null;
@@ -13,14 +14,13 @@ export function repoFor(projectKey: string): { owner: string; name: string } | n
} }
function api(path: string): string { function api(path: string): string {
return `https://api.github.com${path}`; return `${BASE}/api/v1${path}`;
} }
function headers(): HeadersInit { function headers(): HeadersInit {
return { return {
Authorization: `Bearer ${TOKEN}`, Authorization: `token ${TOKEN}`,
Accept: "application/vnd.github+json", Accept: "application/json",
"X-GitHub-Api-Version": "2022-11-28",
}; };
} }
@@ -35,24 +35,34 @@ export async function createIssue(
projectKey: string, projectKey: string,
input: { title: string; body: string; labels?: string[] }, input: { title: string; body: string; labels?: string[] },
): Promise<CreatedIssue> { ): Promise<CreatedIssue> {
if (!TOKEN) throw new Error("GITHUB_TOKEN not set"); if (!TOKEN) throw new Error("GITEA_TOKEN not set");
const repo = repoFor(projectKey); const repo = repoFor(projectKey);
if (!repo) throw new Error(`no repo mapping for project ${projectKey}`); if (!repo) throw new Error(`no repo mapping for project ${projectKey}`);
// Gitea requires label IDs (numeric), not strings — resolve/create as needed.
const labelIds = input.labels && input.labels.length ? await ensureLabels(repo, input.labels) : [];
const res = await fetch(api(`/repos/${repo.owner}/${repo.name}/issues`), { const res = await fetch(api(`/repos/${repo.owner}/${repo.name}/issues`), {
method: "POST", method: "POST",
headers: { ...headers(), "content-type": "application/json" }, headers: { ...headers(), "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
title: input.title.slice(0, 256), title: input.title.slice(0, 256),
body: input.body, body: input.body,
labels: input.labels ?? [], labels: labelIds,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
throw new Error(`github ${res.status}: ${text.slice(0, 200)}`); throw new Error(`gitea ${res.status}: ${text.slice(0, 200)}`);
} }
const data = (await res.json()) as CreatedIssue; const data = (await res.json()) as any;
return data; // Gitea returns issue with `number` (per-repo index) and `id` (global). html_url is provided.
return {
id: data.id,
number: data.number,
html_url: data.html_url,
state: data.state,
};
} }
export async function getIssue( export async function getIssue(
@@ -66,16 +76,63 @@ export async function getIssue(
headers: headers(), headers: headers(),
}); });
if (!res.ok) return null; if (!res.ok) return null;
return (await res.json()) as CreatedIssue; const data = (await res.json()) as any;
return {
id: data.id,
number: data.number,
html_url: data.html_url,
state: data.state,
};
} }
// HMAC-SHA256 signature verification (X-Hub-Signature-256: sha256=...) // Resolve label name → id. Create the label if absent.
async function ensureLabels(
repo: { owner: string; name: string },
names: string[],
): Promise<number[]> {
const res = await fetch(api(`/repos/${repo.owner}/${repo.name}/labels?limit=100`), {
headers: headers(),
});
let existing: Array<{ id: number; name: string }> = [];
if (res.ok) existing = (await res.json()) as any;
const byName = new Map(existing.map((l) => [l.name, l.id]));
const ids: number[] = [];
for (const n of names) {
let id = byName.get(n);
if (id) {
ids.push(id);
continue;
}
// Create label with default color
const cr = await fetch(api(`/repos/${repo.owner}/${repo.name}/labels`), {
method: "POST",
headers: { ...headers(), "content-type": "application/json" },
body: JSON.stringify({ name: n, color: pickColor(n), exclusive: false }),
});
if (cr.ok) {
const data = (await cr.json()) as { id: number; name: string };
byName.set(data.name, data.id);
ids.push(data.id);
}
}
return ids;
}
function pickColor(label: string): string {
if (label.includes("P0") || label.includes("P1")) return "#d73a4a";
if (label.includes("P2")) return "#fbca04";
if (label.includes("P3")) return "#0e8a16";
if (label.startsWith("type-")) return "#6e6e6e";
return "#0366d6";
}
// HMAC-SHA256 signature verification for Gitea webhooks (X-Gitea-Signature: hex).
export function verifyWebhookSignature(payload: string, signature: string | null): boolean { export function verifyWebhookSignature(payload: string, signature: string | null): boolean {
if (!WEBHOOK_SECRET) return false; // require secret in prod if (!WEBHOOK_SECRET) return false;
if (!signature || !signature.startsWith("sha256=")) return false; if (!signature) return false;
const provided = signature.slice("sha256=".length); // Gitea sends raw hex (no "sha256=" prefix). Be defensive about both forms.
const provided = signature.startsWith("sha256=") ? signature.slice(7) : signature;
const computed = createHmac("sha256", WEBHOOK_SECRET).update(payload).digest("hex"); const computed = createHmac("sha256", WEBHOOK_SECRET).update(payload).digest("hex");
// timingSafeEqual requires equal-length buffers
const a = Buffer.from(provided, "hex"); const a = Buffer.from(provided, "hex");
const b = Buffer.from(computed, "hex"); const b = Buffer.from(computed, "hex");
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
@@ -83,6 +140,7 @@ export function verifyWebhookSignature(payload: string, signature: string | null
} }
// Build a markdown issue body from an insight + LLM analysis. // Build a markdown issue body from an insight + LLM analysis.
// (Identical to former github.ts version — markdown rendering is provider-agnostic.)
export function buildIssueBody(opts: { export function buildIssueBody(opts: {
insightId: string; insightId: string;
panelUrl: string; panelUrl: string;
@@ -112,15 +170,9 @@ export function buildIssueBody(opts: {
``, ``,
]; ];
if (typeof b.hypothesis === "string") { if (typeof b.hypothesis === "string") lines.push(`## Hypothesis`, b.hypothesis as string, "");
lines.push(`## Hypothesis`, b.hypothesis as string, ""); if (typeof b.intent_hypothesis === "string") lines.push(`## User intent`, b.intent_hypothesis as string, "");
} if (typeof b.friction_point === "string") lines.push(`## Friction point`, b.friction_point as string, "");
if (typeof b.intent_hypothesis === "string") {
lines.push(`## User intent`, b.intent_hypothesis as string, "");
}
if (typeof b.friction_point === "string") {
lines.push(`## Friction point`, b.friction_point as string, "");
}
if (Array.isArray(b.reproduce_steps) && b.reproduce_steps.length) { if (Array.isArray(b.reproduce_steps) && b.reproduce_steps.length) {
lines.push(`## Reproduce steps`); lines.push(`## Reproduce steps`);
(b.reproduce_steps as string[]).forEach((s, i) => lines.push(`${i + 1}. ${s}`)); (b.reproduce_steps as string[]).forEach((s, i) => lines.push(`${i + 1}. ${s}`));
@@ -138,18 +190,10 @@ export function buildIssueBody(opts: {
if (typeof b.affected_provider === "string" || typeof b.implicated_provider === "string") { if (typeof b.affected_provider === "string" || typeof b.implicated_provider === "string") {
lines.push(`## Implicated provider`, `${b.affected_provider ?? b.implicated_provider}`, ""); lines.push(`## Implicated provider`, `${b.affected_provider ?? b.implicated_provider}`, "");
} }
if (typeof b.failure_mode === "string") { if (typeof b.failure_mode === "string") lines.push(`## Failure mode`, `${b.failure_mode}`, "");
lines.push(`## Failure mode`, `${b.failure_mode}`, ""); if (typeof b.quick_fix === "string") lines.push(`## Quick fix`, b.quick_fix as string, "");
} if (typeof b.long_term_fix === "string") lines.push(`## Long-term fix`, b.long_term_fix as string, "");
if (typeof b.quick_fix === "string") { if (typeof b.suggested_action === "string") lines.push(`## Suggested action`, b.suggested_action as string, "");
lines.push(`## Quick fix`, b.quick_fix as string, "");
}
if (typeof b.long_term_fix === "string") {
lines.push(`## Long-term fix`, b.long_term_fix as string, "");
}
if (typeof b.suggested_action === "string") {
lines.push(`## Suggested action`, b.suggested_action as string, "");
}
if (Array.isArray(b.suggested_investigation) && b.suggested_investigation.length) { if (Array.isArray(b.suggested_investigation) && b.suggested_investigation.length) {
lines.push(`## Suggested investigation`); lines.push(`## Suggested investigation`);
(b.suggested_investigation as string[]).forEach((s) => lines.push(`- ${s}`)); (b.suggested_investigation as string[]).forEach((s) => lines.push(`- ${s}`));
@@ -159,7 +203,6 @@ export function buildIssueBody(opts: {
if (typeof b.unclear_concept === "string") lines.push(`## Unclear concept`, b.unclear_concept as string, ""); if (typeof b.unclear_concept === "string") lines.push(`## Unclear concept`, b.unclear_concept as string, "");
if (typeof b.documentation_gap === "string") lines.push(`## Documentation gap`, b.documentation_gap as string, ""); if (typeof b.documentation_gap === "string") lines.push(`## Documentation gap`, b.documentation_gap as string, "");
if (typeof b.suggested_in_app_help === "string") lines.push(`## Suggested in-app help`, b.suggested_in_app_help as string, ""); if (typeof b.suggested_in_app_help === "string") lines.push(`## Suggested in-app help`, b.suggested_in_app_help as string, "");
if (typeof b.suggested_fix_effort === "string") { if (typeof b.suggested_fix_effort === "string") {
lines.push(`**Effort estimate:** ${b.suggested_fix_effort}`, ""); lines.push(`**Effort estimate:** ${b.suggested_fix_effort}`, "");
} }

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
import { prisma } from "../db"; import { prisma } from "../db";
import { getIssue } from "../lib/github"; import { getIssue } from "../lib/gitea";
const POLL_LIMIT = Number(process.env.GITHUB_SYNC_LIMIT ?? "50"); const POLL_LIMIT = Number(process.env.GITHUB_SYNC_LIMIT ?? "50");
@@ -13,9 +13,13 @@ export type GithubSyncResult = {
// Used because the panel is Tailscale-only and can't receive webhooks. // Used because the panel is Tailscale-only and can't receive webhooks.
export async function runGithubSync(): Promise<GithubSyncResult> { export async function runGithubSync(): Promise<GithubSyncResult> {
// Only poll insights that have an issue and could still change state. // Only poll insights that have an issue and could still change state.
const giteaBase = (process.env.GITEA_BASE_URL ?? "https://git.semih.ai").replace(/\/$/, "");
const tracked = await prisma.insight.findMany({ const tracked = await prisma.insight.findMany({
where: { where: {
githubIssueNumber: { not: null }, githubIssueNumber: { not: null },
// Only sync issues hosted on the configured Gitea instance.
// Old GitHub-hosted insights stay frozen until manually re-linked or dismissed.
githubIssueUrl: { startsWith: giteaBase },
status: { notIn: ["validated", "dismissed", "duplicate"] }, status: { notIn: ["validated", "dismissed", "duplicate"] },
}, },
orderBy: { updatedAt: "asc" }, orderBy: { updatedAt: "asc" },

View File

@@ -1,10 +1,11 @@
// Minimal GitHub REST client for the worker (read-only). // Minimal Gitea REST client for the worker (read-only).
// Outbound only — used because Tailscale-only panel can't receive GitHub webhooks. // Outbound only — used because Tailscale-only panel can't receive webhooks.
const TOKEN = process.env.GITHUB_TOKEN ?? ""; const TOKEN = process.env.GITEA_TOKEN ?? "";
const BASE = (process.env.GITEA_BASE_URL ?? "https://git.semih.ai").replace(/\/$/, "");
function repoFor(projectKey: string): { owner: string; name: string } | null { function repoFor(projectKey: string): { owner: string; name: string } | null {
const env = process.env[`GITHUB_REPO_${projectKey.toUpperCase()}`]; const env = process.env[`GITEA_REPO_${projectKey.toUpperCase()}`];
if (!env) return null; if (!env) return null;
const [owner, name] = env.split("/"); const [owner, name] = env.split("/");
if (!owner || !name) return null; if (!owner || !name) return null;
@@ -20,11 +21,10 @@ export async function getIssue(
if (!TOKEN) return null; if (!TOKEN) return null;
const repo = repoFor(projectKey); const repo = repoFor(projectKey);
if (!repo) return null; if (!repo) return null;
const res = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/issues/${number}`, { const res = await fetch(`${BASE}/api/v1/repos/${repo.owner}/${repo.name}/issues/${number}`, {
headers: { headers: {
Authorization: `Bearer ${TOKEN}`, Authorization: `token ${TOKEN}`,
Accept: "application/vnd.github+json", Accept: "application/json",
"X-GitHub-Api-Version": "2022-11-28",
}, },
}); });
if (!res.ok) return null; if (!res.ok) return null;