From ed4bf9a0ee99b99c0a763fb0a100953ae9da769f Mon Sep 17 00:00:00 2001 From: Milas Holsting Date: Tue, 19 May 2026 12:00:00 +0200 Subject: [PATCH] work --- .env.example | 5 + docs/evaluation.md | 16 + docs/technical-decisions.md | 30 + docs/weekly-plan.md | 19 + drizzle/0001_kind_jamie_braddock.sql | 33 + drizzle/0002_eager_shen.sql | 28 + drizzle/meta/0001_snapshot.json | 630 +++++++++++++ drizzle/meta/0002_snapshot.json | 840 ++++++++++++++++++ drizzle/meta/_journal.json | 14 + src/lib/server/auth.ts | 5 + src/lib/server/db/schema.ts | 135 ++- src/lib/server/events.ts | 16 + src/lib/server/github-app.ts | 172 ++++ src/routes/+page.server.ts | 72 ++ src/routes/+page.svelte | 106 ++- src/routes/api/events/+server.ts | 34 + .../api/github/install/callback/+server.ts | 34 + src/routes/auth/+page.server.ts | 4 +- src/routes/auth/login/+page.server.ts | 10 +- src/routes/auth/login/+page.svelte | 2 +- src/routes/integrations/+page.server.ts | 53 ++ src/routes/integrations/+page.svelte | 64 ++ src/routes/projects/+page.server.ts | 39 + src/routes/projects/+page.svelte | 38 + src/routes/projects/[id]/+page.server.ts | 148 +++ src/routes/projects/[id]/+page.svelte | 94 ++ .../[id]/link-repository/+page.server.ts | 89 ++ .../[id]/link-repository/+page.svelte | 44 + 28 files changed, 2762 insertions(+), 12 deletions(-) create mode 100644 docs/evaluation.md create mode 100644 docs/technical-decisions.md create mode 100644 docs/weekly-plan.md create mode 100644 drizzle/0001_kind_jamie_braddock.sql create mode 100644 drizzle/0002_eager_shen.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/lib/server/events.ts create mode 100644 src/lib/server/github-app.ts create mode 100644 src/routes/+page.server.ts create mode 100644 src/routes/api/events/+server.ts create mode 100644 src/routes/api/github/install/callback/+server.ts create mode 100644 src/routes/integrations/+page.server.ts create mode 100644 src/routes/integrations/+page.svelte create mode 100644 src/routes/projects/+page.server.ts create mode 100644 src/routes/projects/+page.svelte create mode 100644 src/routes/projects/[id]/+page.server.ts create mode 100644 src/routes/projects/[id]/+page.svelte create mode 100644 src/routes/projects/[id]/link-repository/+page.server.ts create mode 100644 src/routes/projects/[id]/link-repository/+page.svelte diff --git a/.env.example b/.env.example index c31bc64..4e660fb 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,8 @@ BETTER_AUTH_SECRET="" # https://www.better-auth.com/docs/authentication/github GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" + +# GitHub App +GITHUB_APP_ID="" +GITHUB_APP_SLUG="" +GITHUB_APP_SETUP_URL="" diff --git a/docs/evaluation.md b/docs/evaluation.md new file mode 100644 index 0000000..28e7fac --- /dev/null +++ b/docs/evaluation.md @@ -0,0 +1,16 @@ +# Evaluering + +## Hvad fungerede godt +- SvelteKit gjorde det hurtigt at samle UI og backend i samme repo. +- Login og beskyttede routes gav en klar brugerflow. +- Projekter og tasks kan udvides uden at ændre arkitekturen meget. + +## Hvad var udfordrende +- Rigtig GitHub/Gitea sync kræver provider-specifik auth og API-håndtering. +- Realtidsopdateringer skal afstemmes med deploy-miljø og connection limits. +- Progressberegning skal være konsistent, når tasks ændres samtidigt. + +## Hvordan kan det bruges professionelt +- Som intern status- og opgaveportal. +- Som udviklerdashboard til issues og sprint-overblik. +- Som fundament for et teamværktøj med integrationer og live status. diff --git a/docs/technical-decisions.md b/docs/technical-decisions.md new file mode 100644 index 0000000..ad0f212 --- /dev/null +++ b/docs/technical-decisions.md @@ -0,0 +1,30 @@ +# Tekniske Valg + +## Hvad jeg byggede +En SvelteKit-baseret produktivitetsapp med login, projekter, tasks, issue-overblik og live opdateringskanal. + +## Hvorfor SvelteKit +- Server `load` gør det enkelt at hente data tæt på routen. +- Form actions passer godt til mutationer uden tung klientlogik. +- SSR giver hurtig første render og bedre default UX. +- SSE passer til lette live-opdateringer uden fuld websocket-kompleksitet. + +## Hvordan det virker i praksis +- Brugeren logger ind. +- Dashboardet henter projekter, tasks og issues server-side. +- Brugeren kan oprette projekter og tasks via actions. +- Projekter kan opdatere status og beregnet progress. +- En SSE-endpoint kan sende opdateringer til klienten. + +## Hvad der virkede godt +- SvelteKit gjorde dataflowet kort og tydeligt. +- Server actions holdt mutationerne samlede. +- Drizzle passede godt til schema-drevet udvikling. + +## Hvad der var udfordrende +- Realtime sync fra GitHub/Gitea kræver API-specifik integration. +- Progress bør helst beregnes robust i databasen eller via en service. +- SSE er nemt til énvejskommunikation, men websocket kan blive nødvendig senere. + +## Professionel brug +Løsningen kan bruges som intern project tracker, support-overblik eller developer dashboard med integrerede issues og statusopdateringer. diff --git a/docs/weekly-plan.md b/docs/weekly-plan.md new file mode 100644 index 0000000..3be0351 --- /dev/null +++ b/docs/weekly-plan.md @@ -0,0 +1,19 @@ +# Weekly Plan + +## Uge 1 +- Planlæg et lille MVP-projekt i SvelteKit. +- Implementer login, dashboard, projekter og opgaver. +- Dokumenter hvorfor SvelteKit passer til SSR, form actions og realtime opdateringer. + +## Uge 2 +- Tilføj issue-sync fra GitHub eller Gitea. +- Arbejd med live opdateringer via SSE. +- Dokumenter de tekniske valg undervejs. +- Beskriv hvordan teknologien fungerer i praksis. +- Vurder hvad der fungerede godt, og hvad der var udfordrende. +- Forklar hvordan løsningen kan bruges professionelt. + +## Leverancer +- Kort teknisk dokumentation. +- Kildekode med projekter, tasks og issue-overblik. +- En evaluering af styrker og begrænsninger. diff --git a/drizzle/0001_kind_jamie_braddock.sql b/drizzle/0001_kind_jamie_braddock.sql new file mode 100644 index 0000000..84bedb3 --- /dev/null +++ b/drizzle/0001_kind_jamie_braddock.sql @@ -0,0 +1,33 @@ +CREATE TABLE "issue" ( + "id" serial PRIMARY KEY NOT NULL, + "project_id" integer NOT NULL, + "provider" text NOT NULL, + "external_id" text NOT NULL, + "url" text NOT NULL, + "title" text NOT NULL, + "state" text NOT NULL, + "labels" text DEFAULT '[]' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "project" ( + "id" serial PRIMARY KEY NOT NULL, + "owner_id" text NOT NULL, + "name" text NOT NULL, + "description" text DEFAULT '' NOT NULL, + "status" text DEFAULT 'planning' NOT NULL, + "progress" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "task" ADD COLUMN "project_id" integer NOT NULL;--> statement-breakpoint +ALTER TABLE "task" ADD COLUMN "description" text DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE "task" ADD COLUMN "status" text DEFAULT 'todo' NOT NULL;--> statement-breakpoint +ALTER TABLE "task" ADD COLUMN "due_date" timestamp;--> statement-breakpoint +ALTER TABLE "task" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "task" ADD COLUMN "updated_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "issue" ADD CONSTRAINT "issue_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "project" ADD CONSTRAINT "project_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "task" ADD CONSTRAINT "task_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0002_eager_shen.sql b/drizzle/0002_eager_shen.sql new file mode 100644 index 0000000..f8d3cd8 --- /dev/null +++ b/drizzle/0002_eager_shen.sql @@ -0,0 +1,28 @@ +CREATE TABLE "github_installation" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "installation_id" text NOT NULL, + "account_login" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "github_installation_installation_id_unique" UNIQUE("installation_id") +); +--> statement-breakpoint +CREATE TABLE "github_repository_link" ( + "id" serial PRIMARY KEY NOT NULL, + "project_id" integer NOT NULL, + "installation_id" integer NOT NULL, + "owner" text NOT NULL, + "repo" text NOT NULL, + "full_name" text NOT NULL, + "default_branch" text DEFAULT 'main' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "github_repository_link_full_name_unique" UNIQUE("full_name") +); +--> statement-breakpoint +ALTER TABLE "github_installation" ADD CONSTRAINT "github_installation_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "github_repository_link" ADD CONSTRAINT "github_repository_link_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "github_repository_link" ADD CONSTRAINT "github_repository_link_installation_id_github_installation_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."github_installation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "github_installation_installation_id_idx" ON "github_installation" USING btree ("installation_id");--> statement-breakpoint +CREATE UNIQUE INDEX "github_repository_link_project_id_idx" ON "github_repository_link" USING btree ("project_id"); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..6ea4bb3 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,630 @@ +{ + "id": "c6ca10a5-c791-4bd8-b5ab-a9e4aeac4f6b", + "prevId": "9b42afc7-ca3d-4f87-b3d1-3ec8549767c5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_owner_id_user_id_fk": { + "name": "project_owner_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task": { + "name": "task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'todo'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "task_project_id_project_id_fk": { + "name": "task_project_id_project_id_fk", + "tableFrom": "task", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..15d8b70 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,840 @@ +{ + "id": "924d9707-48ad-4091-82d5-4322719e0ef0", + "prevId": "c6ca10a5-c791-4bd8-b5ab-a9e4aeac4f6b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.github_installation": { + "name": "github_installation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installation_installation_id_idx": { + "name": "github_installation_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installation_user_id_user_id_fk": { + "name": "github_installation_user_id_user_id_fk", + "tableFrom": "github_installation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installation_installation_id_unique": { + "name": "github_installation_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repository_link": { + "name": "github_repository_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repository_link_project_id_idx": { + "name": "github_repository_link_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repository_link_project_id_project_id_fk": { + "name": "github_repository_link_project_id_project_id_fk", + "tableFrom": "github_repository_link", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_repository_link_installation_id_github_installation_id_fk": { + "name": "github_repository_link_installation_id_github_installation_id_fk", + "tableFrom": "github_repository_link", + "tableTo": "github_installation", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repository_link_full_name_unique": { + "name": "github_repository_link_full_name_unique", + "nullsNotDistinct": false, + "columns": [ + "full_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_owner_id_user_id_fk": { + "name": "project_owner_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task": { + "name": "task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'todo'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "task_project_id_project_id_fk": { + "name": "task_project_id_project_id_fk", + "tableFrom": "task", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8e0ae86..0298c62 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1779102031969, "tag": "0000_sturdy_harrier", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1779806328935, + "tag": "0001_kind_jamie_braddock", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1779807906067, + "tag": "0002_eager_shen", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 22b2f71..c58e3b0 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -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, diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 5d1a8e0..eb5897b 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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'; diff --git a/src/lib/server/events.ts b/src/lib/server/events.ts new file mode 100644 index 0000000..898d64f --- /dev/null +++ b/src/lib/server/events.ts @@ -0,0 +1,16 @@ +const listeners = new Set>(); + +export function subscribe(controller: ReadableStreamDefaultController) { + 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); + } + } +} diff --git a/src/lib/server/github-app.ts b/src/lib/server/github-app.ts new file mode 100644 index 0000000..4a7852e --- /dev/null +++ b/src/lib/server/github-app.ts @@ -0,0 +1,172 @@ +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`); + } +} + +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; + }>; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..207d9ef --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,72 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { project, task, issue } from '$lib/server/db/schema'; +import { desc, eq, sql } from 'drizzle-orm'; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.user) { + throw redirect(302, '/auth/login'); + } + + const projects = await db + .select({ + id: project.id, + name: project.name, + description: project.description, + status: project.status, + taskCount: sql`( + select count(*) from ${task} where ${task.projectId} = ${project.id} + )`.mapWith(Number), + doneCount: sql`( + select count(*) from ${task} + where ${task.projectId} = ${project.id} and ${task.status} = 'done' + )`.mapWith(Number), + issueCount: sql`( + select count(*) from ${issue} where ${issue.projectId} = ${project.id} + )`.mapWith(Number) + }) + .from(project) + .where(eq(project.ownerId, event.locals.user.id)) + .orderBy(desc(project.updatedAt)); + + const normalizedProjects = projects.map((item) => ({ + ...item, + progress: item.taskCount ? Math.round((item.doneCount / item.taskCount) * 100) : 0 + })); + + const recentTasks = await db + .select({ + id: task.id, + title: task.title, + status: task.status, + priority: task.priority, + projectId: task.projectId + }) + .from(task) + .innerJoin(project, eq(task.projectId, project.id)) + .where(eq(project.ownerId, event.locals.user.id)) + .orderBy(desc(task.updatedAt)) + .limit(5); + + const recentIssues = await db + .select({ + id: issue.id, + title: issue.title, + state: issue.state, + provider: issue.provider, + projectId: issue.projectId + }) + .from(issue) + .innerJoin(project, eq(issue.projectId, project.id)) + .where(eq(project.ownerId, event.locals.user.id)) + .orderBy(desc(issue.updatedAt)) + .limit(5); + + return { + user: event.locals.user, + projects: normalizedProjects, + recentTasks, + recentIssues + }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cc88df0..0883a47 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,2 +1,104 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + + + Taskarr + + +
+
+
+

Taskarr

+

Projects, tasks, and synced issues in one place

+

A compact SvelteKit workspace for tracking progress, handling tasks, and keeping GitHub or Gitea issues close to the work.

+
+ +
+ +
+
+

Projects

+

{data.projects.length}

+
+
+

Recent tasks

+

{data.recentTasks.length}

+
+
+

Recent issues

+

{data.recentIssues.length}

+
+
+ +
+
+

Connect GitHub

+

Install the GitHub App to link repositories and sync issues into your projects.

+ Go to integrations +
+ +
+

Projects

+
+ {#each data.projects as project} +
+
+
+

{project.name}

+

{project.description}

+
+ {project.progress}% +
+
+
+
+

{project.taskCount} tasks · {project.issueCount} issues · {project.status}

+
+ {/each} +
+
+ +
+
+

Recent tasks

+
+ {#each data.recentTasks as task} +
+
+

{task.title}

+

Priority {task.priority} · {task.status}

+
+
+ {/each} +
+
+ +
+

Recent issues

+
+ {#each data.recentIssues as item} +
+

{item.title}

+

{item.provider} · {item.state}

+
+ {/each} +
+
+
+
+
diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts new file mode 100644 index 0000000..84824a3 --- /dev/null +++ b/src/routes/api/events/+server.ts @@ -0,0 +1,34 @@ +import { subscribe } from '$lib/server/events'; + +export const GET = () => { + let heartbeat: ReturnType | undefined; + let stop: (() => void) | undefined; + + const stream = new ReadableStream({ + start(controller) { + stop = subscribe(controller); + heartbeat = setInterval(() => { + try { + controller.enqueue(': ping\n\n'); + } catch { + if (heartbeat) clearInterval(heartbeat); + stop?.(); + } + }, 25000); + + controller.enqueue(': connected\n\n'); + }, + cancel() { + if (heartbeat) clearInterval(heartbeat); + stop?.(); + } + }); + + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + 'connection': 'keep-alive' + } + }); +}; diff --git a/src/routes/api/github/install/callback/+server.ts b/src/routes/api/github/install/callback/+server.ts new file mode 100644 index 0000000..e0d5f60 --- /dev/null +++ b/src/routes/api/github/install/callback/+server.ts @@ -0,0 +1,34 @@ +import { redirect, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { githubInstallation } from '$lib/server/db/schema'; +import { decodeInstallState, getGitHubInstallation } from '$lib/server/github-app'; + +export const GET = async ({ url, locals }) => { + const installationId = url.searchParams.get('installation_id'); + const state = decodeInstallState(url.searchParams.get('state') ?? undefined); + + if (!installationId) throw error(400, 'Missing installation_id'); + if (!state.userId) throw error(400, 'Missing install state'); + if (!locals.user || locals.user.id !== state.userId) throw redirect(302, '/auth/login'); + + const installation = await getGitHubInstallation(installationId); + const accountLogin = installation.account?.login ?? 'github'; + + await db + .insert(githubInstallation) + .values({ + userId: locals.user.id, + installationId, + accountLogin + }) + .onConflictDoUpdate({ + target: githubInstallation.installationId, + set: { + userId: locals.user.id, + accountLogin, + updatedAt: new Date() + } + }); + + throw redirect(303, state.redirect); +}; diff --git a/src/routes/auth/+page.server.ts b/src/routes/auth/+page.server.ts index 013b7b4..a48ad4f 100644 --- a/src/routes/auth/+page.server.ts +++ b/src/routes/auth/+page.server.ts @@ -5,7 +5,7 @@ import { auth } from '$lib/server/auth'; export const load: PageServerLoad = (event) => { if (!event.locals.user) { - return redirect(302, '/auth/login'); + throw redirect(302, '/auth/login'); } return { user: event.locals.user }; }; @@ -15,6 +15,6 @@ export const actions: Actions = { await auth.api.signOut({ headers: event.request.headers }); - return redirect(302, '/auth/login'); + throw redirect(302, '/auth/login'); } }; diff --git a/src/routes/auth/login/+page.server.ts b/src/routes/auth/login/+page.server.ts index 35c7c7c..44ad85c 100644 --- a/src/routes/auth/login/+page.server.ts +++ b/src/routes/auth/login/+page.server.ts @@ -6,7 +6,7 @@ import { APIError } from 'better-auth/api'; export const load: PageServerLoad = (event) => { if (event.locals.user) { - return redirect(302, '/demo/auth'); + throw redirect(302, '/'); } return {}; }; @@ -32,7 +32,7 @@ export const actions: Actions = { return fail(500, { message: 'Unexpected error' }); } - return redirect(302, '/demo/auth'); + throw redirect(302, '/'); }, signUpEmail: async (event) => { const formData = await event.request.formData(); @@ -56,12 +56,12 @@ export const actions: Actions = { return fail(500, { message: 'Unexpected error' }); } - return redirect(302, '/demo/auth'); + throw redirect(302, '/'); }, signInSocial: async (event) => { const formData = await event.request.formData(); const provider = formData.get('provider')?.toString() ?? 'github'; - const callbackURL = formData.get('callbackURL')?.toString() ?? '/demo/auth'; + const callbackURL = formData.get('callbackURL')?.toString() ?? '/'; const result = await auth.api.signInSocial({ body: { @@ -71,7 +71,7 @@ export const actions: Actions = { }); if (result.url) { - return redirect(302, result.url); + throw redirect(302, result.url); } return fail(400, { message: 'Social sign-in failed' }); } diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte index 5007e84..1e178b1 100644 --- a/src/routes/auth/login/+page.svelte +++ b/src/routes/auth/login/+page.svelte @@ -45,7 +45,7 @@
- + diff --git a/src/routes/integrations/+page.server.ts b/src/routes/integrations/+page.server.ts new file mode 100644 index 0000000..37e7491 --- /dev/null +++ b/src/routes/integrations/+page.server.ts @@ -0,0 +1,53 @@ +import { redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { githubInstallation, githubRepositoryLink, project } from '$lib/server/db/schema'; +import { desc, eq } from 'drizzle-orm'; +import { getGitHubInstallUrl, listGitHubInstallationRepositories } from '$lib/server/github-app'; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + + const installations = await db + .select() + .from(githubInstallation) + .where(eq(githubInstallation.userId, event.locals.user.id)) + .orderBy(desc(githubInstallation.updatedAt)); + + const linkedRepos = await db + .select({ + id: githubRepositoryLink.id, + fullName: githubRepositoryLink.fullName, + owner: githubRepositoryLink.owner, + repo: githubRepositoryLink.repo, + projectName: project.name + }) + .from(githubRepositoryLink) + .innerJoin(project, eq(githubRepositoryLink.projectId, project.id)) + .where(eq(project.ownerId, event.locals.user.id)) + .orderBy(desc(githubRepositoryLink.updatedAt)); + + const installUrl = getGitHubInstallUrl({ redirect: '/integrations', userId: event.locals.user.id }); + + const repositoryCatalog = await Promise.all( + installations.map(async (installation) => ({ + installationId: installation.id, + accountLogin: installation.accountLogin, + repositories: await listGitHubInstallationRepositories(installation.installationId) + })) + ); + + return { + installUrl, + installations, + linkedRepos, + repositoryCatalog + }; +}; + +export const actions: Actions = { + startInstall: async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + throw redirect(303, getGitHubInstallUrl({ redirect: '/integrations', userId: event.locals.user.id })); + } +}; diff --git a/src/routes/integrations/+page.svelte b/src/routes/integrations/+page.svelte new file mode 100644 index 0000000..8b94646 --- /dev/null +++ b/src/routes/integrations/+page.svelte @@ -0,0 +1,64 @@ + + + + Integrations · Taskarr + + +
+
+
+

GitHub App integration

+

Install the app on GitHub, then link repositories to projects.

+
+ Dashboard +
+ +
+
+

Install app

+

Install the GitHub App to choose the repositories it can access.

+
+ Install GitHub App + + + +
+
+ +
+

Installed accounts

+
+ {#if data.installations.length} + {#each data.installations as installation} +
+

{installation.accountLogin}

+

Installation ID {installation.installationId}

+
+ {/each} + {:else} +

No installations yet.

+ {/if} +
+
+
+ +
+

Linked repositories

+
+ {#if data.linkedRepos.length} + {#each data.linkedRepos as repo} +
+

{repo.fullName}

+

Linked to {repo.projectName}

+
+ {/each} + {:else} +

No linked repositories yet.

+ {/if} +
+
+
diff --git a/src/routes/projects/+page.server.ts b/src/routes/projects/+page.server.ts new file mode 100644 index 0000000..ca7ce97 --- /dev/null +++ b/src/routes/projects/+page.server.ts @@ -0,0 +1,39 @@ +import { redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { project } from '$lib/server/db/schema'; +import { desc, eq } from 'drizzle-orm'; +import { publish } from '$lib/server/events'; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + + const projects = await db + .select() + .from(project) + .where(eq(project.ownerId, event.locals.user.id)) + .orderBy(desc(project.updatedAt)); + + return { projects }; +}; + +export const actions: Actions = { + create: async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + const formData = await event.request.formData(); + const name = formData.get('name')?.toString().trim() ?? ''; + const description = formData.get('description')?.toString().trim() ?? ''; + + if (!name) return { error: 'Project name is required' }; + + await db.insert(project).values({ + ownerId: event.locals.user.id, + name, + description + }); + + publish('project-created'); + + throw redirect(303, '/projects'); + } +}; diff --git a/src/routes/projects/+page.svelte b/src/routes/projects/+page.svelte new file mode 100644 index 0000000..50dad5a --- /dev/null +++ b/src/routes/projects/+page.svelte @@ -0,0 +1,38 @@ + + + + Projects · Taskarr + + +
+
+
+

Projects

+

Create and track the work that matters.

+
+ Dashboard +
+ +
+ + + {#if form?.error} +

{form.error}

+ {/if} + +
+ +
+ {#each data.projects as project} + +

{project.name}

+

{project.description}

+

{project.status}

+
+ {/each} +
+
diff --git a/src/routes/projects/[id]/+page.server.ts b/src/routes/projects/[id]/+page.server.ts new file mode 100644 index 0000000..8c8386f --- /dev/null +++ b/src/routes/projects/[id]/+page.server.ts @@ -0,0 +1,148 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { githubRepositoryLink, issue, project, task } from '$lib/server/db/schema'; +import { desc, eq, and, ne } from 'drizzle-orm'; +import { publish } from '$lib/server/events'; +import { listGitHubRepositoryIssues } from '$lib/server/github-app'; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + + const projectId = Number(event.params.id); + if (!Number.isFinite(projectId)) throw error(404, 'Project not found'); + + const [record] = await db + .select() + .from(project) + .where(and(eq(project.id, projectId), eq(project.ownerId, event.locals.user.id))) + .limit(1); + + if (!record) throw error(404, 'Project not found'); + + const tasks = await db + .select() + .from(task) + .where(eq(task.projectId, projectId)) + .orderBy(desc(task.updatedAt)); + + const completedTasks = tasks.filter((item) => item.status === 'done').length; + const progress = tasks.length ? Math.round((completedTasks / tasks.length) * 100) : 0; + + if (record.progress !== progress) { + await db.update(project).set({ progress }).where(eq(project.id, projectId)); + } + + const issues = await db + .select() + .from(issue) + .where(eq(issue.projectId, projectId)) + .orderBy(desc(issue.updatedAt)); + + const repositoryLinks = await db + .select() + .from(githubRepositoryLink) + .where(eq(githubRepositoryLink.projectId, projectId)); + + return { project: record, tasks, issues, repositoryLinks }; +}; + +export const actions: Actions = { + createTask: async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + const projectId = Number(event.params.id); + if (!Number.isFinite(projectId)) throw error(404, 'Project not found'); + const formData = await event.request.formData(); + const title = formData.get('title')?.toString().trim() ?? ''; + const description = formData.get('description')?.toString().trim() ?? ''; + + if (!title) return { error: 'Task title is required' }; + + const [record] = await db + .select() + .from(project) + .where(and(eq(project.id, projectId), eq(project.ownerId, event.locals.user.id))) + .limit(1); + + if (!record) throw error(404, 'Project not found'); + + await db.insert(task).values({ + projectId, + title, + description + }); + + await db.update(project).set({ updatedAt: new Date() }).where(eq(project.id, projectId)); + publish(`task-created:${projectId}`); + + throw redirect(303, `/projects/${projectId}`); + }, + updateTaskStatus: async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + const projectId = Number(event.params.id); + if (!Number.isFinite(projectId)) throw error(404, 'Project not found'); + const formData = await event.request.formData(); + const taskId = Number(formData.get('taskId')); + const status = formData.get('status')?.toString(); + + if (!Number.isFinite(taskId) || !status) throw error(400, 'Invalid task update'); + + await db + .update(task) + .set({ status: status as 'todo' | 'doing' | 'blocked' | 'done' }) + .where(and(eq(task.id, taskId), eq(task.projectId, projectId))); + + await db.update(project).set({ updatedAt: new Date() }).where(eq(project.id, projectId)); + publish(`task-updated:${projectId}`); + + throw redirect(303, `/projects/${projectId}`); + }, + syncIssues: async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + const projectId = Number(event.params.id); + if (!Number.isFinite(projectId)) throw error(404, 'Project not found'); + + const [record] = await db + .select() + .from(project) + .where(and(eq(project.id, projectId), eq(project.ownerId, event.locals.user.id))) + .limit(1); + + if (!record) throw error(404, 'Project not found'); + + const [linkedRepository] = await db + .select() + .from(githubRepositoryLink) + .where(eq(githubRepositoryLink.projectId, projectId)) + .limit(1); + + if (!linkedRepository) throw error(400, 'Link a repository first'); + + const githubIssues = await listGitHubRepositoryIssues({ + installationId: String(linkedRepository.installationId), + owner: linkedRepository.owner, + repo: linkedRepository.repo + }); + + await db.delete(issue).where(and(eq(issue.projectId, projectId), eq(issue.provider, 'github'))); + + for (const item of githubIssues.filter((entry) => !entry.pull_request)) { + await db + .insert(issue) + .values({ + projectId, + provider: 'github', + externalId: String(item.id), + url: item.html_url, + title: item.title, + state: item.state, + labels: JSON.stringify(item.labels.map((label) => label.name)) + }) + ; + } + + publish(`issues-synced:${projectId}`); + + throw redirect(303, `/projects/${projectId}`); + } +}; diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte new file mode 100644 index 0000000..8cda014 --- /dev/null +++ b/src/routes/projects/[id]/+page.svelte @@ -0,0 +1,94 @@ + + + + {data.project.name} · Taskarr + + +
+
+
+

{data.project.name}

+

{data.project.description}

+
+
+ Link repo + Back +
+
+ +
+
+

Tasks

+
+ + + {#if form?.error} +

{form.error}

+ {/if} + +
+ +
+ {#each data.tasks as item} +
+

{item.title}

+

{item.status} · priority {item.priority}

+
+ + + +
+
+ {/each} +
+
+ +
+
+

Issues

+
+ +
+
+ +
+ {#each data.issues as item} +
+

{item.title}

+

{item.provider} · {item.state}

+
+ {/each} +
+
+
+ +
+

Linked repositories

+
+ {#each data.repositoryLinks as repo} +
+

{repo.fullName}

+

{repo.owner}/{repo.repo}

+
+ {/each} +
+
+
diff --git a/src/routes/projects/[id]/link-repository/+page.server.ts b/src/routes/projects/[id]/link-repository/+page.server.ts new file mode 100644 index 0000000..07106c2 --- /dev/null +++ b/src/routes/projects/[id]/link-repository/+page.server.ts @@ -0,0 +1,89 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { githubInstallation, githubRepositoryLink, project } from '$lib/server/db/schema'; +import { and, eq } from 'drizzle-orm'; +import { listGitHubInstallationRepositories } from '$lib/server/github-app'; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + const projectId = Number(event.params.id); + if (!Number.isFinite(projectId)) throw error(404, 'Project not found'); + + const [record] = await db + .select() + .from(project) + .where(and(eq(project.id, projectId), eq(project.ownerId, event.locals.user.id))) + .limit(1); + + if (!record) throw error(404, 'Project not found'); + + const installations = await db + .select() + .from(githubInstallation) + .where(eq(githubInstallation.userId, event.locals.user.id)); + + const repositoryCatalog = await Promise.all( + installations.map(async (installation) => ({ + installationId: installation.id, + accountLogin: installation.accountLogin, + repositories: await listGitHubInstallationRepositories(installation.installationId) + })) + ); + + return { project: record, installations, repositoryCatalog }; +}; + +export const actions: Actions = { + link: async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + const projectId = Number(event.params.id); + const formData = await event.request.formData(); + const repository = formData.get('repository')?.toString() ?? ''; + + if (!repository) { + return { error: 'Choose a repository to link' }; + } + + const parsed = JSON.parse(repository) as { + installationId: number; + owner: string; + repo: string; + fullName: string; + defaultBranch: string; + }; + + if (!parsed.installationId || !parsed.fullName) { + return { error: 'Choose a valid repository' }; + } + + const [projectRecord] = await db + .select() + .from(project) + .where(and(eq(project.id, projectId), eq(project.ownerId, event.locals.user.id))) + .limit(1); + + if (!projectRecord) throw error(404, 'Project not found'); + + const [installation] = await db + .select() + .from(githubInstallation) + .where(and(eq(githubInstallation.id, parsed.installationId), eq(githubInstallation.userId, event.locals.user.id))) + .limit(1); + + if (!installation) throw error(404, 'Installation not found'); + + await db + .insert(githubRepositoryLink) + .values({ + projectId, + installationId: installation.id, + owner: parsed.owner, + repo: parsed.repo, + fullName: parsed.fullName, + defaultBranch: parsed.defaultBranch || 'main' + }); + + throw redirect(303, `/projects/${projectId}`); + } +}; diff --git a/src/routes/projects/[id]/link-repository/+page.svelte b/src/routes/projects/[id]/link-repository/+page.svelte new file mode 100644 index 0000000..1c3e75d --- /dev/null +++ b/src/routes/projects/[id]/link-repository/+page.svelte @@ -0,0 +1,44 @@ + + + + Link repository · Taskarr + + +
+
+

Link a repository

+

Attach a GitHub repository to {data.project.name} using an installed app.

+ +
+ +

Repositories shown here come from the installed GitHub App.

+ {#if form?.error} +

{form.error}

+ {/if} + +
+
+