From e2b17d2ec1b423a5add6de1fd53399dc4e2bcb72 Mon Sep 17 00:00:00 2001 From: Derek Worthen Date: Tue, 28 Nov 2023 14:39:43 -0800 Subject: [PATCH] Improve cache layer - Cache user, communities, and settings --- .changeset/thin-deers-speak.md | 7 + .vscode/launch.json | 1 + migrations/0002_marvelous_zemo.sql | 32 ++ migrations/meta/0002_snapshot.json | 370 +++++++++++++++ migrations/meta/_journal.json | 7 + package.json | 11 +- src/electron/configs.ts | 8 +- src/electron/db/migrate.ts | 71 ++- src/electron/db/schema.ts | 53 ++- src/electron/lib/paths.ts | 2 +- src/electron/lib/stdout.ts | 3 +- src/electron/main.ts | 54 ++- src/electron/services/AppUpdaterService.ts | 1 - src/electron/services/BallotService.ts | 265 +++++++---- src/electron/services/CacheService.ts | 26 ++ src/electron/services/CommunityService.ts | 127 +++++ src/electron/services/ConfigService.ts | 438 ------------------ src/electron/services/GitService.ts | 14 +- src/electron/services/Gov4GitService.ts | 42 +- src/electron/services/SettingsService.ts | 130 ++++++ src/electron/services/UserService.ts | 342 ++++++++++++-- src/electron/services/index.ts | 4 +- src/renderer/src/components/DataLoader.tsx | 100 ++-- src/renderer/src/components/IssueBallot.tsx | 16 +- src/renderer/src/components/Issues.tsx | 10 +- src/renderer/src/components/PullRequests.tsx | 10 +- src/renderer/src/components/RequireAuth.tsx | 15 +- src/renderer/src/components/SettingsForm.tsx | 87 ++-- src/renderer/src/components/SiteNav.tsx | 4 +- src/renderer/src/components/UserBadge.tsx | 2 +- src/renderer/src/hooks/useCatchError.ts | 4 +- src/renderer/src/services/CacheService.ts | 7 + src/renderer/src/services/CommunityService.ts | 8 + src/renderer/src/services/ConfigService.ts | 7 - src/renderer/src/services/SettingsService.ts | 7 + src/renderer/src/services/index.ts | 3 +- src/renderer/src/state/community.ts | 5 + src/renderer/src/state/settings.ts | 3 + src/shared/services/BallotService.ts | 11 +- src/shared/services/CacheService.ts | 3 + src/shared/services/CommunityService.ts | 14 + src/shared/services/ConfigService.ts | 36 -- src/shared/services/Service.ts | 3 + src/shared/services/SettingsService.ts | 26 ++ src/shared/services/UserService.ts | 14 +- src/shared/services/index.ts | 4 +- test/BallotService.test.ts | 94 ++++ test/CommunityService.test.ts | 61 +++ test/ConfigService.test.ts | 166 ------- test/GitService.test.ts | 116 +++-- test/SettingsService.test.ts | 74 +++ test/UserService.test.ts | 64 +++ test/config.ts | 29 ++ test/testRunner.test.ts | 95 ++++ 54 files changed, 2114 insertions(+), 992 deletions(-) create mode 100644 .changeset/thin-deers-speak.md create mode 100644 migrations/0002_marvelous_zemo.sql create mode 100644 migrations/meta/0002_snapshot.json create mode 100644 src/electron/services/CacheService.ts create mode 100644 src/electron/services/CommunityService.ts delete mode 100644 src/electron/services/ConfigService.ts create mode 100644 src/electron/services/SettingsService.ts create mode 100644 src/renderer/src/services/CacheService.ts create mode 100644 src/renderer/src/services/CommunityService.ts delete mode 100644 src/renderer/src/services/ConfigService.ts create mode 100644 src/renderer/src/services/SettingsService.ts create mode 100644 src/renderer/src/state/community.ts create mode 100644 src/renderer/src/state/settings.ts create mode 100644 src/shared/services/CacheService.ts create mode 100644 src/shared/services/CommunityService.ts delete mode 100644 src/shared/services/ConfigService.ts create mode 100644 src/shared/services/SettingsService.ts create mode 100644 test/BallotService.test.ts create mode 100644 test/CommunityService.test.ts delete mode 100644 test/ConfigService.test.ts create mode 100644 test/SettingsService.test.ts create mode 100644 test/UserService.test.ts create mode 100644 test/config.ts create mode 100644 test/testRunner.test.ts diff --git a/.changeset/thin-deers-speak.md b/.changeset/thin-deers-speak.md new file mode 100644 index 0000000..d4cc393 --- /dev/null +++ b/.changeset/thin-deers-speak.md @@ -0,0 +1,7 @@ +--- +'gov4git-desktop-app': minor +--- + +Improve cache layer + +- Cache user, communities, and settings diff --git a/.vscode/launch.json b/.vscode/launch.json index 5f487b3..0111629 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -29,6 +29,7 @@ "windows": { "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" }, + "envFile": "${workspaceFolder}/.env", "args": [ ".", "--remote-debugging-port=9222" diff --git a/migrations/0002_marvelous_zemo.sql b/migrations/0002_marvelous_zemo.sql new file mode 100644 index 0000000..d755889 --- /dev/null +++ b/migrations/0002_marvelous_zemo.sql @@ -0,0 +1,32 @@ +CREATE TABLE `communities` ( + `url` text PRIMARY KEY NOT NULL, + `branch` text NOT NULL, + `name` text NOT NULL, + `configPath` text NOT NULL, + `projectUrl` text NOT NULL, + `selected` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `userCommunities` ( + `id` integer PRIMARY KEY NOT NULL, + `userId` text NOT NULL, + `communityId` text NOT NULL, + `isMember` integer NOT NULL, + `isMaintainer` integer NOT NULL, + `votingCredits` real NOT NULL, + `votingScore` real NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `users`(`username`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`communityId`) REFERENCES `communities`(`url`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `users` ( + `username` text PRIMARY KEY NOT NULL, + `pat` text NOT NULL, + `memberPublicUrl` text NOT NULL, + `memberPublicBranch` text NOT NULL, + `memberPrivateUrl` text NOT NULL, + `memberPrivateBranch` text NOT NULL +); +--> statement-breakpoint +ALTER TABLE ballots ADD `status` text DEFAULT 'open' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `userCommunities_communityId_unique` ON `userCommunities` (`communityId`); \ No newline at end of file diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..5d33e2e --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,370 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "a4363b5b-6556-4e59-83b7-0aa26ceb6c9c", + "prevId": "fc43d5b6-3813-407d-9034-bba7ada9827b", + "tables": { + "ballots": { + "name": "ballots", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "communityUrl": { + "name": "communityUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "choices": { + "name": "choices", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "choice": { + "name": "choice", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "communities": { + "name": "communities", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configPath": { + "name": "configPath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectUrl": { + "name": "projectUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected": { + "name": "selected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "configStore": { + "name": "configStore", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "communityUrl": { + "name": "communityUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectUrl": { + "name": "projectUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "configs": { + "name": "configs", + "columns": { + "communityUrl": { + "name": "communityUrl", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectUrl": { + "name": "projectUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "userCommunities": { + "name": "userCommunities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "communityId": { + "name": "communityId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isMember": { + "name": "isMember", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isMaintainer": { + "name": "isMaintainer", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "votingCredits": { + "name": "votingCredits", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "votingScore": { + "name": "votingScore", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "userCommunities_communityId_unique": { + "name": "userCommunities_communityId_unique", + "columns": [ + "communityId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "userCommunities_userId_users_username_fk": { + "name": "userCommunities_userId_users_username_fk", + "tableFrom": "userCommunities", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "username" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "userCommunities_communityId_communities_url_fk": { + "name": "userCommunities_communityId_communities_url_fk", + "tableFrom": "userCommunities", + "tableTo": "communities", + "columnsFrom": [ + "communityId" + ], + "columnsTo": [ + "url" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "username": { + "name": "username", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pat": { + "name": "pat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memberPublicUrl": { + "name": "memberPublicUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memberPublicBranch": { + "name": "memberPublicBranch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memberPrivateUrl": { + "name": "memberPrivateUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memberPrivateBranch": { + "name": "memberPrivateBranch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 7b2c814..4dc60b8 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1697678597832, "tag": "0001_clear_puppet_master", "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1701096291441, + "tag": "0002_marvelous_zemo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 57307df..0dc6bab 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,18 @@ "main": "./build/electron/main.js", "scripts": { "clean": "shx rm -rf ./build", - "db:generate-migration": "drizzle-kit generate:sqlite", - "db:print-schema": "drizzle-kit introspect:sqlite", - "db:studio": "drizzle-kit studio --port 3000 --verbose", + "db:generate-migration": "dotenv -e .env -- drizzle-kit generate:sqlite", + "db:print-schema": "dotenv -e .env -- drizzle-kit introspect:sqlite", "format:check": "prettier --check \"./src/**/*.{js,jsx,ts,tsx}\"", "format": "prettier --write \"./src/**/*.{js,jsx,ts,tsx}\"", "lint:check": "eslint \"./src/**/*.{js,jsx,ts,tsx}\"", "lint": "eslint --fix \"./src/**/*.{js,jsx,ts,tsx}\"", - "test": "jest", + "test": "jest testRunner.test.ts", "build:webapp": "vite build --config ./vite.webapp.config.ts", "build:electron": "tsup --dts", "build": "conc npm:build:webapp npm:build:electron", - "dev:electron": "nodemon --watch ./build/electron -e js --exec \"electron .\"", - "dev:webapp": "vite dev --config ./vite.webapp.config.ts --port 6553", + "dev:electron": "dotenv -e .env -- nodemon --watch ./build/electron -e js --exec \"electron . --trace-warnings\"", + "dev:webapp": "dotenv -e .env -- vite dev --config ./vite.webapp.config.ts --port 6553", "dev": "conc \"npm:dev:webapp\" \"npm:build:electron -- --watch --env.PORT 6553\" npm:dev:electron", "preview": "electron .", "git:clean": "git-is-clean", diff --git a/src/electron/configs.ts b/src/electron/configs.ts index 6cbb852..a45bea4 100644 --- a/src/electron/configs.ts +++ b/src/electron/configs.ts @@ -5,7 +5,9 @@ import { toResolvedPath } from './lib/paths.js' export const CONFIG_PATH = toResolvedPath( process.env['GOV4GIT_CONFIG_PATH'] ?? '~/.gov4git', ) -export const DB_PATH = resolve( - process.env['GOV4GIT_DB_PATH'] ?? CONFIG_PATH, - 'gov4git.db', +export const DB_PATH = toResolvedPath( + process.env['GOV4GIT_DB_PATH'] ?? resolve(CONFIG_PATH, 'gov4git.db'), ) + +export const COMMUNITY_REPO_NAME = + process.env['GOV4GIT_COMMUNITY_REPO_NAME'] ?? 'gov4git-identity' diff --git a/src/electron/db/migrate.ts b/src/electron/db/migrate.ts index 9be0854..d65d4ff 100644 --- a/src/electron/db/migrate.ts +++ b/src/electron/db/migrate.ts @@ -1,8 +1,12 @@ +import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' -import { loadDb } from './db.js' +import { Config } from '~/shared' + +import { DB, loadDb } from './db.js' +import { communities, configStore, users } from './schema.js' export async function migrateDb(dbPath: string, isPackaged = false) { const migrationPath = resolve( isPackaged ? process.resourcesPath : process.cwd(), @@ -10,4 +14,69 @@ export async function migrateDb(dbPath: string, isPackaged = false) { ) const db = await loadDb(dbPath) migrate(db, { migrationsFolder: migrationPath }) + await migrateData(db) +} + +async function migrateData(db: DB) { + const user = (await db.select().from(users).limit(1))[0] + + if (user != null) { + return + } + + const confStore = (await db.select().from(configStore).limit(1))[0] + + if (confStore == null) { + return + } + + const configPath = confStore.path + + if (!existsSync(configPath)) { + return + } + + const config: Config = JSON.parse(readFileSync(configPath, 'utf-8')) + + if ( + config.user != null && + config.user.username != null && + config.user.username !== '' && + config.user.pat != null && + config.user.pat !== '' && + config.project_repo != null && + config.gov_public_url != null && + config.gov_public_branch != null && + config.project_repo !== '' && + config.gov_private_url !== '' && + config.gov_public_branch !== '' && + config.member_public_url != null && + config.member_public_url !== '' && + config.member_private_url != null && + config.member_private_url !== '' && + config.member_public_branch != null && + config.member_public_branch !== '' && + config.member_private_branch != null && + config.member_private_branch !== '' && + config.community_name != null && + config.community_name !== '' + ) { + await db.insert(users).values({ + username: config.user.username, + pat: config.user.pat, + memberPublicUrl: config.member_public_url, + memberPublicBranch: config.member_public_branch, + memberPrivateUrl: config.member_private_url, + memberPrivateBranch: config.member_private_branch, + }) + + await db.insert(communities).values({ + url: config.gov_public_url, + branch: config.gov_public_branch, + name: config.community_name, + configPath: configPath, + projectUrl: config.project_repo, + selected: true, + }) + } } diff --git a/src/electron/db/schema.ts b/src/electron/db/schema.ts index 71d1a22..3dd88fa 100644 --- a/src/electron/db/schema.ts +++ b/src/electron/db/schema.ts @@ -14,10 +14,13 @@ export const ballots = sqliteTable('ballots', { choice: text('choice').notNull(), score: real('score').notNull(), user: text('user', { mode: 'json' }).notNull().$type(), + status: text('status', { enum: ['open', 'closed'] }) + .notNull() + .default('open'), }) export type BallotDB = typeof ballots.$inferSelect -export type InsertBallotDB = typeof ballots.$inferInsert +export type BallotDBInsert = typeof ballots.$inferInsert export const configs = sqliteTable('configs', { communityUrl: text('communityUrl').notNull().primaryKey(), @@ -39,3 +42,51 @@ export const configStore = sqliteTable('configStore', { export type ConfigStoreDB = typeof configStore.$inferSelect export type InsertConfigStoreDB = typeof configStore.$inferInsert + +/** + * Version 2 DB + */ + +export const communities = sqliteTable('communities', { + url: text('url').primaryKey(), + branch: text('branch').notNull(), + name: text('name').notNull(), + configPath: text('configPath').notNull(), + projectUrl: text('projectUrl').notNull(), + // isMember: integer('isMember', { mode: 'boolean' }).notNull(), + // isMaintainer: integer('isMaintainer', { mode: 'boolean' }).notNull(), + selected: integer('selected', { mode: 'boolean' }).notNull(), +}) + +export type Community = typeof communities.$inferSelect +export type CommunityInsert = typeof communities.$inferInsert + +export const users = sqliteTable('users', { + username: text('username').primaryKey(), + pat: text('pat').notNull(), + memberPublicUrl: text('memberPublicUrl').notNull(), + memberPublicBranch: text('memberPublicBranch').notNull(), + memberPrivateUrl: text('memberPrivateUrl').notNull(), + memberPrivateBranch: text('memberPrivateBranch').notNull(), +}) + +export type User = typeof users.$inferSelect +export type UserInsert = typeof users.$inferInsert + +export const userCommunities = sqliteTable('userCommunities', { + id: integer('id', { mode: 'number' }).primaryKey(), + userId: text('userId') + .notNull() + .references(() => users.username), + communityId: text('communityId') + .notNull() + .unique() + .references(() => communities.url), + isMember: integer('isMember', { mode: 'boolean' }).notNull(), + isMaintainer: integer('isMaintainer', { mode: 'boolean' }).notNull(), + votingCredits: real('votingCredits').notNull(), + votingScore: real('votingScore').notNull(), +}) + +export type UserCommunity = typeof userCommunities.$inferSelect +export type UserCommunityInsert = typeof userCommunities.$inferInsert diff --git a/src/electron/lib/paths.ts b/src/electron/lib/paths.ts index 0d2db77..1fb4d2a 100644 --- a/src/electron/lib/paths.ts +++ b/src/electron/lib/paths.ts @@ -3,7 +3,7 @@ import { homedir } from 'node:os' import { resolve } from 'node:path' export function toResolvedPath(path: string): string { - return resolve(path.replace('~', homedir())) + return resolve(path.replace(/^~/, homedir())) } export async function hashString(value: string): Promise { diff --git a/src/electron/lib/stdout.ts b/src/electron/lib/stdout.ts index bcfe32c..dab4b34 100644 --- a/src/electron/lib/stdout.ts +++ b/src/electron/lib/stdout.ts @@ -1,6 +1,7 @@ export function parseStdout(cmd: string[], stdout: string): T { try { - return JSON.parse(stdout) as T + if (stdout.trim() === '') return '' as T + return JSON.parse(stdout ?? '') } catch (ex) { throw new Error(`Unable to parse stdout for command: ${cmd.join(' ')}`) } diff --git a/src/electron/main.ts b/src/electron/main.ts index 2dc3ace..59a663d 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -10,32 +10,38 @@ import { import type { InvokeServiceProps } from '~/shared' -import { CONFIG_PATH, DB_PATH } from './configs.js' +import { COMMUNITY_REPO_NAME, CONFIG_PATH, DB_PATH } from './configs.js' import { DB, loadDb } from './db/db.js' import { migrateDb } from './db/migrate.js' +import { BallotService } from './services/BallotService.js' +import { CacheService } from './services/CacheService.js' +import { CommunityService } from './services/CommunityService.js' import { Gov4GitService } from './services/Gov4GitService.js' import { AppUpdaterService, - BallotService, - ConfigService, GitService, LogService, Services, - UserService, } from './services/index.js' +import { SettingsService } from './services/SettingsService.js' +import { UserService } from './services/UserService.js' const port = process.env['PORT'] const services = new Services() const logService = new LogService(resolve(CONFIG_PATH, 'logs.txt')) +services.register('log', logService) logService.info(`Gov4Git Version ${logService.getAppVersion()}`) async function setup(): Promise { - services.register('log', logService) - - const db = await loadDb(DB_PATH) - services.register('db', db) + try { + logService.info(`Initializing DB: ${DB_PATH}`) + const db = await loadDb(DB_PATH) + services.register('db', db) + } catch (ex) { + logService.error(`Failed to load DB. ${ex}`) + } try { await migrateDb(DB_PATH, app.isPackaged) } catch (ex) { @@ -43,23 +49,39 @@ async function setup(): Promise { logService.error(ex) } - const gov4GitService = new Gov4GitService(services) - services.register('gov4git', gov4GitService) - const gitService = new GitService() services.register('git', gitService) - const configService = new ConfigService(services) - services.register('config', configService) + const gov4GitService = new Gov4GitService(services) + services.register('gov4git', gov4GitService) + + const userService = new UserService({ + services, + identityRepoName: COMMUNITY_REPO_NAME, + }) + services.register('user', userService) - const ballotService = new BallotService(services) + const ballotService = new BallotService({ + services, + }) services.register('ballots', ballotService) + const communityService = new CommunityService({ + services, + configDir: CONFIG_PATH, + }) + services.register('community', communityService) + + const settingsService = new SettingsService({ + services, + }) + services.register('settings', settingsService) + const appUpdaterService = new AppUpdaterService(services) services.register('appUpdater', appUpdaterService) - const userService = new UserService(services) - services.register('user', userService) + const cacheService = new CacheService({ services }) + services.register('cache', cacheService) } async function serviceHandler( diff --git a/src/electron/services/AppUpdaterService.ts b/src/electron/services/AppUpdaterService.ts index 4904d04..970ce89 100644 --- a/src/electron/services/AppUpdaterService.ts +++ b/src/electron/services/AppUpdaterService.ts @@ -81,7 +81,6 @@ export class AppUpdaterService extends AbstractAppUpdaterService { } public override restartAndUpdate = async (): Promise => { - await this.ballotService.clearCache() autoUpdater.quitAndInstall() } } diff --git a/src/electron/services/BallotService.ts b/src/electron/services/BallotService.ts index d1814d9..841abb9 100644 --- a/src/electron/services/BallotService.ts +++ b/src/electron/services/BallotService.ts @@ -1,17 +1,20 @@ -import { desc } from 'drizzle-orm' +import { and, asc, desc, eq, sql } from 'drizzle-orm' import { AbstractBallotService, Ballot, - BallotLabel, CreateBallotOptions, VoteOption, } from '~/shared' import { DB } from '../db/db.js' -import { ballots } from '../db/schema.js' -import { formatDecimal } from '../lib/numbers.js' -import { ConfigService } from './ConfigService.js' +import { + BallotDBInsert, + ballots, + communities, + userCommunities, + users, +} from '../db/schema.js' import { Gov4GitService } from './Gov4GitService.js' import { Services } from './Services.js' @@ -27,9 +30,12 @@ type RemoteBallot = { strategy: string participants_group: string parent_commit: string - label: BallotLabel + label: 'issues' | 'pull' | 'other' identifier: string url: string + frozen: boolean + closed: boolean + cancelled: boolean } type RemoteBallotInfo = { @@ -125,20 +131,23 @@ type RemoteBallotTrack = { }> | null } +export type BallotServiceOptions = { + services: Services +} + export class BallotService extends AbstractBallotService { - protected declare services: Services - protected declare db: DB - protected declare configService: ConfigService - protected declare gov4GitService: Gov4GitService + private declare readonly services: Services + private declare readonly db: DB + private declare readonly govService: Gov4GitService - constructor(services: Services) { + constructor({ services }: BallotServiceOptions) { super() this.services = services this.db = this.services.load('db') - this.configService = this.services.load('config') - this.gov4GitService = this.services.load('gov4git') + this.govService = this.services.load('gov4git') } - protected getBallotLabel = (path: string[]): BallotLabel => { + + private getBallotLabel = (path: string[]): 'issues' | 'pull' | 'other' => { if (path.length === 3 && path[1]?.toLowerCase() === 'issues') { return 'issues' } else if (path.length === 3 && path[1]?.toLowerCase() === 'pull') { @@ -147,24 +156,31 @@ export class BallotService extends AbstractBallotService { return 'other' } - protected getBallotIdentifier = (path: string[]): string => { + private getBallotId = (path: string[]): string => { return path.join('/') } - protected getBallotInfo = async (ballotPath: string, username: string) => { - const ballotInfoCommand = ['ballot', 'show', '--name', ballotPath] - const ballotInfo = await this.gov4GitService.mustRun( + private fetchBallotInfo = async ( + // communityUrl: string, + ballotId: string, + ): Promise => { + const ballotInfoCommand = ['ballot', 'show', '--name', ballotId] + const ballotInfo = await this.govService.mustRun( ...ballotInfoCommand, ) - const ballotTrackingCommand = ['ballot', 'track', '--name', ballotPath] + + return ballotInfo + } + + private fetchBallotTracking = async (tallyId: string) => { + const ballotTrackingCommand = ['ballot', 'track', '--name', tallyId] let ballotTracking: RemoteBallotTrack | null = null try { - ballotTracking = await this.gov4GitService.mustRun( + ballotTracking = await this.govService.mustRun( ...ballotTrackingCommand, ) if (ballotTracking != null && ballotTracking.pending_votes != null) { - const userAcceptedVotes = - ballotInfo.ballot_tally.ballot_accepted_votes[username] + const userAcceptedVotes = ballotTracking.accepted_votes if (userAcceptedVotes != null) { ballotTracking.pending_votes = ballotTracking.pending_votes.reduce< any[] @@ -182,31 +198,34 @@ export class BallotService extends AbstractBallotService { } catch (er) { // skip } - return { - ballotInfo, - ballotTracking, - } + return ballotTracking } - public getBallot = async (ballotId: string): Promise => { - const config = await this.configService.getConfig() - if (config == null) { - throw new Error( - `Unable to load ballot info for ${ballotId} as config is null.`, - ) - } - const b = await this.getBallotInfo(ballotId, config.user.username) + private loadBallot = async ( + username: string, + communityUrl: string, + ballotId: string, + ) => { + const [ballotInfo, ballotTracking] = await Promise.all([ + this.fetchBallotInfo(ballotId), + this.fetchBallotTracking(ballotId), + ]) + const ballot: Record = {} - ballot['communityUrl'] = config.gov_public_url - ballot['label'] = this.getBallotLabel( - b.ballotInfo.ballot_advertisement.path, + ballot['communityUrl'] = communityUrl + ballot['label'] = this.getBallotLabel(ballotInfo.ballot_advertisement.path) + ballot['identifier'] = this.getBallotId( + ballotInfo.ballot_advertisement.path, ) - ballot['identifier'] = this.getBallotIdentifier( - b.ballotInfo.ballot_advertisement.path, - ) - ballot['title'] = b.ballotInfo.ballot_advertisement.title - ballot['description'] = b.ballotInfo.ballot_advertisement.description - ballot['choices'] = b.ballotInfo.ballot_advertisement.choices + ballot['title'] = ballotInfo.ballot_advertisement.title + ballot['description'] = ballotInfo.ballot_advertisement.description + ballot['choices'] = ballotInfo.ballot_advertisement.choices + + const closed = + ballotInfo.ballot_advertisement.frozen || + ballotInfo.ballot_advertisement.closed || + ballotInfo.ballot_advertisement.cancelled + ballot['status'] = closed ? 'closed' : 'open' const choice = (ballot['choices'] as string[])[0] @@ -216,11 +235,10 @@ export class BallotService extends AbstractBallotService { ) } ballot['choice'] = choice - ballot['score'] = b.ballotInfo.ballot_tally.ballot_scores[choice] ?? 0 + ballot['score'] = ballotInfo.ballot_tally.ballot_scores[choice] ?? 0 - const talliedVotes = - b.ballotInfo.ballot_tally.ballot_votes_by_user[config.user.username] - const pendingVotes = b.ballotTracking?.pending_votes ?? [] + const talliedVotes = ballotInfo.ballot_tally.ballot_votes_by_user[username] + const pendingVotes = ballotTracking?.pending_votes ?? [] let talliedScore = 0 if (talliedVotes != null) { @@ -248,7 +266,7 @@ export class BallotService extends AbstractBallotService { await this.db .insert(ballots) - .values(ballot as Ballot) + .values(ballot as BallotDBInsert) .onConflictDoUpdate({ target: ballots.identifier, set: ballot, @@ -256,47 +274,121 @@ export class BallotService extends AbstractBallotService { return ballot as Ballot } - protected _getOpen = async () => { - const config = await this.configService.getConfig() - if (config == null) return [] - const username = config.user!.username! - const command = ['ballot', 'list', '--open', '--participant', username] - const remoteBallots = await this.gov4GitService.mustRun( + public getBallot = async (ballotId: string) => { + const [userRows, communityRows] = await Promise.all([ + this.db.select().from(users).limit(1), + this.db + .select() + .from(communities) + .where(eq(communities.selected, true)) + .limit(1), + ]) + + const user = userRows[0] + const community = communityRows[0] + + if (user == null || community == null) { + return null + } + + return this.loadBallot(user.username, community.url, ballotId) + } + + public loadBallots = async () => { + const [userRows, communityRows] = await Promise.all([ + this.db.select().from(users).limit(1), + this.db + .select() + .from(communities) + .where(eq(communities.selected, true)) + .limit(1), + ]) + + const user = userRows[0] + const community = communityRows[0] + + if (user == null || community == null) { + return + } + + const command = ['ballot', 'list', '--participant', user.username] + const remoteBallots = await this.govService.mustRun( ...command, ) - const ballotPromises = remoteBallots.map((b) => - this.getBallot(this.getBallotIdentifier(b.path)), - ) - return await Promise.all(ballotPromises).then((results) => { - return results.sort((a, b) => (a.score > b.score ? -1 : 1)) - }) - } + const ballotPromises = [] + for (const remoteBallot of remoteBallots) { + ballotPromises.push( + this.loadBallot( + user.username, + community.url, + this.getBallotId(remoteBallot.path), + ), + ) + } - public override clearCache = async (): Promise => { - await this.db.delete(ballots) + await Promise.all(ballotPromises) } - public override updateCache = async (): Promise => { - await this.clearCache() - return await this._getOpen() - } + public getBallots = async () => { + const selectedCommunity = ( + await this.db + .select() + .from(communities) + .where(eq(communities.selected, true)) + .limit(1) + )[0] - public getOpen = async () => { - const blts = await this.db - .select() + if (selectedCommunity == null) { + return [] + } + + const ballotCounts = await this.db + .select({ + count: sql`count(*)`, + }) .from(ballots) - .orderBy(desc(ballots.score)) - if (blts.length > 0) { - // void this._getOpen() - return blts + .where( + and( + eq(ballots.communityUrl, selectedCommunity.url), + eq(ballots.status, 'open'), + ), + ) + + if (ballotCounts.length === 0 || ballotCounts[0]?.count === 0) { + await this.loadBallots() } - return await this._getOpen() + + return await this.db + .select() + .from(ballots) + .where( + and( + eq(ballots.communityUrl, selectedCommunity.url), + eq(ballots.status, 'open'), + ), + ) + .orderBy(desc(ballots.score), asc(ballots.title)) } public vote = async ({ name, choice, strength }: VoteOption) => { - // throw new Error(`FAILED TO VOTE!`) - await this.gov4GitService.mustRun( + const userInfo = ( + await this.db + .select() + .from(communities) + .innerJoin( + userCommunities, + eq(communities.url, userCommunities.communityId), + ) + .where(eq(communities.selected, true)) + .limit(1) + )[0] + + if (userInfo == null) { + return + } + + await this.govService.mustRun( 'ballot', 'vote', '--name', @@ -306,10 +398,21 @@ export class BallotService extends AbstractBallotService { '--strengths', strength, ) + + const votingCredits = userInfo.userCommunities.votingCredits - +strength + const votingScore = Math.sqrt(Math.abs(votingCredits)) + + await this.db + .update(userCommunities) + .set({ + votingCredits, + votingScore, + }) + .where(eq(userCommunities.id, userInfo.userCommunities.id)) } public createBallot = async (options: CreateBallotOptions) => { - const name = `${options.type}/${options.title.replace(/\s/g, '')}` + const name = `github/${options.type}/${options.title.replace(/\s/g, '')}` const command = [ 'ballot', 'open', @@ -322,14 +425,14 @@ export class BallotService extends AbstractBallotService { '--group', 'everybody', '--choices', - 'Prioritize', + 'prioritize', '--use_credits', ] - await this.gov4GitService.mustRun(...command) + await this.govService.mustRun(...command) } public tallyBallot = async (ballotName: string) => { const command = ['ballot', 'tally', '--name', ballotName] - await this.gov4GitService.mustRun(...command) + await this.govService.mustRun(...command) } } diff --git a/src/electron/services/CacheService.ts b/src/electron/services/CacheService.ts new file mode 100644 index 0000000..aff3236 --- /dev/null +++ b/src/electron/services/CacheService.ts @@ -0,0 +1,26 @@ +import { BallotService } from './BallotService.js' +import { Services } from './Services.js' +import { UserService } from './UserService.js' + +export type CacheServiceOptions = { + services: Services +} + +export class CacheService { + private declare readonly services: Services + private declare readonly userService: UserService + private declare readonly ballotService: BallotService + + constructor({ services }: CacheServiceOptions) { + this.services = services + this.ballotService = this.services.load('ballots') + this.userService = this.services.load('user') + } + + public refreshCache = async () => { + await Promise.all([ + this.userService.loadUser(), + this.ballotService.loadBallots(), + ]) + } +} diff --git a/src/electron/services/CommunityService.ts b/src/electron/services/CommunityService.ts new file mode 100644 index 0000000..4516306 --- /dev/null +++ b/src/electron/services/CommunityService.ts @@ -0,0 +1,127 @@ +import { eq } from 'drizzle-orm' +import { resolve } from 'path' + +import { AbstractCommunityService } from '~/shared' + +import { DB } from '../db/db.js' +import { communities, Community, users } from '../db/schema.js' +import { hashString, toResolvedPath } from '../lib/paths.js' +import { GitService } from './GitService.js' +import { Services } from './Services.js' + +export type CommunityServiceOptions = { + services: Services + configDir?: string +} + +export class CommunityService extends AbstractCommunityService { + private declare readonly services: Services + private declare readonly configDir: string + private declare readonly db: DB + private declare readonly gitService: GitService + + constructor({ services, configDir = '~/.gov4git' }: CommunityServiceOptions) { + super() + this.services = services + this.configDir = toResolvedPath(configDir) + this.db = this.services.load('db') + this.gitService = this.services.load('git') + } + + public validateCommunityUrl = async ( + url: string, + ): Promise<[string | null, string[] | null]> => { + const user = (await this.db.select().from(users).limit(1))[0] + + if (user == null) { + return [null, ['User not set. Cannot validate community URL.']] + } + + const errors: string[] = [] + if (!(await this.gitService.doesRemoteRepoExist(url, user))) { + errors.push( + `Community url, ${url}, does not exist. Please enter a valid community URL.`, + ) + return [null, errors] + } + + const communityMainBranch = + (await this.gitService.getDefaultBranch(url, user)) ?? 'main' + + return [communityMainBranch, null] + } + + public getCommunity = async (): Promise => { + return ( + ( + await this.db + .select() + .from(communities) + .where(eq(communities.selected, true)) + )[0] ?? null + ) + } + + public selectCommunity = async (url: string) => { + const community = ( + await this.db.select().from(communities).where(eq(communities.url, url)) + )[0] + + if (community == null) { + throw new Error(`Invalid community url ${url} to select`) + } + + await this.db.update(communities).set({ selected: false }) + await this.db + .update(communities) + .set({ selected: true }) + .where(eq(communities.url, url)) + } + + public insertCommunity = async (projectUrl: string): Promise => { + if (projectUrl === '') { + return [`Community URL is a required field.`] + } + + const user = (await this.db.select().from(users).limit(1))[0] + console.log(`================ ${user?.username} ===================`) + if (user == null) { + return [] + } + + const projectRepoUrl = projectUrl.replace(/(\/|\.git)$/i, '') + const communityName = projectRepoUrl.split('/').at(-1)! + const communityUrl = `${projectRepoUrl.replace( + /-gov\.public$/i, + '', + )}-gov.public.git` + + const [communityMainBranch, errors] = await this.validateCommunityUrl( + communityUrl, + ) + if (errors != null && errors.length > 0) { + return errors + } + + const configPath = resolve( + this.configDir, + (await hashString(communityUrl)) + '.json', + ) + + const community = { + url: communityUrl, + branch: communityMainBranch!, + name: communityName, + projectUrl: projectRepoUrl, + configPath, + selected: true, + } + await this.db.update(communities).set({ selected: false }) + await this.db.insert(communities).values(community).onConflictDoUpdate({ + target: communities.url, + set: community, + }) + + return [] + } +} diff --git a/src/electron/services/ConfigService.ts b/src/electron/services/ConfigService.ts deleted file mode 100644 index 2135b5d..0000000 --- a/src/electron/services/ConfigService.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { existsSync } from 'node:fs' -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' - -import { eq } from 'drizzle-orm' -import validator from 'validator' - -import { AbstractConfigService, Config, ConfigMeta } from '~/shared' - -import { DB } from '../db/db.js' -import { - ballots, - configs, - configStore, - ConfigStoreDB, - InsertConfigDB, - InsertConfigStoreDB, -} from '../db/schema.js' -import { hashString, toResolvedPath } from '../lib/paths.js' -import { mergeDeep } from '../lib/records.js' -import { GitService, GitUserInfo } from './GitService.js' -import { Gov4GitService } from './Gov4GitService.js' -import { Services } from './Services.js' - -export class ConfigService extends AbstractConfigService { - protected declare configDir: string - protected declare services: Services - protected declare gitService: GitService - protected declare govService: Gov4GitService - protected declare db: DB - protected declare identityRepoName: string - - constructor( - services: Services, - configDir = '~/.gov4git', - identityRepoName = 'gov4git-identity', - ) { - super() - this.configDir = toResolvedPath(configDir) - this.services = services - this.db = this.services.load('db') - this.gitService = this.services.load('git') - this.govService = this.services.load('gov4git') - this.identityRepoName = identityRepoName - } - - protected getSelectedConfig = async (): Promise => { - const configs = await this.db.select().from(configStore).limit(1) - if (configs.length === 0) return null - return configs[0]! - } - - protected readConfig = async (configPath: string): Promise => { - const confPath = resolve(this.configDir, configPath) - if (!existsSync(confPath)) return null - try { - const configContents = await readFile(confPath, 'utf-8') - const config = JSON.parse(configContents) as Config - return config - } catch (ex) { - throw new Error(`Unable to load config ${confPath}. Error: ${ex}`) - } - } - - protected _getUserName = async (username: string) => { - if (username === '') return '' - const command = ['group', 'list', '--name', 'everybody'] - const users = await this.govService.mustRun(...command) - const existingInd = users.findIndex((u) => { - return u.toLocaleLowerCase() === username.toLocaleLowerCase() - }) - if (existingInd !== -1) { - return users[existingInd]! - } - return username - } - - public getConfig = async (): Promise => { - const selectedConfig = await this.getSelectedConfig() - if (selectedConfig == null) return null - const config = await this.readConfig(selectedConfig.path) - if (config != null) { - this.govService.setConfigPath(selectedConfig.path) - config.user.username = await this._getUserName( - config.user?.username ?? '', - ) - const configRecord = { - communityUrl: selectedConfig.communityUrl, - path: selectedConfig.path, - name: selectedConfig.name, - projectUrl: selectedConfig.projectUrl, - } - await this.db.insert(configs).values(configRecord).onConflictDoUpdate({ - target: configs.communityUrl, - set: configRecord, - }) - } - return config - } - - public deleteConfig = async (url: string) => { - const communityUrl = this.getCommunityUrl(url) - this.throwIfNotUrl(communityUrl, 'deleteConfig') - const confToDelete = ( - await this.db - .select() - .from(configs) - .where(eq(configs.communityUrl, communityUrl)) - .limit(1) - )[0] - if (confToDelete != null) { - if (existsSync(confToDelete.path)) { - await rm(confToDelete.path) - } - await this.db - .delete(configs) - .where(eq(configs.communityUrl, communityUrl)) - } - const newConf = (await this.db.select().from(configs).limit(1))[0] - const selectedConfig = ( - await this.db.select().from(configStore).limit(1) - )[0] - if ( - selectedConfig != null && - selectedConfig.communityUrl === communityUrl - ) { - if (newConf != null) { - const insertRecord: InsertConfigStoreDB = { - id: 1, - ...newConf, - } - await this.db - .insert(configStore) - .values(insertRecord) - .onConflictDoUpdate({ - target: configStore.id, - set: insertRecord, - }) - } else { - await this.db.delete(configStore) - } - } - } - - public getAvailableConfigs = async (): Promise => { - return await this.db.select().from(configs) - } - - protected isUrl = (url: string): boolean => { - return validator.isURL(url) - } - - protected throwIfNotUrl = (url: string, from: string) => { - if (!this.isUrl(url)) { - throw new Error(`${url} is not a valid url. From: ${from}`) - } - } - - protected getCommunityName = (url: string): string => { - const projectUrl = this.getProjectUrl(url) - return projectUrl.split('/').at(-1)! - } - - protected getCommunityUrl = (url: string): string => { - return `${url - .replace(/\.git/i, '') - .replace(/-gov\.public$/i, '')}-gov.public.git` - } - - protected getProjectUrl = (url: string): string => { - return `${url.replace(/\.git/i, '').replace(/-gov\.public$/i, '')}` - } - - protected write = async (location: string, obj: any) => { - const path = resolve(this.configDir, location) - const dir = dirname(path) - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }) - } - - await writeFile(path, JSON.stringify(obj, undefined, 2), 'utf-8') - } - - protected getConfigFilePath = async (url: string) => { - this.throwIfNotUrl(url, 'getConfigFilePath') - const communityUrl = this.getCommunityUrl(url) - return resolve(this.configDir, (await hashString(communityUrl)) + '.json') - } - - public selectConfig = async (url: string) => { - this.throwIfNotUrl(url, 'selectConfig') - const projectUrl = this.getProjectUrl(url) - const communityUrl = this.getCommunityUrl(url) - const configPath = await this.getConfigFilePath(communityUrl) - const communityName = this.getCommunityName(communityUrl) - const configStoreRecord: InsertConfigStoreDB = { - id: 1, - communityUrl, - projectUrl, - name: communityName, - path: configPath, - } - this.govService.setConfigPath(configPath) - await this.db - .insert(configStore) - .values(configStoreRecord) - .onConflictDoUpdate({ - target: configStore.id, - set: configStoreRecord, - }) - } - - public createOrUpdateConfig = async ( - config: Partial, - ): Promise => { - let newConfig: Partial = config - if (newConfig.gov_public_url) { - await this.selectConfig(newConfig.gov_public_url) - } - const currentConfig = await this.getConfig() - newConfig = mergeDeep({}, structuredClone(currentConfig ?? {}), newConfig) - - const errors = await this.validateConfig(newConfig) - if (errors.length > 0) return errors - - const fullConfig = newConfig as Config - if (fullConfig.gov_public_url) { - await this.selectConfig(fullConfig.gov_public_url) - } - await this.write( - await this.getConfigFilePath(fullConfig.gov_public_url), - fullConfig, - ) - const insertRecord: InsertConfigDB = { - communityUrl: fullConfig.gov_public_url, - name: fullConfig.community_name, - path: await this.getConfigFilePath(fullConfig.gov_public_url), - projectUrl: fullConfig.project_repo, - } - await this.db.insert(configs).values(insertRecord).onConflictDoUpdate({ - target: configs.communityUrl, - set: insertRecord, - }) - await this.runGov4GitInit(fullConfig) - await this.db.delete(ballots) - return errors - } - - protected runGov4GitInit = async (config: Partial) => { - const user = config.user! - const isPublicEmpty = !(await this.gitService.hasCommits( - config.member_public_url!, - user, - )) - const isPrivateEmpty = !(await this.gitService.hasCommits( - config.member_private_url!, - user, - )) - - if (isPublicEmpty || isPrivateEmpty) { - this.govService.mustRun('init-id') - } - } - - public validateSettings = async (): Promise => { - const config = await this.getConfig() - if (config == null) return [] - return this.validateConfig(config) - } - - protected validateConfig = async ( - config: Partial, - ): Promise => { - let errors: string[] = [] - - errors = [...this.requireFields(config)] - if (errors.length > 0) return errors - - errors = [...(await this.validateUser(config))] - if (errors.length > 0) return errors - - errors = [...(await this.validatePublicCommunityUrl(config))] - if (errors.length > 0) return errors - - await this.validateIdentityRepos(config) - this.validateAuthTokens(config) - - return errors - } - - protected requireFields = (config: Partial): string[] => { - const errors: string[] = [] - if ( - !('user' in config) || - !('username' in config.user!) || - config.user.username === '' - ) { - errors.push(`Username is required. Please provide a username`) - } - - if ( - !('user' in config) || - !('pat' in config.user!) || - config.user.pat === '' - ) { - errors.push( - `Personal Access Token is required. Please provide a valid GitHub Access Token`, - ) - } - - config.project_repo = (config.project_repo ?? '').replace( - /(\/|\.git)$/i, - '', - ) - - config.community_name = config.project_repo.split('/').at(-1) - - if ( - !('project_repo' in config) || - config.project_repo === '' || - !validator.isURL(config.project_repo!) - ) { - errors.push( - `Community URL is required. Please enter a valid Community URL.`, - ) - } else { - config.gov_public_url = this.getCommunityUrl(config.project_repo) - } - return errors - } - - protected validateUser = async ( - config: Partial, - ): Promise => { - const errors: string[] = [] - const user = config.user! - - const scopes = await this.gitService.getOAuthScopes(user.pat) - if (!scopes.includes('repo')) { - errors.push( - 'Personal Access Token has insufficient privileges. Please ensure PAT has rights to top-level repo scope.', - ) - return errors - } - - if (!(await this.gitService.doesUserExist(user as GitUserInfo))) { - errors.push(`Invalid user credentials`) - } - - return errors - } - - protected validatePublicCommunityUrl = async ( - config: Partial, - ): Promise => { - const errors: string[] = [] - const user = config.user! - - if ( - !(await this.gitService.doesRemoteRepoExist(config.gov_public_url!, user)) - ) { - errors.push( - `Community url, ${config.gov_public_url}, does not exist. Please enter a valid community URL.`, - ) - return errors - } - - const communityMainBranch = - (await this.gitService.getDefaultBranch(config.gov_public_url!, user)) ?? - 'main' - config.gov_public_branch = communityMainBranch - return errors - } - - protected validateIdentityRepos = async ( - config: Partial, - ): Promise => { - const user = config.user! - - config.member_public_url = - config.member_public_url ?? - `https://github.com/${user.username}/${this.identityRepoName}-public.git` - config.member_private_url = - config.member_private_url ?? - `https://github.com/${user.username}/${this.identityRepoName}-private.git` - - if ( - !(await this.gitService.doesRemoteRepoExist( - config.member_public_url!, - user, - )) - ) { - await this.gitService.initializeRemoteRepo( - config.member_public_url, - user, - false, - ) - } - - config.member_public_branch = - (await this.gitService.getDefaultBranch( - config.member_public_url!, - user, - )) ?? 'main' - - if ( - !(await this.gitService.doesRemoteRepoExist( - config.member_private_url!, - user, - )) - ) { - await this.gitService.initializeRemoteRepo( - config.member_private_url, - user, - ) - } - config.member_private_branch = - (await this.gitService.getDefaultBranch( - config.member_private_url!, - user, - )) ?? 'main' - } - - protected validateAuthTokens = (config: Partial): void => { - config.auth = config.auth ?? {} - const user = config.user! - - config.auth![config.gov_public_url!] = { - access_token: user.pat!, - } - config.auth![config.member_public_url!] = { - access_token: user.pat!, - } - config.auth![config.member_private_url!] = { - access_token: user.pat!, - } - } -} diff --git a/src/electron/services/GitService.ts b/src/electron/services/GitService.ts index eb9e83a..161dd1f 100644 --- a/src/electron/services/GitService.ts +++ b/src/electron/services/GitService.ts @@ -43,6 +43,7 @@ export class GitService { Authorization: `Token ${token}`, }, }) + await response.text() const scopes = (response.headers.get('X-OAuth-Scopes') ?? '').split(', ') return scopes } @@ -163,13 +164,14 @@ export class GitService { public doesUserExist = async (user: GitUserInfo): Promise => { const authHeader = this.getAuthHeader(user) - const response = await fetch(`${this.apiBaseUrl}/user`, { + const response = await fetch(`${this.apiBaseUrl}/users/${user.username}`, { method: 'GET', headers: { Accept: 'application/vnd.github+json', ...authHeader, }, }) + await response.text() if (response.status !== 200) { return false } @@ -197,7 +199,7 @@ export class GitService { const authHeader = this.getAuthHeader(user) - await fetch(`${this.apiBaseUrl}/user/repos`, { + const response = await fetch(`${this.apiBaseUrl}/user/repos`, { method: 'POST', headers: { Accept: 'application/vnd.github+json', @@ -213,6 +215,7 @@ export class GitService { has_discussions: false, }), }) + await response.text() } catch (ex) { throw new Error(`Unable to initialize repo ${repo}. Error: ${ex}`) } @@ -228,14 +231,17 @@ export class GitService { const repoSegment = this.getRepoSegment(repoUrl) const authHeader = this.getAuthHeader(user) - - await fetch(`${this.apiBaseUrl}/repos/${repoSegment}`, { + const result = await fetch(`${this.apiBaseUrl}/repos/${repoSegment}`, { method: 'DElETE', headers: { Accept: 'application/vnd.github+json', ...authHeader, }, }) + await result.text() + if (result.status !== 204) { + throw new Error(`Status code: ${result.status}`) + } } catch (ex) { throw new Error(`Unable to delete repo ${repoUrl}. Error: ${ex}`) } diff --git a/src/electron/services/Gov4GitService.ts b/src/electron/services/Gov4GitService.ts index eceaa33..4e6f6ab 100644 --- a/src/electron/services/Gov4GitService.ts +++ b/src/electron/services/Gov4GitService.ts @@ -1,29 +1,51 @@ import { runGov4Git } from '@gov4git/js-client' +import { eq } from 'drizzle-orm' +import { existsSync } from 'fs' +import { DB } from '../db/db.js' +import { communities } from '../db/schema.js' import { parseStdout } from '../lib/stdout.js' import { LogService } from './LogService.js' import { Services } from './Services.js' export class Gov4GitService { - protected declare configPath: string - protected declare services: Services - protected declare log: LogService + protected declare readonly services: Services + protected declare readonly log: LogService + protected declare readonly db: DB constructor(services: Services) { - this.configPath = '' this.services = services this.log = this.services.load('log') - } - - public setConfigPath = (configPath: string): void => { - this.configPath = configPath + this.db = this.services.load('db') } public mustRun = async (...command: string[]): Promise => { - if (this.configPath !== '') { - command.push('--config', this.configPath) + const selectedCommunity = ( + await this.db + .select() + .from(communities) + .where(eq(communities.selected, true)) + .limit(1) + )[0] + + if (selectedCommunity == null) { + throw new Error( + `Unable to run Gov4Git command ${command.join( + ' ', + )} as config is not provided.`, + ) } + if (!existsSync(selectedCommunity.configPath)) { + throw new Error( + `Unable to run Gov4Git command ${command.join(' ')} as config ${ + selectedCommunity.configPath + } does not exist`, + ) + } + + command.push('--config', selectedCommunity.configPath) + command.push('-v') try { diff --git a/src/electron/services/SettingsService.ts b/src/electron/services/SettingsService.ts new file mode 100644 index 0000000..560fb35 --- /dev/null +++ b/src/electron/services/SettingsService.ts @@ -0,0 +1,130 @@ +import { eq } from 'drizzle-orm' +import { existsSync, writeFileSync } from 'fs' +import { mkdir } from 'fs/promises' +import { dirname } from 'path' + +import { AbstractSettingsService, Config } from '~/shared' + +import { DB } from '../db/db.js' +import { communities, users } from '../db/schema.js' +import { CommunityService } from './CommunityService.js' +import { GitService } from './GitService.js' +import { Gov4GitService } from './Gov4GitService.js' +import { Services } from './Services.js' +import { UserService } from './UserService.js' + +export type SettingsServiceOptions = { + services: Services +} + +export class SettingsService extends AbstractSettingsService { + private declare readonly services: Services + private declare readonly communityService: CommunityService + private declare readonly userService: UserService + private declare readonly gitService: GitService + private declare readonly govService: Gov4GitService + private declare readonly db: DB + + constructor({ services }: SettingsServiceOptions) { + super() + this.services = services + this.db = this.services.load('db') + this.communityService = this.services.load('community') + this.userService = this.services.load('user') + this.gitService = this.services.load('git') + this.govService = this.services.load('gov4git') + } + + private runGov4GitInit = async (config: Config) => { + const user = config.user + const isPublicEmpty = !(await this.gitService.hasCommits( + config.member_public_url!, + user, + )) + const isPrivateEmpty = !(await this.gitService.hasCommits( + config.member_private_url!, + user, + )) + + if (isPublicEmpty || isPrivateEmpty) { + this.govService.mustRun('init-id') + } + } + + public generateConfig = async () => { + const [allCommunities, allUsers] = await Promise.all([ + this.db + .select() + .from(communities) + .where(eq(communities.selected, true)) + .limit(1), + this.db.select().from(users).limit(1), + ]) + + const community = allCommunities[0] + const user = allUsers[0] + + if (community == null || user == null) { + return null + } + + const config = { + notice: + 'Do not modify this file. It will be overwritten by Gov4Git application', + configPath: community.configPath, + community_name: community.name, + project_repo: community.projectUrl, + user: { + username: user.username, + pat: user.pat, + }, + gov_public_url: community.url, + gov_public_branch: community.branch, + member_public_url: user.memberPublicUrl, + member_public_branch: user.memberPublicBranch, + member_private_url: user.memberPrivateUrl, + member_private_branch: user.memberPrivateBranch, + auth: { + [community.url]: { + access_token: user.pat, + }, + [user.memberPublicUrl]: { + access_token: user.pat, + }, + [user.memberPrivateUrl]: { + access_token: user.pat, + }, + }, + } + + const dir = dirname(community.configPath) + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }) + } + + writeFileSync( + community.configPath, + JSON.stringify(config, undefined, 2), + 'utf-8', + ) + + await this.runGov4GitInit(config) + + return config + } + + public validateConfig = async (): Promise => { + const config = await this.generateConfig() + if (config == null) return [] + const userErrors = await this.userService.validateUser( + config.user.username, + config.user.pat, + ) + if (userErrors.length > 0) { + return userErrors + } + const [, communityErrors] = + await this.communityService.validateCommunityUrl(config.gov_public_url) + return communityErrors ?? [] + } +} diff --git a/src/electron/services/UserService.ts b/src/electron/services/UserService.ts index bdfca50..52ff53a 100644 --- a/src/electron/services/UserService.ts +++ b/src/electron/services/UserService.ts @@ -1,56 +1,138 @@ -import { AbstractUserService, Config, User } from '~/shared' +import { and, eq } from 'drizzle-orm' -import { hasRequiredKeys } from '../lib/records.js' -import { BallotService } from './BallotService.js' -import { ConfigService } from './ConfigService.js' +import { AbstractUserService } from '~/shared' + +import { DB } from '../db/db.js' +import { + ballots, + communities, + userCommunities, + UserInsert, + users, +} from '../db/schema.js' +import { GitService } from './GitService.js' import { Gov4GitService } from './Gov4GitService.js' import { Services } from './Services.js' +export type UserServiceOptions = { + services: Services + identityRepoName?: string +} export class UserService extends AbstractUserService { - protected declare services: Services - protected declare configService: ConfigService - protected declare gov4GitService: Gov4GitService - protected declare ballotService: BallotService + private declare readonly services: Services + private declare readonly identityRepoName: string + private declare readonly repoUrlBase: string + private declare readonly db: DB + private declare readonly gitService: GitService + private declare readonly govService: Gov4GitService - constructor(services: Services) { + constructor({ + services, + identityRepoName = 'gov4git-identity', + }: UserServiceOptions) { super() this.services = services - this.configService = this.services.load('config') - this.gov4GitService = this.services.load('gov4git') - this.ballotService = this.services.load('ballots') + this.identityRepoName = identityRepoName + this.repoUrlBase = 'https://github.com' + this.db = this.services.load('db') + this.gitService = this.services.load('git') + this.govService = this.services.load('gov4git') + } + + private deleteDBTables = async () => { + await this.db.delete(userCommunities) + await Promise.all([ + this.db.delete(users), + this.db.delete(ballots), + this.db.delete(communities), + ]) + } + + private requireFields = (username: string, pat: string): string[] => { + const errors: string[] = [] + if (username === '') { + errors.push('Username is a required field') + } + if (pat === '') { + errors.push('Personal Access Token is a required field') + } + return errors } - protected _getUser = async (): Promise => { - const config = await this.configService.getConfig() - if (config == null) return null - const user = { - voting_credits: 0, - voting_score: 0, - is_member: false, - is_maintainer: false, - ...config.user, - } - user.is_member = await this.isCommunityMember(user) - if (user.is_member) { - user.voting_credits = await this.getVotingCredits(user) - user.voting_score = Math.sqrt(user.voting_credits) - user.is_maintainer = this.isCommunityMaintainer(config) - } - return user + public validateUser = async ( + username: string, + pat: string, + ): Promise => { + const missingErrors = this.requireFields(username, pat) + if (missingErrors.length > 0) { + return missingErrors + } + const errors: string[] = [] + const scopes = await this.gitService.getOAuthScopes(pat) + if (!scopes.includes('repo')) { + errors.push( + 'Personal Access Token has insufficient privileges. Please ensure PAT has rights to top-level repo scope.', + ) + return errors + } + + if ( + !(await this.gitService.doesUserExist({ + username, + pat, + })) + ) { + errors.push(`Invalid user credentials`) + } + + return errors } - protected getVotingCredits = async (user: User): Promise => { + private getOpenBallots = async () => { + const community = ( + await this.db + .select() + .from(communities) + .where(eq(communities.selected, true)) + )[0] + if (community == null) { + return [] + } + const results = await this.db + .select() + .from(ballots) + .where(and(eq(ballots.status, 'open'))) + return results + } + + private isCommunityMember = async ( + username: string, + ): Promise => { + const command = ['group', 'list', '--name', 'everybody'] + const users = await this.govService.mustRun(...command) + const existingInd = users.findIndex((u) => { + return u.toLocaleLowerCase() === username.toLocaleLowerCase() + }) + if (existingInd !== -1) { + return users[existingInd]! + } + return null + } + + private getVotingCredits = async (username: string): Promise => { const command = [ 'balance', 'get', '--user', - user.username, + username, '--key', 'voting_credits', ] - const credits = await this.gov4GitService.mustRun(...command) - const ballots = await this.ballotService.getOpen() - const totalPendingSpentCredits = ballots.reduce((acc, cur) => { + const [credits, openTallies] = await Promise.all([ + this.govService.mustRun(...command), + this.getOpenBallots(), + ]) + const totalPendingSpentCredits = openTallies.reduce((acc, cur) => { const spentCredits = cur.user.talliedCredits const score = cur.user.newScore const scoreSign = score < 0 ? -1 : 1 @@ -61,26 +143,186 @@ export class UserService extends AbstractUserService { return credits - totalPendingSpentCredits } - protected isCommunityMember = async (user: User): Promise => { - const command = ['group', 'list', '--name', 'everybody'] - const users = await this.gov4GitService.mustRun(...command) - const existingInd = users.findIndex((u) => { - return u.toLocaleLowerCase() === user.username.toLocaleLowerCase() + private validateIdRepo = async ( + username: string, + pat: string, + isPrivate: boolean, + ) => { + const url = `${this.repoUrlBase}/${username}/${this.identityRepoName}-${ + isPrivate ? 'private' : 'public' + }.git` + + if (!(await this.gitService.doesRemoteRepoExist(url, { username, pat }))) { + await this.gitService.initializeRemoteRepo( + url, + { username, pat }, + isPrivate, + ) + } + + const branch = + (await this.gitService.getDefaultBranch(url, { username, pat })) ?? 'main' + + return { url, branch } + } + + private constructUser = async ( + username: string, + pat: string, + ): Promise => { + const publicRepo = await this.validateIdRepo(username, pat, false) + const privateRepo = await this.validateIdRepo(username, pat, true) + + return { + username, + pat, + memberPublicUrl: publicRepo.url, + memberPublicBranch: publicRepo.branch, + memberPrivateUrl: privateRepo.url, + memberPrivateBranch: privateRepo.branch, + } + } + + public authenticate = async ( + username: string, + pat: string, + ): Promise => { + const validationErrors = await this.validateUser(username, pat) + if (validationErrors.length > 0) { + return validationErrors + } + + const [existingUsers, newUser] = await Promise.all([ + this.db.select().from(users), + this.constructUser(username, pat), + ]) + + const existingUser = existingUsers[0] + if (existingUser != null && existingUser.username !== newUser.username) { + await this.deleteDBTables() + } + + await this.db.insert(users).values(newUser).onConflictDoUpdate({ + target: users.username, + set: newUser, }) - return existingInd !== -1 + return [] } - protected isCommunityMaintainer = (config: Config): boolean => { - const keys = [ - 'gov_private_url', - 'gov_private_branch', - `auth.${config.gov_public_url?.replace(/\./g, '###')}.access_token`, - `auth.${config.gov_private_url?.replace(/\./g, '###')}.access_token`, - ] - return hasRequiredKeys(config, keys) + public loadUser = async () => { + const [allUsers, selectedCommunities] = await Promise.all([ + this.db.select().from(users), + this.db.select().from(communities).where(eq(communities.selected, true)), + ]) + const user = allUsers[0] + const community = selectedCommunities[0] + + if (user == null) { + return { + users: null, + communities: null, + userCommunities: null, + } + } + if (community == null) { + return { + users: user, + communities: null, + userCommunities: null, + } + } + + const newUsername = await this.isCommunityMember(user.username) + const isMember = newUsername != null + const votingCredits = isMember + ? await this.getVotingCredits(newUsername) + : 0 + const votingScore = Math.sqrt(Math.abs(votingCredits)) + + if (newUsername != null && newUsername !== user.username) { + await this.db + .update(users) + .set({ username: newUsername }) + .where(eq(users.username, user.username)) + } + + const userCommunity = { + userId: newUsername ?? user.username, + communityId: community.url, + isMember: isMember, + isMaintainer: false, + votingCredits: votingCredits, + votingScore: votingScore, + } + + await this.db + .insert(userCommunities) + .values(userCommunity) + .onConflictDoUpdate({ + target: userCommunities.communityId, + set: userCommunity, + }) + + return { + users: { + ...user, + username: newUsername ?? user.username, + }, + communities: community, + userCommunities: userCommunity, + } } - public async getUser(): Promise { - return await this._getUser() + public getUser = async () => { + const userInfos = ( + await this.db + .select() + .from(userCommunities) + .rightJoin( + communities, + eq(userCommunities.communityId, communities.url), + ) + .leftJoin(users, eq(userCommunities.userId, users.username)) + .where(eq(communities.selected, true)) + .limit(1) + )[0] + + if ( + userInfos == null || + userInfos.userCommunities == null || + userInfos.users == null + ) { + const newUserInfo = await this.loadUser() + if (newUserInfo.users == null) { + return null + } else if (newUserInfo.userCommunities == null) { + return { + ...newUserInfo.users, + communityId: '', + isMember: false, + isMaintainer: false, + votingCredits: 0, + votingScore: 0, + } + } else { + return { + ...newUserInfo.users, + communityId: newUserInfo.userCommunities.communityId, + isMember: newUserInfo.userCommunities.isMember, + isMaintainer: false, + votingCredits: newUserInfo.userCommunities.votingCredits, + votingScore: newUserInfo.userCommunities.votingScore, + } + } + } else { + return { + ...userInfos.users, + communityId: userInfos.userCommunities.communityId, + isMember: userInfos.userCommunities.isMember, + isMaintainer: false, + votingCredits: userInfos.userCommunities.votingCredits, + votingScore: userInfos.userCommunities.votingScore, + } + } } } diff --git a/src/electron/services/index.ts b/src/electron/services/index.ts index 7e49fa3..c441625 100644 --- a/src/electron/services/index.ts +++ b/src/electron/services/index.ts @@ -1,8 +1,10 @@ export * from './Services.js' export * from './BallotService.js' -export * from './ConfigService.js' export * from './UserService.js' export * from './GitService.js' export * from './LogService.js' export * from './Gov4GitService.js' export * from './AppUpdaterService.js' +export * from './CacheService.js' +export * from './CommunityService.js' +export * from './SettingsService.js' diff --git a/src/renderer/src/components/DataLoader.tsx b/src/renderer/src/components/DataLoader.tsx index b6bc73e..d139fb2 100644 --- a/src/renderer/src/components/DataLoader.tsx +++ b/src/renderer/src/components/DataLoader.tsx @@ -8,10 +8,11 @@ import { eventBus } from '../lib/eventBus.js' import { debounceAsync } from '../lib/functions.js' import { appUpdaterService } from '../services/AppUpdaterService.js' import { ballotService } from '../services/BallotService.js' -import { configService } from '../services/ConfigService.js' +import { cacheService } from '../services/CacheService.js' +import { communityService } from '../services/CommunityService.js' import { userService } from '../services/UserService.js' import { ballotsAtom } from '../state/ballots.js' -import { configAtom } from '../state/config.js' +import { communityAtom } from '../state/community.js' import { loaderAtom } from '../state/loader.js' import { updatesAtom } from '../state/updates.js' import { userAtom, userLoadedAtom } from '../state/user.js' @@ -19,11 +20,12 @@ import { userAtom, userLoadedAtom } from '../state/user.js' export const DataLoader: FC = function DataLoader() { const catchError = useCatchError() const setUpdates = useSetAtom(updatesAtom) - const setConfig = useSetAtom(configAtom) + // const setConfig = useSetAtom(configAtom) const setBallots = useSetAtom(ballotsAtom) const setUser = useSetAtom(userAtom) const setUserLoaded = useSetAtom(userLoadedAtom) const setLoading = useSetAtom(loaderAtom) + const setCommunity = useSetAtom(communityAtom) const [loadingQueue, setLoadingQueue] = useState[]>([]) const navigate = useNavigate() @@ -49,18 +51,26 @@ export const DataLoader: FC = function DataLoader() { return debounceAsync(_checkForUpdates) }, [_checkForUpdates]) - const _getConfig = useCallback(async () => { - try { - const config = await configService.getConfig() - setConfig(config) - } catch (ex) { - await catchError(`Failed to load config. ${ex}`) - } - }, [catchError, setConfig]) + const _refreshCache = useCallback(async () => { + await cacheService.refreshCache() + }, []) + + const refreshCache = useMemo(() => { + return debounceAsync(_refreshCache) + }, [_refreshCache]) + + // const _getConfig = useCallback(async () => { + // try { + // const config = await configService.getConfig() + // setConfig(config) + // } catch (ex) { + // await catchError(`Failed to load config. ${ex}`) + // } + // }, [catchError, setConfig]) - const getConfig = useMemo(() => { - return debounceAsync(_getConfig) - }, [_getConfig]) + // const getConfig = useMemo(() => { + // return debounceAsync(_getConfig) + // }, [_getConfig]) const _getUser = useCallback(async () => { try { @@ -76,9 +86,22 @@ export const DataLoader: FC = function DataLoader() { return debounceAsync(_getUser) }, [_getUser]) + const _getCommunity = useCallback(async () => { + try { + const community = await communityService.getCommunity() + setCommunity(community) + } catch (ex) { + await catchError(`Failed to load community. ${ex}`) + } + }, [catchError, setCommunity]) + + const getCommunity = useMemo(() => { + return debounceAsync(_getCommunity) + }, [_getCommunity]) + const _getBallots = useCallback(async () => { try { - const ballots = await ballotService.getOpen() + const ballots = await ballotService.getBallots() setBallots(ballots) } catch (ex) { await catchError(`Failed to load ballots. ${ex}`) @@ -89,23 +112,26 @@ export const DataLoader: FC = function DataLoader() { return debounceAsync(_getBallots) }, [_getBallots]) - const _updateBallotCache = useCallback(async () => { - try { - const ballots = await ballotService.updateCache() - setBallots(ballots) - } catch (ex) { - await catchError(`Failed to load ballots. ${ex}`) - } - }, [setBallots, catchError]) + // const _updateBallotCache = useCallback(async () => { + // // try { + // // const ballots = await ballotService.updateCache() + // // setBallots(ballots) + // // } catch (ex) { + // // await catchError(`Failed to load ballots. ${ex}`) + // // } + // }, [setBallots, catchError]) - const updateBallotCache = useMemo(() => { - return debounceAsync(_updateBallotCache) - }, [_updateBallotCache]) + // const updateBallotCache = useMemo(() => { + // return debounceAsync(_updateBallotCache) + // }, [_updateBallotCache]) const _getBallot = useCallback( async (e: CustomEvent<{ ballotId: string }>) => { try { const ballot = await ballotService.getBallot(e.detail.ballotId) + if (ballot == null) { + return + } setBallots((ballots) => { if (ballots == null) return [ballot] const existingInd = ballots.findIndex( @@ -132,12 +158,14 @@ export const DataLoader: FC = function DataLoader() { useEffect(() => { const listeners: Array<() => void> = [] - addToQueue(getConfig()) addToQueue(getUser()) + addToQueue(getCommunity()) addToQueue(getBallots()) const updateCacheInterval = setInterval(async () => { - return await updateBallotCache().then(getUser) + return await refreshCache().then(async () => { + await Promise.all([getUser(), getBallots()]) + }) }, 60 * 1000) listeners.push(() => { @@ -155,7 +183,8 @@ export const DataLoader: FC = function DataLoader() { listeners.push( eventBus.subscribe('user-logged-in', async () => { - const prom = getConfig().then(updateBallotCache).then(getUser) + // const prom = getConfig().then(updateBallotCache).then(getUser) + const prom = Promise.all([getUser(), getCommunity(), getBallots()]) addToQueue(prom) await prom navigate(routes.issues.path) @@ -163,12 +192,17 @@ export const DataLoader: FC = function DataLoader() { ) listeners.push( eventBus.subscribe('voted', async (e) => { - await getBallot(e).then(getUser) + // await getBallot(e).then(getUser) + await Promise.all([getBallot(e), getUser()]) }), ) listeners.push( eventBus.subscribe('refresh', async (e) => { - addToQueue(updateBallotCache().then(getUser)) + addToQueue( + refreshCache().then(async () => { + await Promise.all([getBallots(), getUser()]) + }), + ) }), ) @@ -179,14 +213,14 @@ export const DataLoader: FC = function DataLoader() { } }, [ setUpdates, - getConfig, getUser, getBallots, addToQueue, getBallot, - updateBallotCache, navigate, checkForUpdates, + getCommunity, + refreshCache, ]) useEffect(() => { diff --git a/src/renderer/src/components/IssueBallot.tsx b/src/renderer/src/components/IssueBallot.tsx index 4cb7bbf..368d9d0 100644 --- a/src/renderer/src/components/IssueBallot.tsx +++ b/src/renderer/src/components/IssueBallot.tsx @@ -25,7 +25,7 @@ import { useCatchError } from '../hooks/useCatchError.js' import { eventBus } from '../lib/eventBus.js' import { formatDecimal } from '../lib/index.js' import { ballotService } from '../services/index.js' -import { configAtom } from '../state/config.js' +import { communityAtom } from '../state/community.js' import { userAtom } from '../state/user.js' import { useMessageStyles } from '../styles/messages.js' import { BubbleSlider } from './BubbleSlider.js' @@ -49,7 +49,7 @@ export const IssueBallot: FC = function IssueBallot({ const catchError = useCatchError() const messageStyles = useMessageStyles() const user = useAtomValue(userAtom) - const config = useAtomValue(configAtom) + const community = useAtomValue(communityAtom) const [fetchingNewBallot, setFetchingNewBallot] = useState(false) const [voteError, setVoteError] = useState(null) const [inputWidth, setInputWidth] = useState(0) @@ -74,16 +74,16 @@ export const IssueBallot: FC = function IssueBallot({ }, [setFetchingNewBallot, ballot, voteScore, setSuccessMessage]) const githubLink = useMemo(() => { - if (config == null) return null + if (community == null) return null const linkComponent = ballot.identifier.split('/').slice(1).join('/') - return `${config.project_repo}/${linkComponent}` - }, [config, ballot]) + return `${community.projectUrl}/${linkComponent}` + }, [community, ballot]) const maxScore = useMemo(() => { if (user == null) return 0 console.log(ballot) return Math.sqrt( - user.voting_credits + + user.votingCredits + Math.abs(ballot.user.pendingCredits) + Math.abs(ballot.user.talliedCredits), ) @@ -92,7 +92,7 @@ export const IssueBallot: FC = function IssueBallot({ const minScore = useMemo(() => { if (user == null) return 0 return -Math.sqrt( - user.voting_credits + + user.votingCredits + Math.abs(ballot.user.pendingCredits) + Math.abs(ballot.user.talliedCredits), ) @@ -340,7 +340,7 @@ export const IssueBallot: FC = function IssueBallot({ disabled={true} min={0} max={ - (user?.voting_credits ?? 0) + + (user?.votingCredits ?? 0) + ballot.user.pendingCredits + ballot.user.talliedCredits } diff --git a/src/renderer/src/components/Issues.tsx b/src/renderer/src/components/Issues.tsx index 72a3003..9058d2b 100644 --- a/src/renderer/src/components/Issues.tsx +++ b/src/renderer/src/components/Issues.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai' import { type FC, useMemo } from 'react' import { ballotIssuesAtom } from '../state/ballots.js' -import { configAtom } from '../state/config.js' +import { communityAtom } from '../state/community.js' import { useHeadingsStyles } from '../styles/headings.js' import { IssueBallot } from './IssueBallot.js' import { useIssuesStyles } from './Issues.styles.js' @@ -12,12 +12,12 @@ export const Issues: FC = function Issues() { const ballots = useAtomValue(ballotIssuesAtom) const headingStyles = useHeadingsStyles() const styles = useIssuesStyles() - const config = useAtomValue(configAtom) + const community = useAtomValue(communityAtom) const issuesLink = useMemo(() => { - if (config == null) return null - return `${config.project_repo}/issues?q=is:open is:issue label:gov4git:prioritize` - }, [config]) + if (community == null) return null + return `${community.projectUrl}/issues?q=is:open is:issue label:gov4git:prioritize` + }, [community]) return ( <> diff --git a/src/renderer/src/components/PullRequests.tsx b/src/renderer/src/components/PullRequests.tsx index 26a6651..84a9020 100644 --- a/src/renderer/src/components/PullRequests.tsx +++ b/src/renderer/src/components/PullRequests.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai' import { type FC, useMemo } from 'react' import { ballotPullRequestsAtom } from '../state/ballots.js' -import { configAtom } from '../state/config.js' +import { communityAtom } from '../state/community.js' import { useHeadingsStyles } from '../styles/headings.js' import { IssueBallot } from './IssueBallot.js' import { usePullRequestsStyles } from './PullRequests.styles.js' @@ -11,12 +11,12 @@ import { usePullRequestsStyles } from './PullRequests.styles.js' export const PullRequests: FC = function PullRequests() { const ballots = useAtomValue(ballotPullRequestsAtom) const headingStyles = useHeadingsStyles() - const config = useAtomValue(configAtom) + const community = useAtomValue(communityAtom) const styles = usePullRequestsStyles() const issuesLink = useMemo(() => { - if (config == null) return null - return `${config.project_repo}/pulls?q=is:open is:pr label:gov4git:prioritize` - }, [config]) + if (community == null) return null + return `${community.projectUrl}/pulls?q=is:open is:pr label:gov4git:prioritize` + }, [community]) return ( <> diff --git a/src/renderer/src/components/RequireAuth.tsx b/src/renderer/src/components/RequireAuth.tsx index 74955eb..9d4704c 100644 --- a/src/renderer/src/components/RequireAuth.tsx +++ b/src/renderer/src/components/RequireAuth.tsx @@ -6,7 +6,7 @@ import { Navigate } from 'react-router-dom' import type { User } from '~/shared' import { routes } from '../App/index.js' -import { configAtom } from '../state/config.js' +import { communityAtom } from '../state/community.js' import { userAtom, userLoadedAtom } from '../state/user.js' export const RequireAuth: FC = function RequireAuth({ @@ -19,7 +19,7 @@ export const RequireAuth: FC = function RequireAuth({ return <> } else if (user == null) { return - } else if (!user.is_member) { + } else if (!user.isMember) { return } else { return children @@ -27,20 +27,19 @@ export const RequireAuth: FC = function RequireAuth({ } const Unauthorized: FC = function Unauthorized() { - const config = useAtomValue(configAtom) + const community = useAtomValue(communityAtom) const user = useAtomValue(userAtom) as User return ( - Unauthorized {user?.username} is not a member of {config?.project_repo}. -   + Unauthorized {user?.username} is not a member of {community?.url}.   >({}) + const [unsavedChanges, setUnsavedChanges] = useState(true) const catchError = useCatchError() - const config = useAtomValue(configAtom) + const user = useAtomValue(userAtom) + const community = useAtomValue(communityAtom) const [configErrors, setConfigErrors] = useAtom(configErrorsAtom) const [loading, setLoading] = useState(false) const messageStyles = useMessageStyles() - - useEffect(() => { - if (config != null) { - setFormConfig((c) => { - return mergeDeep({}, c, config) - }) - } - }, [config, setFormConfig]) - - const updateConfig = useCallback( - (key: string, value: string) => { - setUnsavedChanges(true) - const obj = createNestedRecord>({ - [key]: value, - }) - setFormConfig((c) => { - return mergeDeep({}, c, obj) - }) - }, - [setFormConfig, setUnsavedChanges], - ) + const [username, setUsername] = useState(user?.username ?? '') + const [pat, setPat] = useState(user?.pat ?? '') + const [projectUrl, setProjectUrl] = useState(community?.projectUrl ?? '') const save = useCallback( async (ev: FormEvent) => { @@ -63,28 +46,44 @@ export const SettingsForm = function SettingsForm() { setConfigErrors([]) try { setLoading(true) - const errors = await configService.createOrUpdateConfig(formConfig) - if (errors.length > 0) { - setConfigErrors(errors) + const userErrors = await userService.authenticate(username, pat) + if (userErrors.length > 0) { + setConfigErrors(userErrors) setLoading(false) } else { - setUnsavedChanges(false) - setLoading(false) - eventBus.emit('user-logged-in') + const communityErrors = await communityService.insertCommunity( + projectUrl, + ) + if (communityErrors.length > 0) { + setConfigErrors(communityErrors) + setLoading(false) + } else { + await settingsService.generateConfig() + setUnsavedChanges(false) + setLoading(false) + eventBus.emit('user-logged-in') + } } } catch (ex) { setLoading(false) await catchError(`Failed to save config. ${ex}`) } }, - [setUnsavedChanges, formConfig, setConfigErrors, catchError, setLoading], + [ + setUnsavedChanges, + username, + pat, + projectUrl, + setConfigErrors, + catchError, + setLoading, + ], ) const reset = useCallback(() => { - setFormConfig({}) setUnsavedChanges(false) navigate(routes.issues.path) - }, [setFormConfig, setUnsavedChanges, navigate]) + }, [setUnsavedChanges, navigate]) const onClose = useCallback(() => { setConfigErrors([]) @@ -115,9 +114,9 @@ export const SettingsForm = function SettingsForm() { width="100%" type="text" id="username" - value={formConfig.user?.username ?? ''} + value={username} disabled={loading} - onChange={(e) => updateConfig('user.username', e.target.value)} + onChange={(e) => setUsername(e.target.value)} /> updateConfig('user.pat', e.target.value)} + onChange={(e) => setPat(e.target.value)} /> updateConfig('project_repo', e.target.value)} + onChange={(e) => setProjectUrl(e.target.value)} /> diff --git a/src/renderer/src/components/SiteNav.tsx b/src/renderer/src/components/SiteNav.tsx index 65ab5d8..62d2ebe 100644 --- a/src/renderer/src/components/SiteNav.tsx +++ b/src/renderer/src/components/SiteNav.tsx @@ -63,7 +63,7 @@ export const SiteNav: FC = function SiteNav() { {user && Object.entries(routes).map(([key, route]) => { if (!route.siteNav || route.footer) return - if (!user?.is_maintainer && route.forAdmin) return + if (!user?.isMaintainer && route.forAdmin) return return (
{Object.entries(routes).map(([key, route]) => { if (!route.siteNav || !route.footer) return - if (!user?.is_maintainer && route.forAdmin) return + if (!user?.isMaintainer && route.forAdmin) return return (
{user.username} -
{formatDecimal(user.voting_credits ?? 0)}
+
{formatDecimal(user.votingCredits ?? 0)}
diff --git a/src/renderer/src/hooks/useCatchError.ts b/src/renderer/src/hooks/useCatchError.ts index 6556c95..a36178c 100644 --- a/src/renderer/src/hooks/useCatchError.ts +++ b/src/renderer/src/hooks/useCatchError.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { routes } from '../App/Router.js' -import { configService } from '../services/ConfigService.js' +import { settingsService } from '../services/SettingsService.js' import { configErrorsAtom } from '../state/config.js' import { errorAtom } from '../state/error.js' @@ -15,7 +15,7 @@ export function useCatchError() { const setError = useCallback( async (error: string) => { try { - const configErrors = await configService.validateSettings() + const configErrors = await settingsService.validateConfig() if (configErrors.length > 0) { setConfigErrors(configErrors) navigate(routes.settings.path) diff --git a/src/renderer/src/services/CacheService.ts b/src/renderer/src/services/CacheService.ts new file mode 100644 index 0000000..9576f6a --- /dev/null +++ b/src/renderer/src/services/CacheService.ts @@ -0,0 +1,7 @@ +import { AbstractCacheService } from '~/shared' + +import { proxyService } from './proxyService.js' + +const CacheService = proxyService('cache') + +export const cacheService = new CacheService() diff --git a/src/renderer/src/services/CommunityService.ts b/src/renderer/src/services/CommunityService.ts new file mode 100644 index 0000000..accd9b1 --- /dev/null +++ b/src/renderer/src/services/CommunityService.ts @@ -0,0 +1,8 @@ +import { AbstractCommunityService } from '~/shared' + +import { proxyService } from './proxyService.js' + +const CommunityService = + proxyService('community') + +export const communityService = new CommunityService() diff --git a/src/renderer/src/services/ConfigService.ts b/src/renderer/src/services/ConfigService.ts deleted file mode 100644 index 5868cd1..0000000 --- a/src/renderer/src/services/ConfigService.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AbstractConfigService } from '~/shared' - -import { proxyService } from './proxyService.js' - -const ConfigService = proxyService('config') - -export const configService = new ConfigService() diff --git a/src/renderer/src/services/SettingsService.ts b/src/renderer/src/services/SettingsService.ts new file mode 100644 index 0000000..e12b17c --- /dev/null +++ b/src/renderer/src/services/SettingsService.ts @@ -0,0 +1,7 @@ +import { AbstractSettingsService } from '~/shared' + +import { proxyService } from './proxyService.js' + +const SettingsService = proxyService('settings') + +export const settingsService = new SettingsService() diff --git a/src/renderer/src/services/index.ts b/src/renderer/src/services/index.ts index 2f99b24..adf3a1f 100644 --- a/src/renderer/src/services/index.ts +++ b/src/renderer/src/services/index.ts @@ -1,5 +1,6 @@ export * from './BallotService.js' -export * from './ConfigService.js' export * from './UserService.js' export * from './LogService.js' export * from './AppUpdaterService.js' +export * from './CommunityService.js' +export * from './SettingsService.js' diff --git a/src/renderer/src/state/community.ts b/src/renderer/src/state/community.ts new file mode 100644 index 0000000..07d0169 --- /dev/null +++ b/src/renderer/src/state/community.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai' + +import type { Community } from '~/shared' + +export const communityAtom = atom(null) diff --git a/src/renderer/src/state/settings.ts b/src/renderer/src/state/settings.ts new file mode 100644 index 0000000..1487424 --- /dev/null +++ b/src/renderer/src/state/settings.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai' + +export const settingsErrorAtom = atom([]) diff --git a/src/shared/services/BallotService.ts b/src/shared/services/BallotService.ts index 13803b5..cad45ae 100644 --- a/src/shared/services/BallotService.ts +++ b/src/shared/services/BallotService.ts @@ -9,6 +9,7 @@ export type Ballot = { choices: string[] choice: string description: string + status: 'open' | 'closed' user: { talliedScore: number talliedCredits: number @@ -31,11 +32,9 @@ export type CreateBallotOptions = { } export abstract class AbstractBallotService { - public abstract getBallot(ballotId: string): Promise - public abstract getOpen(): Promise - public abstract updateCache(): Promise - public abstract clearCache(): Promise + public abstract getBallot(ballotId: string): Promise public abstract vote(voteOptions: VoteOption): Promise - public abstract createBallot(options: CreateBallotOptions): Promise - public abstract tallyBallot(ballotName: string): Promise + // public abstract createBallot(options: CreateBallotOptions): Promise + // public abstract tallyBallot(ballotName: string): Promise + public abstract getBallots(): Promise } diff --git a/src/shared/services/CacheService.ts b/src/shared/services/CacheService.ts new file mode 100644 index 0000000..f94f004 --- /dev/null +++ b/src/shared/services/CacheService.ts @@ -0,0 +1,3 @@ +export abstract class AbstractCacheService { + public abstract refreshCache(): Promise +} diff --git a/src/shared/services/CommunityService.ts b/src/shared/services/CommunityService.ts new file mode 100644 index 0000000..006c94c --- /dev/null +++ b/src/shared/services/CommunityService.ts @@ -0,0 +1,14 @@ +export type Community = { + url: string + name: string + branch: string + configPath: string + projectUrl: string + selected: boolean +} + +export abstract class AbstractCommunityService { + public abstract getCommunity(): Promise + + public abstract insertCommunity(projectUrl: string): Promise +} diff --git a/src/shared/services/ConfigService.ts b/src/shared/services/ConfigService.ts deleted file mode 100644 index 113ea4a..0000000 --- a/src/shared/services/ConfigService.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type Config = { - cache_dir?: string - cache_ttl_seconds?: number - user: { - username: string - pat: string - } - community_name: string - project_repo: string - auth: Record - gov_public_url: string - gov_public_branch: string - gov_private_url?: string - gov_private_branch?: string - member_public_url: string - member_public_branch: string - member_private_url: string - member_private_branch: string -} - -export type ConfigMeta = { - communityUrl: string - name: string - path: string - projectUrl: string -} - -export abstract class AbstractConfigService { - public abstract getAvailableConfigs(): Promise - public abstract selectConfig(communityUrl: string): Promise - public abstract getConfig(): Promise - public abstract createOrUpdateConfig( - config: Partial, - ): Promise - public abstract validateSettings(): Promise -} diff --git a/src/shared/services/Service.ts b/src/shared/services/Service.ts index 2bf8c2d..9fafacd 100644 --- a/src/shared/services/Service.ts +++ b/src/shared/services/Service.ts @@ -9,6 +9,9 @@ export type ServiceId = | 'log' | 'db' | 'appUpdater' + | 'community' + | 'settings' + | 'cache' export type ObjectProxy = { [P in keyof T]: ServiceProxy diff --git a/src/shared/services/SettingsService.ts b/src/shared/services/SettingsService.ts new file mode 100644 index 0000000..8828af7 --- /dev/null +++ b/src/shared/services/SettingsService.ts @@ -0,0 +1,26 @@ +export type Config = { + notice: string + configPath: string + cache_dir?: string + cache_ttl_seconds?: number + user: { + username: string + pat: string + } + community_name: string + project_repo: string + auth: Record + gov_public_url: string + gov_public_branch: string + gov_private_url?: string + gov_private_branch?: string + member_public_url: string + member_public_branch: string + member_private_url: string + member_private_branch: string +} + +export abstract class AbstractSettingsService { + public abstract validateConfig(): Promise + public abstract generateConfig(): Promise +} diff --git a/src/shared/services/UserService.ts b/src/shared/services/UserService.ts index 5996aa6..6be0995 100644 --- a/src/shared/services/UserService.ts +++ b/src/shared/services/UserService.ts @@ -1,12 +1,18 @@ export type User = { + communityId: string username: string pat: string - voting_credits: number - voting_score: number - is_maintainer: boolean - is_member: boolean + votingCredits: number + votingScore: number + isMaintainer: boolean + isMember: boolean + memberPublicUrl: string + memberPublicBranch: string + memberPrivateUrl: string + memberPrivateBranch: string } export abstract class AbstractUserService { + public abstract authenticate(username: string, pat: string): Promise public abstract getUser(): Promise } diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts index 70996bc..a202ddd 100644 --- a/src/shared/services/index.ts +++ b/src/shared/services/index.ts @@ -1,6 +1,8 @@ export * from './Service.js' export * from './BallotService.js' -export * from './ConfigService.js' export * from './UserService.js' export * from './LogService.js' export * from './AppUpdaterService.js' +export * from './CommunityService.js' +export * from './SettingsService.js' +export * from './CacheService.js' diff --git a/test/BallotService.test.ts b/test/BallotService.test.ts new file mode 100644 index 0000000..d8c9abc --- /dev/null +++ b/test/BallotService.test.ts @@ -0,0 +1,94 @@ +import { beforeAll, describe, expect, test } from '@jest/globals' + +import { + BallotService, + GitService, + Gov4GitService, + Services, + UserService, +} from '../src/electron/services/index.js' +import { config } from './config.js' + +export default function run(services: Services) { + let userService: UserService + let govService: Gov4GitService + let ballotService: BallotService + let gitService: GitService + + describe('Ballots', () => { + beforeAll(async () => { + gitService = services.load('git') + govService = services.load('gov4git') + ballotService = services.load('ballots') + userService = services.load('user') + + await userService.loadUser() + const user = await userService.getUser() + + if (user == null) { + throw new Error(`No User to use`) + } + + const hasCommits = await gitService.hasCommits(config.communityUrl, user) + + if (!hasCommits) { + await govService.mustRun('init-gov') + } + + if (!user?.isMember) { + await govService.mustRun( + 'user', + 'add', + '--name', + config.user.username, + '--repo', + config.publicRepo, + '--branch', + 'main', + ) + } + await govService.mustRun( + 'balance', + 'add', + '--user', + config.user.username, + '--key', + 'voting_credits', + '--value', + '10', + ) + }) + test('Vote flow', async () => { + expect(true).toEqual(true) + await userService.loadUser() + const user1 = await userService.getUser() + if (user1 == null) { + throw new Error(`No user to load`) + } + let ballots = await ballotService.getBallots() + if (ballots.length === 0) { + await ballotService.createBallot({ + type: 'issues', + title: '12', + description: 'Testing', + }) + } + await ballotService.vote({ + name: `github/issues/12`, + choice: 'prioritize', + strength: '4', + }) + await ballotService.tallyBallot(`github/issues/12`) + await ballotService.loadBallots() + ballots = await ballotService.getBallots() + const user2 = await userService.getUser() + expect(ballots.length).toEqual(1) + const ballot = ballots[0] + if (ballot == null) { + throw new Error(`No ballots`) + } + expect(ballot.score).toEqual(2) + expect(user2?.votingCredits).toEqual(user1.votingCredits - 4) + }, 1200000) + }) +} diff --git a/test/CommunityService.test.ts b/test/CommunityService.test.ts new file mode 100644 index 0000000..26c9360 --- /dev/null +++ b/test/CommunityService.test.ts @@ -0,0 +1,61 @@ +import { beforeAll, describe, expect, test } from '@jest/globals' +import { eq } from 'drizzle-orm' + +import { DB } from '../src/electron/db/db.js' +import { communities } from '../src/electron/db/schema.js' +import { CommunityService, Services } from '../src/electron/services/index.js' +import { config } from './config.js' + +export default function run(services: Services) { + let communityService: CommunityService + let db: DB + + beforeAll(async () => { + communityService = services.load('community') + db = services.load('db') + }) + + describe('Validate Community URL', () => { + test('Invalid', async () => { + // Act + const errors = await communityService.validateCommunityUrl( + `${config.baseUrl}/${config.user.username}/repo-does-not-exist.git`, + ) + + // Assert + expect(errors[0]).toBeNull() + expect((errors[1] ?? []).length > 0).toEqual(true) + }) + + test('Valid', async () => { + // Act + const errors = await communityService.validateCommunityUrl( + config.communityUrl, + ) + + // Assert + expect(errors[0]).not.toBeNull() + expect(typeof errors[0] === 'string').toEqual(true) + expect(errors[0] !== '').toEqual(true) + expect((errors[1] ?? []).length).toEqual(0) + }) + }) + + describe('Inserting new community', () => { + test('Insert', async () => { + const errors = await communityService.insertCommunity(config.projectRepo) + expect((errors[1] ?? []).length).toEqual(0) + + const selectedCommunity = ( + await db + .select() + .from(communities) + .where(eq(communities.selected, true)) + .limit(1) + )[0] + + expect(selectedCommunity).not.toBeUndefined() + expect(selectedCommunity?.projectUrl).toEqual(config.projectRepo) + }) + }) +} diff --git a/test/ConfigService.test.ts b/test/ConfigService.test.ts deleted file mode 100644 index 6a2e37d..0000000 --- a/test/ConfigService.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import 'dotenv/config' - -import { afterAll, beforeAll, describe, expect, test } from '@jest/globals' -import { existsSync } from 'fs' -import { rm } from 'fs/promises' -import { resolve } from 'path' - -import { DB, loadDb } from '../src/electron/db/db.js' -import { migrateDb } from '../src/electron/db/migrate.js' -import { - ConfigService, - GitService, - type GitUserInfo, - Gov4GitService, - LogService, - Services, -} from '../src/electron/services/index.js' - -const user: GitUserInfo = { - username: process.env['GH_USER']!, - pat: process.env['GH_TOKEN']!, -} -const baseUrl = 'https://github.com' -const identityName = 'test-gov4git-identity' -const projectRepo = `${baseUrl}/${user.username}/test-gov4git-project-repo` -const communityUrl = `${projectRepo}-gov.public.git` -const publicRepo = `${baseUrl}/${user.username}/${identityName}-public.git` -const privateRepo = `${baseUrl}/${user.username}/${identityName}-private.git` - -const configDir = resolve(__dirname, 'config') -const dbPath = resolve(configDir, 'gov4git.db') -const services = new Services() -let dbService: DB -let loggingService: LogService -let gitService: GitService -let govService: Gov4GitService -let configService: ConfigService - -beforeAll(async () => { - dbService = await loadDb(dbPath) - await migrateDb(dbPath) - services.register('db', dbService) - loggingService = new LogService(resolve(configDir, 'logs.txt')) - services.register('log', loggingService) - gitService = new GitService() - services.register('git', gitService) - govService = new Gov4GitService(services) - services.register('gov4git', govService) - configService = new ConfigService(services, configDir, identityName) - await gitService.initializeRemoteRepo(projectRepo, user, false, true) - await gitService.initializeRemoteRepo(communityUrl, user, false, true) -}, 30000) - -afterAll(async () => { - await gitService.deleteRepo(projectRepo, user) - await gitService.deleteRepo(communityUrl, user) - await gitService.deleteRepo(publicRepo, user) - await gitService.deleteRepo(privateRepo, user) - loggingService.close() - dbService.close() - await rm(configDir, { recursive: true, force: true }) -}, 30000) - -describe('Setup and teardown', () => { - test('Setup', async () => { - // Act - const shouldNotExist = !(await gitService.doesRemoteRepoExist( - publicRepo, - user, - )) - const shouldNotExist2 = !(await gitService.doesRemoteRepoExist( - privateRepo, - user, - )) - await configService.createOrUpdateConfig({ - user: user, - project_repo: projectRepo, - }) - const shouldExist = await gitService.doesRemoteRepoExist(publicRepo, user) - const shouldExist2 = await gitService.doesRemoteRepoExist(privateRepo, user) - // Assert - expect(shouldNotExist).toEqual(true) - expect(shouldNotExist2).toEqual(true) - expect(shouldExist).toEqual(true) - expect(shouldExist2).toEqual(true) - }, 30000) - - test('teardown', async () => { - // Act - const availableConfigs = await configService.getAvailableConfigs() - const existingInd = availableConfigs.findIndex( - (c) => c.communityUrl === communityUrl, - ) - const selectedConfigPath = availableConfigs[existingInd]!.path - await configService.selectConfig(communityUrl) - const shouldNotBeNull = await configService.getConfig() - - const fileShouldExist = existsSync(selectedConfigPath) - - await configService.deleteConfig(projectRepo) - const nowAvailableConfigs = await configService.getAvailableConfigs() - const nonExisitingInd = nowAvailableConfigs.findIndex( - (c) => c.communityUrl === communityUrl, - ) - const fileShouldNotExist = !existsSync(selectedConfigPath) - await configService.selectConfig(communityUrl) - const shouldBeNull = await configService.getConfig() - // Assert - expect(existingInd).not.toEqual(-1) - expect(shouldNotBeNull).not.toBeNull() - expect(fileShouldExist).toEqual(true) - expect(nonExisitingInd).toEqual(-1) - expect(fileShouldNotExist).toEqual(true) - expect(shouldBeNull).toBeNull() - - // Cleanup - await gitService.deleteRepo(publicRepo, user) - await gitService.deleteRepo(privateRepo, user) - const noLongerExists = !(await gitService.doesRemoteRepoExist( - publicRepo, - user, - )) - const noLongerExists2 = !(await gitService.doesRemoteRepoExist( - publicRepo, - user, - )) - expect(noLongerExists).toEqual(true) - expect(noLongerExists2).toEqual(true) - }, 30000) -}) - -describe('Partial Initialization', () => { - test('Initialize from existing repos', async () => { - // Act - await gitService.initializeRemoteRepo(publicRepo, user, false) - await gitService.initializeRemoteRepo(privateRepo, user, true) - const shouldExist = await gitService.doesRemoteRepoExist(publicRepo, user) - const shouldExist2 = await gitService.doesRemoteRepoExist(privateRepo, user) - const shouldNotHaveCommits = !(await gitService.hasCommits( - publicRepo, - user, - )) - const shouldNotHaveCommits2 = !(await gitService.hasCommits( - privateRepo, - user, - )) - - await configService.createOrUpdateConfig({ - user: user, - project_repo: projectRepo, - }) - await new Promise((res) => { - setTimeout(res, 5000) - }) - const shouldHaveCommits = await gitService.hasCommits(publicRepo, user) - const shouldHaveCommits2 = await gitService.hasCommits(privateRepo, user) - - // Assert - expect(shouldExist).toEqual(true) - expect(shouldExist2).toEqual(true) - expect(shouldNotHaveCommits).toEqual(true) - expect(shouldNotHaveCommits2).toEqual(true) - expect(shouldHaveCommits).toEqual(true) - expect(shouldHaveCommits2).toEqual(true) - }, 30000) -}) diff --git a/test/GitService.test.ts b/test/GitService.test.ts index dcc0eca..3904b5c 100644 --- a/test/GitService.test.ts +++ b/test/GitService.test.ts @@ -1,6 +1,8 @@ import 'dotenv/config' -import { expect, test } from '@jest/globals' +import { describe } from 'node:test' + +import { beforeAll, expect, test } from '@jest/globals' import { GitService, @@ -16,51 +18,67 @@ const user: GitUserInfo = { const baseUrl = 'https://github.com' const projectRepo = `${baseUrl}/${user.username}/test-gov4git-creating-deleting-repos` -test('Does public repo exist', async () => { - // Act - const shouldNotExist = !(await gitService.doesPublicRepoExist(projectRepo)) - - // Assert - expect(shouldNotExist).toEqual(true) -}) - -test('Create Repo', async () => { - // Arrange - await gitService.initializeRemoteRepo(projectRepo, user, false, true) - - // Act - const shouldExist = await gitService.doesRemoteRepoExist(projectRepo, user) - - // Assert - expect(shouldExist).toEqual(true) -}) - -test('Get default branch', async () => { - // Act - const shouldBeMain = await gitService.getDefaultBranch(projectRepo, user) - - // Assert - expect(shouldBeMain).toEqual('main') -}) - -test('Has commits', async () => { - // Act - const shouldHaveCommits = await gitService.hasCommits(projectRepo, user) - - // Assert - expect(shouldHaveCommits).toEqual(true) -}) - -test('Remove Repo', async () => { - // Act - const shouldExist = await gitService.doesRemoteRepoExist(projectRepo, user) - await gitService.deleteRepo(projectRepo, user) - const noLongerExists = !(await gitService.doesRemoteRepoExist( - projectRepo, - user, - )) - - // Assert - expect(shouldExist).toEqual(true) - expect(noLongerExists).toEqual(true) -}) +export default function run() { + beforeAll(async () => { + await gitService.deleteRepo(projectRepo, user) + }, 30000) + + describe('Working with Repos', () => { + test('Does public repo exist', async () => { + // Act + const shouldNotExist = !(await gitService.doesPublicRepoExist( + projectRepo, + )) + + // Assert + expect(shouldNotExist).toEqual(true) + }) + + test('Create Repo', async () => { + // Arrange + await gitService.initializeRemoteRepo(projectRepo, user, false, true) + + // Act + const shouldExist = await gitService.doesRemoteRepoExist( + projectRepo, + user, + ) + + // Assert + expect(shouldExist).toEqual(true) + }) + + test('Get default branch', async () => { + // Act + const shouldBeMain = await gitService.getDefaultBranch(projectRepo, user) + + // Assert + expect(shouldBeMain).toEqual('main') + }) + + test('Has commits', async () => { + // Act + const shouldHaveCommits = await gitService.hasCommits(projectRepo, user) + + // Assert + expect(shouldHaveCommits).toEqual(true) + }) + + test('Remove Repo', async () => { + // Act + const shouldExist = await gitService.doesRemoteRepoExist( + projectRepo, + user, + ) + await gitService.deleteRepo(projectRepo, user) + const noLongerExists = !(await gitService.doesRemoteRepoExist( + projectRepo, + user, + )) + + // Assert + expect(shouldExist).toEqual(true) + expect(noLongerExists).toEqual(true) + }) + }) +} diff --git a/test/SettingsService.test.ts b/test/SettingsService.test.ts new file mode 100644 index 0000000..894a1b8 --- /dev/null +++ b/test/SettingsService.test.ts @@ -0,0 +1,74 @@ +import { beforeAll, describe, expect, test } from '@jest/globals' +import { eq } from 'drizzle-orm' +import { existsSync } from 'fs' + +import { DB } from '../src/electron/db/db.js' +import { communities } from '../src/electron/db/schema.js' +import { + GitService, + Services, + SettingsService, +} from '../src/electron/services/index.js' +import { config } from './config.js' + +export default function run(services: Services) { + let settingsService: SettingsService + let gitService: GitService + let db: DB + + beforeAll(async () => { + settingsService = services.load('settings') + db = services.load('db') + gitService = services.load('git') + }) + + describe('Generate Config', () => { + test('Generate', async () => { + await settingsService.generateConfig() + + const selectedCommunity = ( + await db + .select() + .from(communities) + .where(eq(communities.selected, true)) + .limit(1) + )[0] + + if (selectedCommunity == null) { + throw new Error(`No selected community`) + } + + const shouldExist1 = existsSync(selectedCommunity.configPath) + const shouldExist2 = await gitService.doesRemoteRepoExist( + config.publicRepo, + config.user, + ) + const shouldExist3 = await gitService.doesRemoteRepoExist( + config.privateRepo, + config.user, + ) + const shouldHaveCommits1 = await gitService.hasCommits( + config.publicRepo, + config.user, + ) + const shouldHaveCommits2 = await gitService.hasCommits( + config.privateRepo, + config.user, + ) + + expect(shouldExist1).toEqual(true) + expect(shouldExist2).toEqual(true) + expect(shouldExist3).toEqual(true) + expect(shouldHaveCommits1).toEqual(true) + expect(shouldHaveCommits2).toEqual(true) + }, 30000) + }) + + describe('Validate', () => { + test('Validate', async () => { + const errors = await settingsService.validateConfig() + + expect(errors.length).toEqual(0) + }, 30000) + }) +} diff --git a/test/UserService.test.ts b/test/UserService.test.ts new file mode 100644 index 0000000..20227b6 --- /dev/null +++ b/test/UserService.test.ts @@ -0,0 +1,64 @@ +import { beforeAll, describe, expect, test } from '@jest/globals' + +import { + GitService, + Services, + UserService, +} from '../src/electron/services/index.js' +import { config } from './config.js' + +export default function run(services: Services) { + let gitService: GitService + let userService: UserService + + beforeAll(async () => { + gitService = services.load('git') + userService = services.load('user') + }) + + describe('Invalid user credentials', () => { + test('Missing fields', async () => { + // Act + const allFields = await userService.authenticate('', '') + + // Assert + expect(allFields.length).toEqual(2) + }) + + test('Invalid PAT token', async () => { + // Act + const errors = await userService.authenticate( + config.user.username, + 'NOT_A_TOKEN', + ) + + // Assert + expect(errors.length).toEqual(1) + }) + }) + + describe('Authenticating User', () => { + test('Authenticate', async () => { + // Act + const userErrors = await userService.authenticate( + config.user.username, + config.user.pat, + ) + expect(userErrors.length).toEqual(0) + + // Act + const shouldExist1 = await gitService.doesRemoteRepoExist( + config.publicRepo, + config.user, + ) + const shouldExist2 = await gitService.doesRemoteRepoExist( + config.privateRepo, + config.user, + ) + + // Assert + expect(shouldExist1).toEqual(true) + expect(shouldExist2).toEqual(true) + }, 30000) + }) +} diff --git a/test/config.ts b/test/config.ts new file mode 100644 index 0000000..ba4dfd9 --- /dev/null +++ b/test/config.ts @@ -0,0 +1,29 @@ +import 'dotenv/config' + +import { resolve } from 'path' + +const user = { + username: process.env['GH_USER']!, + pat: process.env['GH_TOKEN']!, +} + +const baseUrl = 'https://github.com' +const identityName = 'test-gov4git-identity' +const projectRepo = `${baseUrl}/${user.username}/test-gov4git-project-repo` +const communityUrl = `${projectRepo}-gov.public.git` +const publicRepo = `${baseUrl}/${user.username}/${identityName}-public.git` +const privateRepo = `${baseUrl}/${user.username}/${identityName}-private.git` + +const configDir = resolve(__dirname, 'config') +const dbPath = resolve(configDir, 'gov4git.db') +export const config = { + user, + baseUrl, + identityName, + projectRepo, + communityUrl, + publicRepo, + privateRepo, + configDir, + dbPath, +} diff --git a/test/testRunner.test.ts b/test/testRunner.test.ts new file mode 100644 index 0000000..88ddc50 --- /dev/null +++ b/test/testRunner.test.ts @@ -0,0 +1,95 @@ +import { afterAll, beforeAll, describe } from '@jest/globals' +import { rm } from 'fs/promises' +import { resolve } from 'path' + +import { DB, loadDb } from '../src/electron/db/db.js' +import { migrateDb } from '../src/electron/db/migrate.js' +import { + BallotService, + CommunityService, + GitService, + Gov4GitService, + LogService, + Services, + SettingsService, + UserService, +} from '../src/electron/services/index.js' +// eslint-disable-next-line +import runBallotTests from './BallotService.test' +// eslint-disable-next-line +import runCommunityTests from './CommunityService.test' +import { config } from './config.js' +// eslint-disable-next-line +import runGitTests from './GitService.test' +// eslint-disable-next-line +import runSettingsTests from './SettingsService.test' +// eslint-disable-next-line +import runUserTests from './UserService.test' + +const services = new Services() + +beforeAll(async () => { + const dbService = await loadDb(config.dbPath) + await migrateDb(config.dbPath) + services.register('db', dbService) + const loggingService = new LogService(resolve(config.configDir, 'logs.txt')) + services.register('log', loggingService) + const gitService = new GitService() + services.register('git', gitService) + const govService = new Gov4GitService(services) + services.register('gov4git', govService) + const userService = new UserService({ + services, + identityRepoName: config.identityName, + }) + services.register('user', userService) + const ballotService = new BallotService({ + services, + }) + services.register('ballots', ballotService) + + const communityService = new CommunityService({ + services, + configDir: config.configDir, + }) + services.register('community', communityService) + + const settingsService = new SettingsService({ + services, + }) + services.register('settings', settingsService) + + await gitService.initializeRemoteRepo( + config.projectRepo, + config.user, + false, + true, + ) + await gitService.initializeRemoteRepo( + config.communityUrl, + config.user, + false, + true, + ) +}, 30000) + +afterAll(async () => { + const gitService = services.load('git') + const dbService = services.load('db') + const loggingService = services.load('log') + await gitService.deleteRepo(config.projectRepo, config.user) + await gitService.deleteRepo(config.communityUrl, config.user) + await gitService.deleteRepo(config.publicRepo, config.user) + await gitService.deleteRepo(config.privateRepo, config.user) + loggingService.close() + dbService.close() + await rm(config.configDir, { recursive: true, force: true }) +}, 30000) + +describe('Run tests', () => { + runGitTests() + runUserTests(services) + runCommunityTests(services) + runSettingsTests(services) + runBallotTests(services) +})