Compare commits

...

13 Commits

Author SHA1 Message Date
Gitea Action
29fbb6c0d0 chore(deploy): update image to latest 2026-05-26 16:09:26 +00:00
921560d14d fix
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 5m6s
2026-05-26 18:04:15 +02:00
8e02f673ca work
Some checks failed
Build and Push Container Image / build-and-push (push) Failing after 3m49s
2026-05-26 17:44:22 +02:00
6bd3b76782 fix db_url 2026-05-18 13:40:33 +02:00
Gitea Action
80947f7f37 chore(deploy): update image to latest 2026-05-18 11:17:41 +00:00
7f0652bb6f add husky pre-commit
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 5m40s
2026-05-18 13:04:25 +02:00
dbb44715ab add migrations :P
Some checks failed
Build and Push Container Image / build-and-push (push) Has been cancelled
2026-05-18 13:00:51 +02:00
ee385585d8 fix migration job referencing old secret 2026-05-18 12:54:44 +02:00
13d0e7ee38 fix deployment.yaml in prod 2026-05-18 12:49:14 +02:00
Gitea Action
b7da567342 chore(deploy): update image to latest 2026-05-18 10:42:51 +00:00
8db977c6f0 fix not modifying the correct images
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 2m37s
2026-05-18 12:40:20 +02:00
Gitea Action
e8f6e0c6d3 chore(deploy): update image to latest 2026-05-18 10:31:30 +00:00
c434f4f86a whoops wrong branch
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 2m32s
2026-05-18 12:29:02 +02:00
44 changed files with 3512 additions and 43 deletions

View File

@@ -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-----"

View File

@@ -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
@@ -94,9 +95,9 @@ jobs:
# 2. Update the manifest
cd deploy/overlays/production
kustomize edit set image election=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$IMAGE_TAG
kustomize edit set image main=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$IMAGE_TAG
kustomize edit set image election-migration=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-migrator:$IMAGE_TAG
kustomize edit set image migrator=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-migrator:$IMAGE_TAG
- name: Commit and Push Change
run: |
# 1. Set identity to fix the "Author identity unknown" error
@@ -111,5 +112,5 @@ jobs:
echo "No changes to commit"
else
git commit -m "chore(deploy): update image to ${{ steps.meta.outputs.version }}"
git push origin master
git push origin main
fi

2
.husky/pre-commit Normal file
View File

@@ -0,0 +1,2 @@
bun check
bun db:generate

View File

@@ -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 . .

View File

@@ -27,6 +27,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.0",
"globals": "^17.4.0",
"husky": "^9.1.7",
"mdsvex": "^0.12.7",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
@@ -601,6 +602,8 @@
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],

View File

@@ -15,4 +15,4 @@ spec:
templates:
DATABASE_URL:
text: |
{{- printf "postgresql://%s:%s@postgres-service.taskarr.svc.cluster.local:5432/taskarr_db?sslmode=disable" (get .Secrets "username") (get .Secrets "password") -}}
{{- printf "postgresql://%s:%s@postgres-service.taskarr.svc.cluster.local:5432/taskarr?sslmode=disable" (get .Secrets "username") (get .Secrets "password") -}}

View File

@@ -60,16 +60,31 @@ spec:
secretKeyRef:
name: taskarr-app
key: BETTER_AUTH_SECRET
- name: GITEA_CLIENT_SECRET
- name: GITHUB_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: taskarr-app
key: GITEA_CLIENT_SECRET
- name: GITEA_CLIENT_ID
key: GITHUB_CLIENT_SECRET
- name: GITHUB_CLIENT_ID
valueFrom:
secretKeyRef:
name: taskarr-app
key: GITEA_CLIENT_ID
key: GITHUB_CLIENT_ID
- name: GITHUB_APP_ID
valueFrom:
secretKeyRef:
name: taskarr-app
key: GITHUB_APP_ID
- name: GITHUB_APP_SLUG
valueFrom:
secretKeyRef:
name: taskarr-app
key: GITHUB_APP_SLUG
- name: GITHUB_APP_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: taskarr-app
key: GITHUB_APP_PRIVATE_KEY
restartPolicy: Always
---

View File

@@ -28,7 +28,7 @@ spec:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: election
name: taskarr-db-url
key: DATABASE_URL
restartPolicy: OnFailure

View File

@@ -7,7 +7,7 @@ spec:
template:
spec:
containers:
- name: main # This name must match base EXACTLY
- name: taskarr # This name must match base EXACTLY
ports: # Adding this back into the patch solves the diff
- containerPort: 3000
name: taskarr

View File

@@ -18,7 +18,7 @@ namespace: taskarr
images:
- name: main
newName: reg.milasholsting.dk/taskarr/taskarr
newTag: latest
newTag: sha-921560d
- name: migrator
newName: reg.milasholsting.dk/taskarr/migrator
newTag: latest
newName: reg.milasholsting.dk/taskarr/taskarr-migrator
newTag: sha-921560d

23
docs/evaluation.md Normal file
View File

@@ -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.

View File

@@ -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.

18
docs/weekly-plan.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,59 @@
CREATE TABLE "task" (
"id" serial PRIMARY KEY NOT NULL,
"title" text NOT NULL,
"priority" integer DEFAULT 1 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");

View File

@@ -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;

View File

@@ -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");

View File

@@ -0,0 +1,406 @@
{
"id": "9b42afc7-ca3d-4f87-b3d1-3ec8549767c5",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.task": {
"name": "task",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"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
}
]
}

View File

@@ -20,6 +20,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.0",
"globals": "^17.4.0",
"husky": "^9.1.7",
"mdsvex": "^0.12.7",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
@@ -36,7 +37,7 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"prepare": "husky",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",

View File

@@ -5,18 +5,35 @@ import { env } from '$env/dynamic/private';
import { getRequestEvent } from '$app/server';
import { db } from '$lib/server/db';
export const auth = betterAuth({
baseURL: env.ORIGIN,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET
}
},
plugins: [
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
]
function createAuth() {
const origin = env.ORIGIN;
if (!origin) throw new Error('ORIGIN is not set');
const secret = env.BETTER_AUTH_SECRET;
if (!secret) throw new Error('BETTER_AUTH_SECRET is not set');
const clientId = env.GITHUB_CLIENT_ID;
if (!clientId) throw new Error('GITHUB_CLIENT_ID is not set');
const clientSecret = env.GITHUB_CLIENT_SECRET;
if (!clientSecret) throw new Error('GITHUB_CLIENT_SECRET is not set');
return betterAuth({
baseURL: origin,
secret,
database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: { enabled: true },
socialProviders: {
github: { clientId, clientSecret }
},
plugins: [
sveltekitCookies(getRequestEvent)
]
});
}
let _auth: ReturnType<typeof betterAuth>;
export const auth = new Proxy({} as ReturnType<typeof betterAuth>, {
get(target, prop) {
if (!_auth) _auth = createAuth();
return Reflect.get(_auth, prop, target);
}
});

View File

@@ -2,7 +2,4 @@ import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from './schema';
import { env } from '$env/dynamic/private';
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export const db = drizzle(env.DATABASE_URL, { schema });

View File

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

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

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

View File

@@ -0,0 +1,157 @@
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;
};
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 }) {
if (!env.GITHUB_APP_SLUG) throw new Error('GITHUB_APP_SLUG is not set');
if (!env.BETTER_AUTH_SECRET) throw new Error('BETTER_AUTH_SECRET is not set');
const redirect = input.redirect ?? '/integrations';
const base = new URL(`https://github.com/apps/${env.GITHUB_APP_SLUG}/installations/new`);
base.searchParams.set('state', signState({ redirect, userId: input.userId }, env.BETTER_AUTH_SECRET));
return base.toString();
}
export function decodeInstallState(state?: string) {
if (!state) return { redirect: '/integrations' };
if (!env.BETTER_AUTH_SECRET) throw new Error('BETTER_AUTH_SECRET is not set');
const parsed = verifyState(state, env.BETTER_AUTH_SECRET);
if (!parsed) return { redirect: '/integrations' };
return { redirect: parsed.redirect || '/integrations', userId: parsed.userId };
}
function createGitHubAppJwt() {
if (!env.GITHUB_APP_ID) throw new Error('GITHUB_APP_ID is not set');
if (!env.GITHUB_APP_PRIVATE_KEY) throw new Error('GITHUB_APP_PRIVATE_KEY is not set');
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: env.GITHUB_APP_ID });
const signer = createSign('RSA-SHA256');
signer.update(`${header}.${payload}`);
signer.end();
const key = createPrivateKey(normalizePrivateKey(env.GITHUB_APP_PRIVATE_KEY));
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;
}>;
}

View File

@@ -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<number>`(
select count(*) from ${task} where ${task.projectId} = ${project.id}
)`.mapWith(Number),
doneCount: sql<number>`(
select count(*) from ${task}
where ${task.projectId} = ${project.id} and ${task.status} = 'done'
)`.mapWith(Number),
issueCount: sql<number>`(
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
};
};

View File

@@ -1,2 +1,104 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
import { onMount } from 'svelte';
import type { PageServerData } from './$types';
let { data }: { data: PageServerData } = $props();
onMount(() => {
const events = new EventSource('/api/events');
events.onmessage = () => {
window.location.reload();
};
return () => events.close();
});
</script>
<svelte:head>
<title>Taskarr</title>
</svelte:head>
<main class="mx-auto flex min-h-screen max-w-6xl flex-col gap-8 px-6 py-10">
<header class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="flex flex-col gap-2">
<p class="text-sm uppercase tracking-[0.3em] text-slate-500">Taskarr</p>
<h1 class="text-4xl font-bold text-slate-900">Projects, tasks, and synced issues in one place</h1>
<p class="max-w-2xl text-slate-600">A compact SvelteKit workspace for tracking progress, handling tasks, and keeping GitHub or Gitea issues close to the work.</p>
</div>
<div class="flex gap-3">
<a class="rounded-md border border-slate-300 px-4 py-2 text-slate-900" href="/projects">Projects</a>
<a class="rounded-md bg-blue-600 px-4 py-2 text-white" href="/integrations">Install GitHub App</a>
</div>
</header>
<section class="grid gap-4 md:grid-cols-3">
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-sm text-slate-500">Projects</p>
<p class="mt-2 text-3xl font-semibold">{data.projects.length}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-sm text-slate-500">Recent tasks</p>
<p class="mt-2 text-3xl font-semibold">{data.recentTasks.length}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-sm text-slate-500">Recent issues</p>
<p class="mt-2 text-3xl font-semibold">{data.recentIssues.length}</p>
</div>
</section>
<section class="grid gap-6 lg:grid-cols-2">
<div class="rounded-2xl border border-blue-200 bg-blue-50 p-5 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">Connect GitHub</h2>
<p class="mt-2 text-sm text-slate-600">Install the GitHub App to link repositories and sync issues into your projects.</p>
<a class="mt-4 inline-flex rounded-md bg-slate-900 px-4 py-2 text-white" href="/integrations">Go to integrations</a>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold">Projects</h2>
<div class="mt-4 space-y-3">
{#each data.projects as project}
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="font-medium text-slate-900">{project.name}</p>
<p class="text-sm text-slate-500">{project.description}</p>
</div>
<span class="text-sm text-slate-500">{project.progress}%</span>
</div>
<div class="mt-3 h-2 rounded-full bg-slate-200">
<div class="h-2 rounded-full bg-blue-600" style={`width: ${project.progress}%`}></div>
</div>
<p class="mt-2 text-xs text-slate-500">{project.taskCount} tasks · {project.issueCount} issues · {project.status}</p>
</div>
{/each}
</div>
</div>
<div class="grid gap-6">
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold">Recent tasks</h2>
<div class="mt-4 space-y-3">
{#each data.recentTasks as task}
<div class="flex items-center justify-between rounded-xl bg-slate-50 p-4">
<div>
<p class="font-medium text-slate-900">{task.title}</p>
<p class="text-sm text-slate-500">Priority {task.priority} · {task.status}</p>
</div>
</div>
{/each}
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold">Recent issues</h2>
<div class="mt-4 space-y-3">
{#each data.recentIssues as item}
<div class="rounded-xl bg-slate-50 p-4">
<p class="font-medium text-slate-900">{item.title}</p>
<p class="text-sm text-slate-500">{item.provider} · {item.state}</p>
</div>
{/each}
</div>
</div>
</div>
</section>
</main>

View File

@@ -0,0 +1,34 @@
import { subscribe } from '$lib/server/events';
export const GET = () => {
let heartbeat: ReturnType<typeof setInterval> | 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'
}
});
};

View File

@@ -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}`);
};

View File

@@ -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');
}
};

View File

@@ -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' });
}

View File

@@ -45,7 +45,7 @@
<form method="post" action="?/signInSocial" use:enhance>
<input type="hidden" name="provider" value="github" />
<input type="hidden" name="callbackURL" value="/demo/better-auth" />
<input type="hidden" name="callbackURL" value="/" />
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
>Sign in with GitHub</button
>

View File

@@ -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}`);
}
};

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import type { ActionData, PageServerData } from './$types';
let { data, form }: { data: PageServerData; form: ActionData } = $props();
</script>
<svelte:head>
<title>Set up installation · Taskarr</title>
</svelte:head>
<main class="mx-auto min-h-screen max-w-3xl px-6 py-10">
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h1 class="text-3xl font-bold text-slate-900">GitHub App installed</h1>
<p class="mt-2 text-slate-600">
Installation for <strong>{data.installation.accountLogin}</strong> is ready. Choose which repositories to link and which project to add them to.
</p>
{#if data.repositories.length === 0}
<p class="mt-6 text-sm text-slate-500">
No repositories found. Install the app on an account that has repositories, or add a private key in <code>.env</code>.
</p>
<a class="mt-4 inline-block rounded-md bg-slate-900 px-4 py-2 text-white" href="/integrations">Back to integrations</a>
{:else}
<form method="post" action="?/link" class="mt-6 grid gap-6">
<div>
<label class="text-sm font-medium text-slate-700" for="project">Project</label>
<select name="projectId" id="project" class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2">
<option value="">Choose a project</option>
{#each data.projects as item}
<option value={item.id}>{item.name}</option>
{/each}
</select>
</div>
<fieldset>
<legend class="text-sm font-medium text-slate-700">Repositories</legend>
<div class="mt-2 space-y-2">
{#each data.repositories as repo}
<label class="flex items-center gap-3 rounded-xl border border-slate-200 p-3 hover:bg-slate-50">
<input
type="checkbox"
name="repos"
value={JSON.stringify({
owner: repo.owner.login,
repo: repo.name,
fullName: repo.full_name,
defaultBranch: repo.default_branch
})}
class="h-4 w-4 rounded border-slate-300"
/>
<div>
<p class="font-medium text-slate-900">{repo.full_name}</p>
<p class="text-sm text-slate-500">{repo.default_branch}</p>
</div>
</label>
{/each}
</div>
</fieldset>
{#if form?.error}
<p class="text-sm text-red-600">{form.error}</p>
{/if}
<div class="flex gap-3">
<button class="rounded-md bg-blue-600 px-4 py-2 text-white">Add to project</button>
<a class="rounded-md border border-slate-300 px-4 py-2" href="/integrations">Skip</a>
</div>
</form>
{/if}
</div>
</main>

View File

@@ -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 }));
}
};

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import type { ActionData, PageServerData } from './$types';
let { data, form }: { data: PageServerData; form: ActionData } = $props();
</script>
<svelte:head>
<title>Integrations · Taskarr</title>
</svelte:head>
<main class="mx-auto min-h-screen max-w-5xl px-6 py-10">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-slate-900">GitHub App integration</h1>
<p class="text-slate-600">Install the app on GitHub, then link repositories to projects.</p>
</div>
<a class="rounded-md bg-slate-900 px-4 py-2 text-white" href="/">Dashboard</a>
</div>
<section class="mt-8 grid gap-6 lg:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold">Install app</h2>
<p class="mt-2 text-sm text-slate-600">Install the GitHub App to choose the repositories it can access.</p>
<div class="mt-4 flex gap-3">
<a class="rounded-md bg-blue-600 px-4 py-2 text-white" href={data.installUrl}>Install GitHub App</a>
<form method="post" action="?/startInstall">
<button class="rounded-md border border-slate-300 px-4 py-2">Open install flow</button>
</form>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold">Installed accounts</h2>
<div class="mt-4 space-y-3">
{#if data.installations.length}
{#each data.installations as installation}
<div class="rounded-xl bg-slate-50 p-4">
<p class="font-medium text-slate-900">{installation.accountLogin}</p>
<p class="text-sm text-slate-500">Installation ID {installation.installationId}</p>
</div>
{/each}
{:else}
<p class="text-sm text-slate-500">No installations yet.</p>
{/if}
</div>
</div>
</section>
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold">Linked repositories</h2>
<div class="mt-4 space-y-3">
{#if data.linkedRepos.length}
{#each data.linkedRepos as repo}
<div class="rounded-xl bg-slate-50 p-4">
<p class="font-medium text-slate-900">{repo.fullName}</p>
<p class="text-sm text-slate-500">Linked to {repo.projectName}</p>
</div>
{/each}
{:else}
<p class="text-sm text-slate-500">No linked repositories yet.</p>
{/if}
</div>
</section>
</main>

View File

@@ -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');
}
};

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import type { ActionData, PageServerData } from './$types';
let { data, form }: { data: PageServerData; form: ActionData } = $props();
</script>
<svelte:head>
<title>Projects · Taskarr</title>
</svelte:head>
<main class="mx-auto min-h-screen max-w-5xl px-6 py-10">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-slate-900">Projects</h1>
<p class="text-slate-600">Create and track the work that matters.</p>
</div>
<a class="rounded-md bg-slate-900 px-4 py-2 text-white" href="/">Dashboard</a>
</div>
<form method="post" action="?/create" class="mt-8 grid gap-3 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<input name="name" placeholder="Project name" class="rounded-md border border-slate-300 px-3 py-2" />
<textarea name="description" placeholder="Short description" class="rounded-md border border-slate-300 px-3 py-2"></textarea>
{#if form?.error}
<p class="text-sm text-red-600">{form.error}</p>
{/if}
<button class="w-fit rounded-md bg-blue-600 px-4 py-2 text-white">Create project</button>
</form>
<div class="mt-8 grid gap-4 md:grid-cols-2">
{#each data.projects as project}
<a href={`/projects/${project.id}`} class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-blue-300">
<p class="text-lg font-semibold text-slate-900">{project.name}</p>
<p class="mt-1 text-sm text-slate-500">{project.description}</p>
<p class="mt-3 text-xs uppercase tracking-[0.2em] text-slate-400">{project.status}</p>
</a>
{/each}
</div>
</main>

View File

@@ -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}`);
}
};

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ActionData, PageServerData } from './$types';
let { data, form }: { data: PageServerData; form: ActionData } = $props();
onMount(() => {
const events = new EventSource('/api/events');
events.onmessage = () => {
window.location.reload();
};
return () => events.close();
});
</script>
<svelte:head>
<title>{data.project.name} · Taskarr</title>
</svelte:head>
<main class="mx-auto min-h-screen max-w-5xl px-6 py-10">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-slate-900">{data.project.name}</h1>
<p class="text-slate-600">{data.project.description}</p>
</div>
<div class="flex gap-2">
<a class="rounded-md border border-slate-300 px-4 py-2" href={`/projects/${data.project.id}/link-repository`}>Link repo</a>
<a class="rounded-md bg-slate-900 px-4 py-2 text-white" href="/projects">Back</a>
</div>
</div>
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold">Tasks</h2>
<form method="post" action="?/createTask" class="mt-4 grid gap-3">
<input name="title" placeholder="Task title" class="rounded-md border border-slate-300 px-3 py-2" />
<textarea name="description" placeholder="Task description" class="rounded-md border border-slate-300 px-3 py-2"></textarea>
{#if form?.error}
<p class="text-sm text-red-600">{form.error}</p>
{/if}
<button class="w-fit rounded-md bg-blue-600 px-4 py-2 text-white">Add task</button>
</form>
<div class="mt-6 space-y-3">
{#each data.tasks as item}
<div class="rounded-xl bg-slate-50 p-4">
<p class="font-medium text-slate-900">{item.title}</p>
<p class="text-sm text-slate-500">{item.status} · priority {item.priority}</p>
<form method="post" action="?/updateTaskStatus" class="mt-3 flex gap-2">
<input type="hidden" name="taskId" value={item.id} />
<select name="status" class="rounded-md border border-slate-300 px-2 py-1 text-sm">
<option value="todo">Todo</option>
<option value="doing">Doing</option>
<option value="blocked">Blocked</option>
<option value="done">Done</option>
</select>
<button class="rounded-md bg-slate-900 px-3 py-1 text-sm text-white">Update</button>
</form>
</div>
{/each}
</div>
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Issues</h2>
<form method="post" action="?/syncIssues">
<button class="rounded-md bg-slate-900 px-4 py-2 text-white">Sync GitHub issues</button>
</form>
</div>
<div class="mt-6 space-y-3">
{#each data.issues as item}
<div class="rounded-xl bg-slate-50 p-4">
<p class="font-medium text-slate-900">{item.title}</p>
<p class="text-sm text-slate-500">{item.provider} · {item.state}</p>
</div>
{/each}
</div>
</section>
</div>
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold">Linked repositories</h2>
<div class="mt-4 space-y-3">
{#each data.repositoryLinks as repo}
<div class="rounded-xl bg-slate-50 p-4">
<p class="font-medium text-slate-900">{repo.fullName}</p>
<p class="text-sm text-slate-500">{repo.owner}/{repo.repo}</p>
</div>
{/each}
</div>
</section>
</main>

View File

@@ -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}`);
}
};

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import type { ActionData, PageServerData } from './$types';
let { data, form }: { data: PageServerData; form: ActionData } = $props();
</script>
<svelte:head>
<title>Link repository · Taskarr</title>
</svelte:head>
<main class="mx-auto min-h-screen max-w-3xl px-6 py-10">
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h1 class="text-3xl font-bold text-slate-900">Link a repository</h1>
<p class="mt-2 text-slate-600">Attach a GitHub repository to {data.project.name} using an installed app.</p>
<form method="post" action="?/link" class="mt-6 grid gap-3">
<select name="repository" class="rounded-md border border-slate-300 px-3 py-2">
<option value="">Choose a repository</option>
{#each data.repositoryCatalog as installation}
<optgroup label={installation.accountLogin}>
{#each installation.repositories as repo}
<option
value={JSON.stringify({
installationId: installation.installationId,
owner: repo.owner.login,
repo: repo.name,
fullName: repo.full_name,
defaultBranch: repo.default_branch
})}
>
{repo.full_name}
</option>
{/each}
</optgroup>
{/each}
</select>
<p class="text-sm text-slate-500">Repositories shown here come from the installed GitHub App.</p>
{#if form?.error}
<p class="text-sm text-red-600">{form.error}</p>
{/if}
<button class="w-fit rounded-md bg-blue-600 px-4 py-2 text-white">Link repository</button>
</form>
</div>
</main>

27
taskarr.pem Normal file
View File

@@ -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-----