From 5bbb50bf0367de797b52e697b52304e7f9c6a466 Mon Sep 17 00:00:00 2001 From: Derek Worthen Date: Tue, 27 Feb 2024 07:44:35 -0800 Subject: [PATCH] Add policy service - Add policy info to GH issues - Add policy option to management of issues. --- .changeset/pink-dragons-kneel.md | 8 + migrations/0008_amusing_dark_beast.sql | 10 + migrations/meta/0008_snapshot.json | 430 ++++++++++++++++++ migrations/meta/_journal.json | 7 + src/electron/db/schema.ts | 18 + src/electron/main.ts | 4 + src/electron/services/CommunityService.ts | 62 ++- src/electron/services/PolicyService.ts | 94 ++++ src/electron/services/index.ts | 1 + .../community/manage/IssuesPanel.tsx | 66 ++- src/renderer/src/store/types.ts | 4 +- src/shared/services/CommunityService.ts | 4 +- src/shared/services/Service.ts | 1 + 13 files changed, 684 insertions(+), 25 deletions(-) create mode 100644 .changeset/pink-dragons-kneel.md create mode 100644 migrations/0008_amusing_dark_beast.sql create mode 100644 migrations/meta/0008_snapshot.json create mode 100644 src/electron/services/PolicyService.ts diff --git a/.changeset/pink-dragons-kneel.md b/.changeset/pink-dragons-kneel.md new file mode 100644 index 0000000..57e66ca --- /dev/null +++ b/.changeset/pink-dragons-kneel.md @@ -0,0 +1,8 @@ +--- +'gov4git-desktop-app': minor +--- + +Add policy service + +- Add policy info to GH issues +- Add policy option to management of issues. diff --git a/migrations/0008_amusing_dark_beast.sql b/migrations/0008_amusing_dark_beast.sql new file mode 100644 index 0000000..d19b555 --- /dev/null +++ b/migrations/0008_amusing_dark_beast.sql @@ -0,0 +1,10 @@ +CREATE TABLE `policies` ( + `id` integer PRIMARY KEY NOT NULL, + `title` text NOT NULL, + `communityUrl` text NOT NULL, + `motionType` text NOT NULL, + `description` text NOT NULL, + `githubLabel` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `policies_title_communityUrl_unique` ON `policies` (`title`,`communityUrl`); \ No newline at end of file diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..b60d0ab --- /dev/null +++ b/migrations/meta/0008_snapshot.json @@ -0,0 +1,430 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "30621018-f13e-41fd-8333-62cf05171123", + "prevId": "f208c0d6-275c-4ca7-bf44-024b94da7beb", + "tables": { + "appSettings": { + "name": "appSettings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schemaVersion": { + "name": "schemaVersion", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "communities": { + "name": "communities", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "privateUrl": { + "name": "privateUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "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 + }, + "isMember": { + "name": "isMember", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "isMaintainer": { + "name": "isMaintainer", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "joinRequestUrl": { + "name": "joinRequestUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joinRequestStatus": { + "name": "joinRequestStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userVotingCredits": { + "name": "userVotingCredits", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "motions": { + "name": "motions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "motionId": { + "name": "motionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ballotId": { + "name": "ballotId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "openedAt": { + "name": "openedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closedAt": { + "name": "closedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trackerUrl": { + "name": "trackerUrl", + "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 + }, + "userScore": { + "name": "userScore", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userStrength": { + "name": "userStrength", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "userVoted": { + "name": "userVoted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userVotePending": { + "name": "userVotePending", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "motions_motionId_ballotId_communityUrl_unique": { + "name": "motions_motionId_ballotId_communityUrl_unique", + "columns": [ + "motionId", + "ballotId", + "communityUrl" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "motionsFullTextSearch": { + "name": "motionsFullTextSearch", + "columns": { + "rowid": { + "name": "rowid", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trackerUrl": { + "name": "trackerUrl", + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "policies": { + "name": "policies", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "communityUrl": { + "name": "communityUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "motionType": { + "name": "motionType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "githubLabel": { + "name": "githubLabel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "policies_title_communityUrl_unique": { + "name": "policies_title_communityUrl_unique", + "columns": [ + "title", + "communityUrl" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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 692db14..d51e376 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1705330060343, "tag": "0007_shiny_mastermind", "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1708984378622, + "tag": "0008_amusing_dark_beast", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/electron/db/schema.ts b/src/electron/db/schema.ts index d6e022e..8fd5bb5 100644 --- a/src/electron/db/schema.ts +++ b/src/electron/db/schema.ts @@ -113,3 +113,21 @@ export const motionsFullTextSearch = sqliteTable('motionsFullTextSearch', { export type MotionsFullTextSearch = typeof motionsFullTextSearch.$inferSelect export type MotionsFullTextSearchInsert = typeof motionsFullTextSearch.$inferInsert + +export const policies = sqliteTable( + 'policies', + { + id: int('id').primaryKey(), + title: text('title').notNull(), + communityUrl: text('communityUrl').notNull(), + motionType: text('motionType', { enum: ['concern', 'proposal'] }).notNull(), + description: text('description').notNull(), + githubLabel: text('githubLabel').notNull(), + }, + (t) => ({ + uniqueId: unique().on(t.title, t.communityUrl), + }), +) + +export type Policy = typeof policies.$inferSelect +export type PolicyInsert = typeof policies.$inferInsert diff --git a/src/electron/main.ts b/src/electron/main.ts index 46e94c2..ee4f124 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -25,6 +25,7 @@ import { GitHubService, LogService, MotionService, + PolicyService, Services, ValidationService, } from './services/index.js' @@ -68,6 +69,9 @@ async function setup(): Promise { const gov4GitService = new Gov4GitService(services) services.register('gov4git', gov4GitService) + const policyService = new PolicyService({ services }) + services.register('policy', policyService) + const userService = new UserService({ services, identityRepoName: COMMUNITY_REPO_NAME, diff --git a/src/electron/services/CommunityService.ts b/src/electron/services/CommunityService.ts index 6e245eb..7e0c8a9 100644 --- a/src/electron/services/CommunityService.ts +++ b/src/electron/services/CommunityService.ts @@ -1,14 +1,20 @@ import { eq, sql } from 'drizzle-orm' import { resolve } from 'path' -import { AbstractCommunityService } from '~/shared' +import { AbstractCommunityService, Expand } from '~/shared' import { DB } from '../db/db.js' -import { communities, Community, User } from '../db/schema.js' +import { + communities, + type Community, + type Policy, + type User, +} from '../db/schema.js' import { RepoSegments, urlToRepoSegments } from '../lib/index.js' import { hashString, toResolvedPath } from '../lib/paths.js' -import { GitHubService } from './GitHubService.js' +import { GitHubService, IssueSearchResults } from './GitHubService.js' import { Gov4GitService } from './Gov4GitService.js' +import { PolicyService } from './PolicyService.js' import { Services } from './Services.js' import { SettingsService } from './SettingsService.js' import { UserService } from './UserService.js' @@ -38,7 +44,20 @@ export type IssueVotingCreditsArgs = { export type ManageIssueArgs = { communityUrl: string issueNumber: number + label: string } + +export type CommunityIssue = Expand< + IssueSearchResults & { + policy: Policy | null + } +> + +export type CommunityIssuesResponse = { + policies: Policy[] + issues: CommunityIssue[] +} + export class CommunityService extends AbstractCommunityService { private declare readonly services: Services private declare readonly configDir: string @@ -47,6 +66,7 @@ export class CommunityService extends AbstractCommunityService { private declare readonly settingsService: SettingsService private declare readonly govService: Gov4GitService private declare readonly gitHubService: GitHubService + private declare readonly policyService: PolicyService constructor({ services, configDir = '~/.gov4git' }: CommunityServiceOptions) { super() @@ -57,6 +77,7 @@ export class CommunityService extends AbstractCommunityService { this.userService = this.services.load('user') this.settingsService = this.services.load('settings') this.govService = this.services.load('gov4git') + this.policyService = this.services.load('policy') } public getCommunity = async (): Promise => { @@ -466,7 +487,9 @@ ${user.memberPublicBranch}` await this.govService.mustRun(command, community) } - public getCommunityIssues = async (communityUrl: string) => { + public getCommunityIssues = async ( + communityUrl: string, + ): Promise => { const user = await this.userService.getUser() if (user == null) { throw new Error( @@ -486,18 +509,45 @@ ${user.memberPublicBranch}` ) } + const policies = ( + await this.policyService.getPolicies(communityUrl) + ).filter((p) => p.motionType === 'concern') + const repoSegments = urlToRepoSegments(community.projectUrl) - return await this.gitHubService.searchRepoIssues({ + const issues = await this.gitHubService.searchRepoIssues({ repoOwner: repoSegments.owner, repoName: repoSegments.repo, token: user.pat, state: 'open', }) + + const communityIssues: CommunityIssue[] = issues.map((issue) => { + let policy: Policy | null = null + for (const label of issue.labels) { + policy = + policies.find((p) => { + return label.name === p.githubLabel + }) ?? null + if (policy != null) { + break + } + } + return { + ...issue, + policy, + } + }) + + return { + policies, + issues: communityIssues, + } } public manageIssue = async ({ communityUrl, issueNumber, + label, }: ManageIssueArgs) => { const user = await this.userService.getUser() if (user == null) { @@ -524,7 +574,7 @@ ${user.memberPublicBranch}` repo: repoSegments.repo, token: user.pat, issueNumber, - labels: ['gov4git:pmp-v1'], + labels: [label], }) } } diff --git a/src/electron/services/PolicyService.ts b/src/electron/services/PolicyService.ts new file mode 100644 index 0000000..e6b2144 --- /dev/null +++ b/src/electron/services/PolicyService.ts @@ -0,0 +1,94 @@ +import { eq, sql } from 'drizzle-orm' + +import { serialAsync } from '../../shared/index.js' +import { type DB } from '../db/db.js' +import { communities, type Community, policies, Policy } from '../db/schema.js' +import { Gov4GitService } from './Gov4GitService.js' +import { type Services } from './Services.js' + +export type PolicyServiceOptions = { + services: Services +} + +type Gov4GitPolicy = { + description: string + github_label: string + applies_to_concern: boolean + applies_to_proposal: boolean +} + +export class PolicyService { + private declare readonly services: Services + private declare readonly db: DB + private declare readonly govService: Gov4GitService + + constructor({ services }: PolicyServiceOptions) { + this.services = services + this.db = this.services.load('db') + this.govService = this.services.load('gov4git') + } + + private getCommunityByUrl = async (url: string) => { + const community = ( + await this.db + .select() + .from(communities) + .where(eq(communities.url, url)) + .limit(1) + )[0] + + return community ?? null + } + + private loadPolicies = serialAsync(async (community: Community) => { + const g4gCommand = ['motion', 'policies'] + + const policyData = await this.govService.mustRun< + Record + >(g4gCommand, community) + + for (const [title, data] of Object.entries(policyData)) { + await this.db.insert(policies).values({ + title, + communityUrl: community.url, + motionType: data.applies_to_concern ? 'concern' : 'proposal', + description: data.description, + githubLabel: data.github_label, + }) + } + }) + + public getPolicies = serialAsync( + async (communityUrl: string, skipCache = false): Promise => { + const community = await this.getCommunityByUrl(communityUrl) + + if (community == null) { + throw new Error(`404. Community not found for for ${communityUrl}`) + } + + if (skipCache) { + await this.db + .delete(policies) + .where(eq(policies.communityUrl, communityUrl)) + } + + const policyCount = ( + await this.db + .select({ + count: sql`count(*)`, + }) + .from(policies) + .where(eq(policies.communityUrl, community.url)) + )[0] + + if (policyCount == null || policyCount.count === 0) { + await this.loadPolicies(community) + } + + return await this.db + .select() + .from(policies) + .where(eq(policies.communityUrl, community.url)) + }, + ) +} diff --git a/src/electron/services/index.ts b/src/electron/services/index.ts index 612b7e8..4675fee 100644 --- a/src/electron/services/index.ts +++ b/src/electron/services/index.ts @@ -8,3 +8,4 @@ export * from './SettingsService.js' export * from './ValidationService.js' export * from './MotionService.js' export * from './GitHubService.js' +export * from './PolicyService.js' diff --git a/src/renderer/src/pages/dashboard/community/manage/IssuesPanel.tsx b/src/renderer/src/pages/dashboard/community/manage/IssuesPanel.tsx index e6f1128..3f5f6ff 100644 --- a/src/renderer/src/pages/dashboard/community/manage/IssuesPanel.tsx +++ b/src/renderer/src/pages/dashboard/community/manage/IssuesPanel.tsx @@ -1,5 +1,7 @@ import { Button, + Dropdown, + Option, Table, TableBody, TableCell, @@ -10,15 +12,15 @@ import { import { parse } from 'marked' import { type FC, memo, useCallback, useState } from 'react' -import type { IssueSearchResults } from '../../../../../../electron/services/index.js' +import { Policy } from '../../../../../../electron/db/schema.js' +import type { CommunityIssue } from '../../../../../../electron/services/index.js' import { Message } from '../../../../components/Message.js' import { useDataStore } from '../../../../store/store.js' import { useMessageStyles } from '../../../../styles/messages.js' import { useManageCommunityStyles } from './styles.js' -const isManaged = (issue: IssueSearchResults): boolean => { - const foundIndex = issue.labels.findIndex((i) => i.name === 'gov4git:pmp-v1') - return foundIndex !== -1 +const isManaged = (issue: CommunityIssue): boolean => { + return issue.policy != null } export const IssuesPanel: FC = memo(function IssuesPanel() { @@ -29,25 +31,36 @@ export const IssuesPanel: FC = memo(function IssuesPanel() { const selectedCommunity = useDataStore( (s) => s.communityManage.communityToManage, )! - const [selectedIssue, setSelectedIssue] = useState( + const [selectedIssue, setSelectedIssue] = useState( null, ) + const [selectedPolicy, setSelectedPolicy] = useState(null) const manageIssue = useDataStore((s) => s.communityManage.manageIssue) const issues = useDataStore((s) => s.communityManage.issues) + const selectPolicy = useCallback( + (policy: Policy) => { + console.log('POLICY') + console.log(policy) + setSelectedPolicy(policy) + }, + [setSelectedPolicy], + ) + const onSelect = useCallback( - (issue: IssueSearchResults) => { + (issue: CommunityIssue) => { setSelectedIssue(issue) }, [setSelectedIssue], ) const manage = useCallback(async () => { - if (selectedIssue != null) { + if (selectedIssue != null && selectedPolicy != null) { setLoading(true) await manageIssue({ communityUrl: selectedCommunity.url, issueNumber: selectedIssue.number, + label: selectedPolicy.githubLabel, }) setSuccessMessage( [ @@ -57,7 +70,13 @@ export const IssuesPanel: FC = memo(function IssuesPanel() { ) setLoading(false) } - }, [manageIssue, setLoading, selectedCommunity, selectedIssue]) + }, [ + manageIssue, + setLoading, + selectedCommunity, + selectedIssue, + selectedPolicy, + ]) const dismissMessage = useCallback(() => { setSuccessMessage('') @@ -75,7 +94,7 @@ export const IssuesPanel: FC = memo(function IssuesPanel() { {issues != null && - issues.map((i) => ( + issues.issues.map((i) => ( onSelect(i)} @@ -117,12 +136,29 @@ export const IssuesPanel: FC = memo(function IssuesPanel() { >
{!isManaged(selectedIssue) && ( - + <> + + {issues?.policies.map((p) => ( + + ))} + + + )} {isManaged(selectedIssue) && Managed with Gov4Git}
diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index ddd9761..c1504cd 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -13,7 +13,7 @@ import type { User, } from '../../../electron/db/schema.js' import type { - IssueSearchResults, + CommunityIssuesResponse, IssueVotingCreditsArgs, ManageIssueArgs, OrgMembershipInfo, @@ -117,7 +117,7 @@ export type CommunityManageStore = { communityManage: { communityToManage: Community | null users: UserCredits[] | null - issues: IssueSearchResults[] | null + issues: CommunityIssuesResponse | null setCommunity: (community: Community) => Promise issueVotingCredits: (credits: IssueVotingCreditsArgs) => Promise manageIssue: (args: ManageIssueArgs) => Promise diff --git a/src/shared/services/CommunityService.ts b/src/shared/services/CommunityService.ts index 447724e..6d72e5d 100644 --- a/src/shared/services/CommunityService.ts +++ b/src/shared/services/CommunityService.ts @@ -1,7 +1,7 @@ import type { Community } from '../../electron/db/schema.js' import type { + CommunityIssuesResponse, DeployCommunityArgs, - IssueSearchResults, IssueVotingCreditsArgs, ManageIssueArgs, UserCredits, @@ -22,6 +22,6 @@ export abstract class AbstractCommunityService { ): Promise public abstract getCommunityIssues( communityUrl: string, - ): Promise + ): Promise public abstract manageIssue(args: ManageIssueArgs): Promise } diff --git a/src/shared/services/Service.ts b/src/shared/services/Service.ts index 7a8e3fd..401538d 100644 --- a/src/shared/services/Service.ts +++ b/src/shared/services/Service.ts @@ -12,6 +12,7 @@ export type ServiceId = | 'validation' | 'motion' | 'github' + | 'policy' export type ObjectProxy = { [P in keyof T]: ServiceProxy