diff --git a/.env.example b/.env.example index c31bc64..293c919 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,10 @@ 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="http://localhost:5173/api/github/install/callback" +# PEM private key (single quoted line, \n for line breaks) +# GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 2bf5a63..a11b3ef 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -54,6 +54,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} build-args: | DATABASE_URL="posgres://no:account@nowhere/db" + ORIGIN="https://taskarr.milasholsting.dk" cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Containerfile b/Containerfile index 520b681..cea6c40 100644 --- a/Containerfile +++ b/Containerfile @@ -14,6 +14,9 @@ WORKDIR /app ARG DATABASE_URL ENV DATABASE_URL=$DATABASE_URL +ARG ORIGIN +ENV ORIGIN=$ORIGIN + COPY --from=deps /app/node_modules ./node_modules COPY . . diff --git a/docs/evaluation.md b/docs/evaluation.md new file mode 100644 index 0000000..5f5784f --- /dev/null +++ b/docs/evaluation.md @@ -0,0 +1,23 @@ +# Evaluering + +## Hvad fungerede godt +- SvelteKit gjorde det hurtigt at samle UI og backend i samme repo. +- Login og beskyttede routes gav et klart brugerflow. +- Projekter og tasks kan udvides uden at ændre arkitekturen meget. +- GitHub App-installationsflowet (redirect → callback → repo selection → project link) er en komplet brugerrejse. +- Callback'et håndterer manglende private key graceful uden at crashe. + +## Hvad var udfordrende +- GitHub App kræver en PEM-private key, som skal formateres korrekt i `.env` (enkelt linje, `\n` for line breaks). +- Flere komponenter (callback, repo listing, issue sync) crashede uden private key — krævede `hasGitHubAppPrivateKey()` gates. +- `githubRepositoryLink.installationId` forvekslede intern DB-id med GitHub's `installationId`, hvilket gav 404 ved issue-sync. +- Rigtig GitHub-sync kræver provider-specifik auth og API-håndtering (JWT, access tokens, installation tokens). +- Realtidsopdateringer skal afstemmes med deploy-miljø og connection limits. +- Progressberegning skal være konsistent, når tasks ændres samtidigt. + +## Hvordan kan SvelteKit bruges professionelt +- Som ramme til interne værktøjer og dashboards, hvor SSR og form actions reducerer klient-kompleksitet. +- Til applikationer med behov for hurtig første render og god SEO gennem SSR. +- Til realtime-funktionalitet med SSE, hvor tunge websocket-løsninger ikke er nødvendige. +- Sammen med Drizzle ORM til typesikker databaseadgang i full-stack TypeScript-projekter. +- Til prototyper og MVP'er der skal kunne skaleres til produktion uden at skifte framework. diff --git a/docs/technical-decisions.md b/docs/technical-decisions.md new file mode 100644 index 0000000..b57c741 --- /dev/null +++ b/docs/technical-decisions.md @@ -0,0 +1,47 @@ +# Tekniske Valg + +## Hvad jeg byggede +En SvelteKit-baseret produktivitetsapp med login, projekter, tasks, GitHub App installation, repository linking, issue-sync og live opdateringskanal (SSE). + +## 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 via Better Auth (GitHub OAuth). +- Dashboardet henter projekter, tasks og issues server-side. +- Brugeren kan oprette projekter og tasks via form actions. +- Projekter har en GitHub App-installationsknap, der sender brugeren til GitHub. +- GitHub App installeres på en konto, og der vælges repositorier på GitHub's side. +- Efter installation redirectes brugeren til en setup-side i appen, hvor de vælger et projekt og krydser repos fra. +- De valgte repos gemmes som `githubRepositoryLink`-rækker. +- På projektets detailside kan brugeren synce issues fra det linkede repo via GitHub App's installation access token. +- SSE-endpoint (`/api/events`) sender notifikationer om task-ændringer og issue-sync, så klienten reloader data. + +## GitHub App flow i detaljer +1. `getGitHubInstallUrl()` genererer en URL med en HMAC-signeret state (indeholder `userId` og `redirect`). +2. GitHub sender brugeren tilbage til `GITHUB_APP_SETUP_URL` med `installation_id` og `state`. +3. Callback-validering: state afkodes og verficeres, `installation_id` gemmes i `githubInstallation`. +4. Hvis `GITHUB_APP_PRIVATE_KEY` er sat, hentes account-login fra GitHub API. Ellers bruges en fallback. +5. Brugeren redirectes til `/install/setup?installation={id}` for at vælge repos og projekt. +6. Setup-siden bruger `listGitHubInstallationRepositories()` til at vise repos. +7. Form action opretter `githubRepositoryLink`-rækker og redirecter til projektet. + +## Hvad der virkede godt +- SvelteKit gjorde dataflowet kort og tydeligt (load → form action → redirect). +- Server actions holdt mutationerne samlede og eliminerede behov for klient-state. +- Drizzle passede godt til schema-drevet udvikling med relations og foreign keys. +- GitHub App JWT-auth var overskuelig med `node:crypto` og RS256-signering. +- HMAC-signeret state gav en sikker callback-verifikation uden session storage. + +## Hvad der var udfordrende +- GitHub App kræver en PEM-private key, som skal formateres som en enkelt linje med `\n` i `.env`. +- Private key skal være til stede før JWT-signerede API-kald kan fungere. +- `githubRepositoryLink.installationId` er en foreign key til den interne `githubInstallation.id`, ikke GitHub's `installationId` — skal joins for at få den rigtige værdi. +- SSE er nemt til énvejskommunikation, men websocket kan blive nødvendig senere. +- Progress bør helst beregnes robust i databasen eller via en service. + +## Professionel brug +Løsningen kan bruges som intern project tracker, support-overblik eller developer dashboard med integrerede GitHub issues og statusopdateringer. diff --git a/docs/weekly-plan.md b/docs/weekly-plan.md new file mode 100644 index 0000000..8e9d06f --- /dev/null +++ b/docs/weekly-plan.md @@ -0,0 +1,18 @@ +# 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 GitHub App-integration med installationsflow. +- Implementer repository selection ved installation. +- Byg issue-sync fra GitHub repos. +- Arbejd med live opdateringer via SSE. +- Dokumenter de tekniske valg undervejs. + +## Leverancer +- Kort teknisk dokumentation. +- Kildekode med projekter, tasks, GitHub-installationer 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..ca43f4e --- /dev/null +++ b/src/lib/server/github-app.ts @@ -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; + }>; +} 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..f74f637 --- /dev/null +++ b/src/routes/api/github/install/callback/+server.ts @@ -0,0 +1,40 @@ +import { redirect, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { githubInstallation } from '$lib/server/db/schema'; +import { + decodeInstallState, + getGitHubInstallation, + hasGitHubAppPrivateKey +} 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 accountLogin = hasGitHubAppPrivateKey() + ? (await getGitHubInstallation(installationId)).account?.login ?? 'github' + : 'github'; + + const [record] = await db + .insert(githubInstallation) + .values({ + userId: locals.user.id, + installationId, + accountLogin + }) + .onConflictDoUpdate({ + target: githubInstallation.installationId, + set: { + userId: locals.user.id, + accountLogin, + updatedAt: new Date() + } + }) + .returning({ id: githubInstallation.id }); + + throw redirect(303, `/install/setup?installation=${record.id}`); +}; 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/install/setup/+page.server.ts b/src/routes/install/setup/+page.server.ts new file mode 100644 index 0000000..66138a8 --- /dev/null +++ b/src/routes/install/setup/+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, hasGitHubAppPrivateKey } from '$lib/server/github-app'; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + + const installationId = Number(event.url.searchParams.get('installation')); + if (!Number.isFinite(installationId)) throw error(400, 'Missing installation'); + + const [installation] = await db + .select() + .from(githubInstallation) + .where(and(eq(githubInstallation.id, installationId), eq(githubInstallation.userId, event.locals.user.id))) + .limit(1); + + if (!installation) throw error(404, 'Installation not found'); + + const projects = await db + .select() + .from(project) + .where(eq(project.ownerId, event.locals.user.id)) + .orderBy(project.name); + + const repositories = hasGitHubAppPrivateKey() + ? await listGitHubInstallationRepositories(installation.installationId) + : []; + + return { installation, projects, repositories }; +}; + +export const actions: Actions = { + link: async (event) => { + if (!event.locals.user) throw redirect(302, '/auth/login'); + if (!hasGitHubAppPrivateKey()) throw error(400, 'GitHub App private key is not configured'); + + const installationId = Number(event.url.searchParams.get('installation')); + if (!Number.isFinite(installationId)) throw error(400, 'Missing installation'); + + const formData = await event.request.formData(); + const projectId = Number(formData.get('projectId')); + const selectedRepos = formData.getAll('repos') as string[]; + + if (!Number.isFinite(projectId)) return { error: 'Choose a project' }; + if (selectedRepos.length === 0) return { error: 'Select at least one repository' }; + + const [installation] = await db + .select() + .from(githubInstallation) + .where(and(eq(githubInstallation.id, installationId), eq(githubInstallation.userId, event.locals.user.id))) + .limit(1); + + if (!installation) throw error(404, 'Installation not found'); + + 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'); + + for (const repoJson of selectedRepos) { + const repo = JSON.parse(repoJson) as { + owner: string; + repo: string; + fullName: string; + defaultBranch: string; + }; + + await db + .insert(githubRepositoryLink) + .values({ + projectId, + installationId: installation.id, + owner: repo.owner, + repo: repo.repo, + fullName: repo.fullName, + defaultBranch: repo.defaultBranch || 'main' + }) + .onConflictDoNothing({ target: githubRepositoryLink.fullName }); + } + + throw redirect(303, `/projects/${projectId}`); + } +}; diff --git a/src/routes/install/setup/+page.svelte b/src/routes/install/setup/+page.svelte new file mode 100644 index 0000000..9fd8d5b --- /dev/null +++ b/src/routes/install/setup/+page.svelte @@ -0,0 +1,71 @@ + + + + Set up installation · Taskarr + + +
+
+

GitHub App installed

+

+ Installation for {data.installation.accountLogin} is ready. Choose which repositories to link and which project to add them to. +

+ + {#if data.repositories.length === 0} +

+ No repositories found. Install the app on an account that has repositories, or add a private key in .env. +

+ Back to integrations + {:else} + +
+ + +
+ +
+ Repositories +
+ {#each data.repositories as repo} + + {/each} +
+
+ + {#if form?.error} +

{form.error}

+ {/if} + +
+ + Skip +
+ + {/if} +
+
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..1e7571c --- /dev/null +++ b/src/routes/projects/[id]/+page.server.ts @@ -0,0 +1,152 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { githubInstallation, 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({ + link: githubRepositoryLink, + githubInstallationId: githubInstallation.installationId + }) + .from(githubRepositoryLink) + .innerJoin(githubInstallation, eq(githubRepositoryLink.installationId, githubInstallation.id)) + .where(eq(githubRepositoryLink.projectId, projectId)) + .limit(1); + + if (!linkedRepository) throw error(400, 'Link a repository first'); + + const githubIssues = await listGitHubRepositoryIssues({ + installationId: linkedRepository.githubInstallationId, + owner: linkedRepository.link.owner, + repo: linkedRepository.link.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} + +
+
+
diff --git a/taskarr.pem b/taskarr.pem new file mode 100644 index 0000000..c26b7a7 --- /dev/null +++ b/taskarr.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAl+cyWUQ1WTrEFnkuBRfWbRbG5DRa9ZbyIyHDkMCQF4NEZ7WS +6mZiGzx/ulkVG/fOxzV0dNC+iiyESvOOmrchj1HwbjQee2OHf1TsUeXtck5Q69Wv +IQhKocZsmptbz4WXvPk4W3iF4d6D9DZpt9rc9pu/1URBwFucPebc0ZKUDQOAADxx +PyOBgjUHeET2h7xJBcE0JAVKnu5YDJeak+fmYPz/j8JD+/t4lEeQby0hn6naJ+Hu +G60lgMildf2/Q8AQkZMiEH6uaLXsbEVY7tsiEEs3jLKW7QucD+0afM51bAJtzOat +Rf3z+aXMgGyqhqHeRLAPexk3wJKbeKxxni66FwIDAQABAoIBAQCR2oVGny6WWc3U +QlDExSm3n0oj7n10GJaw0wejorH4UatJ5VeGx/3lZwbBmC2jqCKctp/2VkTOCYaR +LSE9Px/zLFsuhc7K7Ts6MQGkdaCzw60BdTDuB7cZdJvtK8VRElYrNiU99xCeWvja +cbC6v2SxScU9coeruorgCR73/8U2ZC4expBtObjKFah5liMdREQfotbhb/JkbEKR +3xFkO1xHwsJtnGGHD8Dz9E9GY6GOCn4yL/7HfDkX5GSFoV1WuNOzKYjn43jh4MRl +venrTveuE8eurRoALhl2lvinIh/QSp1LF0jtCiZMGvGvEaXDONw4l6BeDQGrp993 +mARbwShRAoGBAMpNnuLTh66T7xKS3xEOzoEd0ktZ1aHtdvee48nMU9Udtw1unm9R +tGqpefZkWyjSVnToUv0F3Ffv5J/xT6MEu7iW+A3G2oW38xtw/NcEvDkWf4+JGtsG +OW+fjyNxD77g348ZS5JvoN5+gGqpqv8DNkeW5RiNB4LG+1LkrSmYlwO/AoGBAMA4 +66Dh+gLQd7/K9M43hMdl2j2hj8/LmhUjQGt4z5AXuhvNemOuXniM1asFJkYCglyq +aLV4yn+9oHWMZuvBwZhLEeMJRtCcxXF6dlQ9aNqUBPQyjYpWaVZd/DFeBGfc2j76 +FMS+oiosypjSFbpZw2xla4ltaBSEpe21carSvv+pAoGBAJf2eIxwUvJrg2FTXCaH +Fc3dZdNeNuB87SmSfA9g4fQrbw6y8mYyXLDmf4v61JM0dOc6gOQ5m8uekwEmFikh +bBV7qfdHUWyywfXyCKtHjk/fu5BJcBfodAqKM85upmI+rw/h82ebXJ7lLXdx3dXT +Rtm92nd3sJWm/LDGv+CIE6AnAoGAH5y805aWYUQbjYU5QXjL82cCv0QEvK3FR1im +zYXuzMm6J7xhNmeEzdqLgTa1lqnu+oJj+gRFeUCcVPikLH2O8xbVeDscVE6UAheb +wjrfNaNGNUrjEC8p37RRN2U44EPn+Jd0Nu8LCFiZcQyL2jvM8dS0HMPLbCRsjW1b +pjZGhxECgYBUmlVGqx3rYW0Tozyz7MQW5dHOjs12ufJtfkQ2vE2su0KNQuBAyzCM +miHHmMcUEx3S0X0JY7ZWThuWKW9tbn6UnwwIBc+v5ghfAKL8cBUSvLyu8ipDghIR +LfB41PGV9F1WsoupnazPWFqyvJX7xYUZ95/ahCbw4utU9hIwPz5O1w== +-----END RSA PRIVATE KEY-----