work
Some checks failed
Build and Push Container Image / build-and-push (push) Failing after 3m49s

This commit is contained in:
2026-05-19 12:00:00 +02:00
parent 6bd3b76782
commit 8e02f673ca
33 changed files with 2992 additions and 12 deletions

View File

@@ -5,6 +5,11 @@ import { env } from '$env/dynamic/private';
import { getRequestEvent } from '$app/server';
import { db } from '$lib/server/db';
if (!env.ORIGIN) throw new Error('ORIGIN is not set');
if (!env.BETTER_AUTH_SECRET) throw new Error('BETTER_AUTH_SECRET is not set');
if (!env.GITHUB_CLIENT_ID) throw new Error('GITHUB_CLIENT_ID is not set');
if (!env.GITHUB_CLIENT_SECRET) throw new Error('GITHUB_CLIENT_SECRET is not set');
export const auth = betterAuth({
baseURL: env.ORIGIN,
secret: env.BETTER_AUTH_SECRET,

View File

@@ -1,9 +1,140 @@
import { pgTable, serial, integer, text } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { user } from './auth.schema';
export const projectStatus = ['planning', 'active', 'paused', 'done'] as const;
export const taskStatus = ['todo', 'doing', 'blocked', 'done'] as const;
export const issueSource = ['github', 'gitea'] as const;
export const githubInstallation = pgTable(
'github_installation',
{
id: serial('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
installationId: text('installation_id').notNull().unique(),
accountLogin: text('account_login').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new Date())
.notNull()
},
(table) => [uniqueIndex('github_installation_installation_id_idx').on(table.installationId)]
);
export const githubRepositoryLink = pgTable(
'github_repository_link',
{
id: serial('id').primaryKey(),
projectId: integer('project_id')
.notNull()
.references(() => project.id, { onDelete: 'cascade' }),
installationId: integer('installation_id')
.notNull()
.references(() => githubInstallation.id, { onDelete: 'cascade' }),
owner: text('owner').notNull(),
repo: text('repo').notNull(),
fullName: text('full_name').notNull().unique(),
defaultBranch: text('default_branch').notNull().default('main'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new Date())
.notNull()
},
(table) => [uniqueIndex('github_repository_link_project_id_idx').on(table.projectId)]
);
export const task = pgTable('task', {
id: serial('id').primaryKey(),
projectId: integer('project_id')
.notNull()
.references(() => project.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
priority: integer('priority').notNull().default(1)
description: text('description').notNull().default(''),
status: text('status', { enum: taskStatus }).notNull().default('todo'),
priority: integer('priority').notNull().default(1),
dueDate: timestamp('due_date'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new Date())
.notNull()
});
export const project = pgTable('project', {
id: serial('id').primaryKey(),
ownerId: text('owner_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description').notNull().default(''),
status: text('status', { enum: projectStatus }).notNull().default('planning'),
progress: integer('progress').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new Date())
.notNull()
});
export const issue = pgTable('issue', {
id: serial('id').primaryKey(),
projectId: integer('project_id')
.notNull()
.references(() => project.id, { onDelete: 'cascade' }),
provider: text('provider', { enum: issueSource }).notNull(),
externalId: text('external_id').notNull(),
url: text('url').notNull(),
title: text('title').notNull(),
state: text('state').notNull(),
labels: text('labels').notNull().default('[]'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new Date())
.notNull()
});
export const githubInstallationRelations = relations(githubInstallation, ({ one, many }) => ({
user: one(user, {
fields: [githubInstallation.userId],
references: [user.id]
}),
repositories: many(githubRepositoryLink)
}));
export const githubRepositoryLinkRelations = relations(githubRepositoryLink, ({ one }) => ({
project: one(project, {
fields: [githubRepositoryLink.projectId],
references: [project.id]
}),
installation: one(githubInstallation, {
fields: [githubRepositoryLink.installationId],
references: [githubInstallation.id]
})
}));
export const projectRelations = relations(project, ({ many }) => ({
tasks: many(task),
issues: many(issue),
githubRepositoryLinks: many(githubRepositoryLink)
}));
export const taskRelations = relations(task, ({ one }) => ({
project: one(project, {
fields: [task.projectId],
references: [project.id]
})
}));
export const issueRelations = relations(issue, ({ one }) => ({
project: one(project, {
fields: [issue.projectId],
references: [project.id]
})
}));
export * from './auth.schema';

16
src/lib/server/events.ts Normal file
View File

@@ -0,0 +1,16 @@
const listeners = new Set<ReadableStreamDefaultController<string>>();
export function subscribe(controller: ReadableStreamDefaultController<string>) {
listeners.add(controller);
return () => listeners.delete(controller);
}
export function publish(message: string) {
for (const controller of listeners) {
try {
controller.enqueue(`data: ${message}\n\n`);
} catch {
listeners.delete(controller);
}
}
}

View File

@@ -0,0 +1,176 @@
import { env } from '$env/dynamic/private';
import { createHmac, createPrivateKey, createSign } from 'node:crypto';
export type GitHubRepository = {
id: number;
name: string;
full_name: string;
default_branch: string;
owner: { login: string };
};
type InstallState = {
redirect: string;
userId: string;
};
function requireEnv(name: string) {
switch (name) {
case 'GITHUB_APP_ID':
if (!env.GITHUB_APP_ID) throw new Error('GITHUB_APP_ID is not set');
return env.GITHUB_APP_ID;
case 'GITHUB_APP_SLUG':
if (!env.GITHUB_APP_SLUG) throw new Error('GITHUB_APP_SLUG is not set');
return env.GITHUB_APP_SLUG;
case 'GITHUB_APP_PRIVATE_KEY':
if (!env.GITHUB_APP_PRIVATE_KEY) throw new Error('GITHUB_APP_PRIVATE_KEY is not set');
return env.GITHUB_APP_PRIVATE_KEY;
case 'BETTER_AUTH_SECRET':
if (!env.BETTER_AUTH_SECRET) throw new Error('BETTER_AUTH_SECRET is not set');
return env.BETTER_AUTH_SECRET;
default:
throw new Error(`${name} is not supported`);
}
}
export function hasGitHubAppPrivateKey() {
return Boolean(env.GITHUB_APP_PRIVATE_KEY);
}
function normalizePrivateKey(key: string) {
return key.includes('-----BEGIN') ? key.replace(/\\n/g, '\n') : Buffer.from(key, 'base64').toString('utf8');
}
function base64urlJson(value: unknown) {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}
function signState(payload: InstallState, secret: string) {
const serialized = base64urlJson(payload);
const signature = createHmac('sha256', secret).update(serialized).digest('base64url');
return `${serialized}.${signature}`;
}
function verifyState(state: string, secret: string) {
const [payload, signature] = state.split('.');
if (!payload || !signature) return null;
const expected = createHmac('sha256', secret).update(payload).digest('base64url');
if (expected !== signature) return null;
try {
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as InstallState;
} catch {
return null;
}
}
export function getGitHubInstallUrl(input: { redirect?: string; userId: string }) {
const slug = requireEnv('GITHUB_APP_SLUG');
const secret = requireEnv('BETTER_AUTH_SECRET');
const redirect = input.redirect ?? '/integrations';
const base = new URL(`https://github.com/apps/${slug}/installations/new`);
base.searchParams.set('state', signState({ redirect, userId: input.userId }, secret));
return base.toString();
}
export function decodeInstallState(state?: string) {
if (!state) return { redirect: '/integrations' };
const secret = requireEnv('BETTER_AUTH_SECRET');
const parsed = verifyState(state, secret);
if (!parsed) return { redirect: '/integrations' };
return { redirect: parsed.redirect || '/integrations', userId: parsed.userId };
}
function createGitHubAppJwt() {
const appId = requireEnv('GITHUB_APP_ID');
const privateKey = requireEnv('GITHUB_APP_PRIVATE_KEY');
const now = Math.floor(Date.now() / 1000);
const header = base64urlJson({ alg: 'RS256', typ: 'JWT' });
const payload = base64urlJson({ iat: now - 60, exp: now + 9 * 60, iss: appId });
const signer = createSign('RSA-SHA256');
signer.update(`${header}.${payload}`);
signer.end();
const key = createPrivateKey(normalizePrivateKey(privateKey));
const signature = signer.sign(key, 'base64url');
return `${header}.${payload}.${signature}`;
}
async function githubAppRequest(path: string, init: RequestInit = {}) {
const response = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
accept: 'application/vnd.github+json',
'x-github-api-version': '2022-11-28',
...(init.headers ?? {})
}
});
if (!response.ok) {
throw new Error(`GitHub App request failed: ${response.status} ${response.statusText}`);
}
return response;
}
export async function getGitHubInstallation(installationId: string) {
const response = await githubAppRequest(`/app/installations/${installationId}`, {
headers: { authorization: `Bearer ${createGitHubAppJwt()}` }
});
return (await response.json()) as {
id: number;
account?: { login?: string };
};
}
export async function getGitHubInstallationAccessToken(installationId: string) {
const response = await githubAppRequest(`/app/installations/${installationId}/access_tokens`, {
method: 'POST',
headers: { authorization: `Bearer ${createGitHubAppJwt()}` }
});
return (await response.json()) as { token: string; expires_at: string };
}
export async function listGitHubInstallationRepositories(installationId: string) {
const { token } = await getGitHubInstallationAccessToken(installationId);
const response = await fetch('https://api.github.com/installation/repositories?per_page=100', {
headers: {
accept: 'application/vnd.github+json',
'x-github-api-version': '2022-11-28',
authorization: `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`GitHub repository list failed: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as { repositories: GitHubRepository[] };
return data.repositories;
}
export async function listGitHubRepositoryIssues(input: { installationId: string; owner: string; repo: string }) {
const { token } = await getGitHubInstallationAccessToken(input.installationId);
const response = await fetch(
`https://api.github.com/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/issues?state=all&per_page=100`,
{
headers: {
accept: 'application/vnd.github+json',
'x-github-api-version': '2022-11-28',
authorization: `Bearer ${token}`
}
}
);
if (!response.ok) {
throw new Error(`GitHub issue list failed: ${response.status} ${response.statusText}`);
}
return (await response.json()) as Array<{
id: number;
number: number;
title: string;
state: 'open' | 'closed';
html_url: string;
labels: Array<{ name: string }>;
pull_request?: unknown;
}>;
}