diff --git a/README.md b/README.md index 9ecc299..9ebf68a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # Watchtower -**NOTE**: Alpha version, release pipelines are WIP, if you're keen on running it locally you'll need to install [wails](https://wails.io/). +**NOTE**: Alpha version, release pipelines are WIP, if you're keen on running it locally you'll need to +install [wails](https://wails.io/). -Focused product view of your GitHub organisation. View repositories, pull requests, security vulnerabilities grouped by product. +Focused product view of your GitHub organisation. View repositories, pull requests, security vulnerabilities grouped by +product. ## Motivation -Working in organisations looking after multiple teams, it was hard to view the health of the product with multiple microservices. + +Working in organisations looking after multiple teams, it was hard to view the health of the product with multiple +microservices. Watch tower provides a lite weight approach to grouping information by product with additional filters. ## Features @@ -15,6 +19,7 @@ Watch tower provides a lite weight approach to grouping information by product w | Group products by organisations | Add multiple organisations and group products within those organisations | | Group repositories by Product | User github topics to group repos by products | | Dashboard view | Quickly view the overall health of a group of products by viewing open PRs, security vulnerabilities and repositories | +| Insights | Get insights at a org level on the for pull requests and security vulnerabilities | | Product view | Focused view for open PRs, security vulnerabilities and repositories | | Notification on new PR / Issue | Get notifications when new PRs or Issues are created in the watched repositories accross all orgs | | | Local only | Data will never leave your device, settings page includes a kill switch to remove all data from the device. GITHUB PAT token only required read access | @@ -26,6 +31,7 @@ Issues, suggestions can be raised in the [issues tab](https://github.com/code-go ## Getting started ## List tasks + List all available tasks ```bash @@ -33,6 +39,7 @@ task ``` ### Install + Install all dependencies and tools ```bash @@ -40,7 +47,7 @@ task go-install task frontend-install ``` -### Run +### Run - Generates sqlc code - Runs the generate command diff --git a/frontend/src/lib/wailsjs/go/models.ts b/frontend/src/lib/wailsjs/go/models.ts index 7ba645f..c5e4714 100755 --- a/frontend/src/lib/wailsjs/go/models.ts +++ b/frontend/src/lib/wailsjs/go/models.ts @@ -1,3 +1,50 @@ +export namespace insights { + + export class PullRequestInsights { + minDaysToMerge: number; + maxDaysToMerge: number; + avgDaysToMerge: number; + merged: number; + closed: number; + open: number; + + static createFrom(source: any = {}) { + return new PullRequestInsights(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.minDaysToMerge = source["minDaysToMerge"]; + this.maxDaysToMerge = source["maxDaysToMerge"]; + this.avgDaysToMerge = source["avgDaysToMerge"]; + this.merged = source["merged"]; + this.closed = source["closed"]; + this.open = source["open"]; + } + } + export class SecurityInsights { + minDaysToFix: number; + maxDaysToFix: number; + avgDaysToFix: number; + fixed: number; + open: number; + + static createFrom(source: any = {}) { + return new SecurityInsights(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.minDaysToFix = source["minDaysToFix"]; + this.maxDaysToFix = source["maxDaysToFix"]; + this.avgDaysToFix = source["avgDaysToFix"]; + this.fixed = source["fixed"]; + this.open = source["open"]; + } + } + +} + export namespace notifications { export class Notification { diff --git a/frontend/src/lib/wailsjs/go/watchtower/Service.d.ts b/frontend/src/lib/wailsjs/go/watchtower/Service.d.ts index 1484ec8..b692a73 100755 --- a/frontend/src/lib/wailsjs/go/watchtower/Service.d.ts +++ b/frontend/src/lib/wailsjs/go/watchtower/Service.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated. DO NOT EDIT import {organisations} from '../models'; import {products} from '../models'; +import {insights} from '../models'; import {notifications} from '../models'; import {context} from '../models'; @@ -35,10 +36,14 @@ export function GetProductRepos(arg1:number):Promise>; +export function GetPullRequestInsightsByOrg(arg1:number,arg2:string):Promise; + export function GetSecurityByOrganisation(arg1:number):Promise>; export function GetSecurityByProductID(arg1:number):Promise>; +export function GetSecurityInsightsByOrg(arg1:number,arg2:string):Promise; + export function GetUnreadNotifications():Promise>; export function MarkNotificationAsRead(arg1:number):Promise; diff --git a/frontend/src/lib/wailsjs/go/watchtower/Service.js b/frontend/src/lib/wailsjs/go/watchtower/Service.js index c6a0221..8166a4d 100755 --- a/frontend/src/lib/wailsjs/go/watchtower/Service.js +++ b/frontend/src/lib/wailsjs/go/watchtower/Service.js @@ -62,6 +62,10 @@ export function GetPullRequestByOrganisation(arg1) { return window['go']['watchtower']['Service']['GetPullRequestByOrganisation'](arg1); } +export function GetPullRequestInsightsByOrg(arg1, arg2) { + return window['go']['watchtower']['Service']['GetPullRequestInsightsByOrg'](arg1, arg2); +} + export function GetSecurityByOrganisation(arg1) { return window['go']['watchtower']['Service']['GetSecurityByOrganisation'](arg1); } @@ -70,6 +74,10 @@ export function GetSecurityByProductID(arg1) { return window['go']['watchtower']['Service']['GetSecurityByProductID'](arg1); } +export function GetSecurityInsightsByOrg(arg1, arg2) { + return window['go']['watchtower']['Service']['GetSecurityInsightsByOrg'](arg1, arg2); +} + export function GetUnreadNotifications() { return window['go']['watchtower']['Service']['GetUnreadNotifications'](); } diff --git a/frontend/src/lib/watchtower/index.ts b/frontend/src/lib/watchtower/index.ts index faddece..59bb62e 100644 --- a/frontend/src/lib/watchtower/index.ts +++ b/frontend/src/lib/watchtower/index.ts @@ -1,9 +1,11 @@ import { OrgService } from "$lib/watchtower/orgs.svelte"; import { ProductsService } from "$lib/watchtower/products.svelte"; import { NotificationsService } from "$lib/watchtower/notifications.svelte"; +import { InsightsService } from "$lib/watchtower/insights.svelte"; const orgSvc = new OrgService(); const productSvc = new ProductsService(); const notificationSvc = new NotificationsService(); +const insightsSvc = new InsightsService(); -export { orgSvc, productSvc, notificationSvc }; +export { orgSvc, productSvc, notificationSvc, insightsSvc }; diff --git a/frontend/src/lib/watchtower/insights.svelte.test.ts b/frontend/src/lib/watchtower/insights.svelte.test.ts new file mode 100644 index 0000000..7b5fe54 --- /dev/null +++ b/frontend/src/lib/watchtower/insights.svelte.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as wails from "$lib/wailsjs/go/watchtower/Service"; +import { insights } from "$lib/wailsjs/go/models"; +import { InsightsService } from "$lib/watchtower/insights.svelte"; + +describe("InsightsService", () => { + const spyGetPR = vi.spyOn(wails, "GetPullRequestInsightsByOrg"); + const spyGetSec = vi.spyOn(wails, "GetSecurityInsightsByOrg"); + + const mockPR = new insights.PullRequestInsights({ + merged: 10, + closed: 2, + open: 5, + avgDaysToMerge: 3.5, + minDaysToMerge: 1, + maxDaysToMerge: 10 + }); + + const mockSec = new insights.SecurityInsights({ + fixed: 8, + open: 3, + avgDaysToFix: 5.2, + minDaysToFix: 1, + maxDaysToFix: 15 + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + describe("initial state", () => { + it("should have default time window", () => { + const service = new InsightsService(); + expect(service.window).toBe("90"); + }); + + it("should not have insights initially", () => { + const service = new InsightsService(); + expect(service.hasInsights).toBe(false); + expect(service.prInsights).toBeUndefined(); + expect(service.secInsights).toBeUndefined(); + }); + }); + + describe("getInsights()", () => { + beforeEach(() => { + spyGetPR.mockResolvedValue(mockPR); + spyGetSec.mockResolvedValue(mockSec); + }); + + it("should fetch insights if not already present", async () => { + const service = new InsightsService(); + const result = await service.getInsights(1); + + expect(spyGetPR).toHaveBeenCalledWith(1, "90"); + expect(spyGetSec).toHaveBeenCalledWith(1, "90"); + expect(result.pr).toEqual(mockPR); + expect(result.sec).toEqual(mockSec); + expect(service.hasInsights).toBe(true); + }); + + it("should not refetch if not stale", async () => { + const service = new InsightsService(); + await service.getInsights(1); + expect(spyGetPR).toHaveBeenCalledTimes(1); + + await service.getInsights(1); + expect(spyGetPR).toHaveBeenCalledTimes(1); + }); + + it("should refetch if stale", async () => { + const service = new InsightsService(); + await service.getInsights(1); + expect(spyGetPR).toHaveBeenCalledTimes(1); + + vi.setSystemTime(new Date(Date.now() + 3 * 60 * 1000)); + + await service.getInsights(1); + expect(spyGetPR).toHaveBeenCalledTimes(2); + }); + }); + + describe("refresh()", () => { + beforeEach(() => { + spyGetPR.mockResolvedValue(mockPR); + spyGetSec.mockResolvedValue(mockSec); + }); + + it("should force refetch even if not stale", async () => { + const service = new InsightsService(); + await service.getInsights(1); + expect(spyGetPR).toHaveBeenCalledTimes(1); + + await service.refresh(1); + expect(spyGetPR).toHaveBeenCalledTimes(2); + }); + }); + + describe("hasInsights", () => { + it("should be true if only PR insights are present", async () => { + spyGetPR.mockResolvedValue(mockPR); + + const service = new InsightsService(); + await service.getInsights(1); + + expect(service.hasInsights).toBe(true); + expect(service.prInsights).toBeDefined(); + }); + + it("should be true if only Sec insights are present", async () => { + spyGetSec.mockResolvedValue(mockSec); + + const service = new InsightsService(); + await service.getInsights(1); + + expect(service.hasInsights).toBe(true); + expect(service.secInsights).toBeDefined(); + }); + }); +}); diff --git a/frontend/src/lib/watchtower/insights.svelte.ts b/frontend/src/lib/watchtower/insights.svelte.ts new file mode 100644 index 0000000..ddb5a94 --- /dev/null +++ b/frontend/src/lib/watchtower/insights.svelte.ts @@ -0,0 +1,72 @@ +import { insights } from "$lib/wailsjs/go/models"; +import { STALE_TIMEOUT_MINUTES } from "$lib/watchtower/types"; +import { + GetPullRequestInsightsByOrg, + GetSecurityInsightsByOrg +} from "$lib/wailsjs/go/watchtower/Service"; + +export class InsightsService { + #defaultTimeWindow = "90"; + #sec?: insights.SecurityInsights; + #pr?: insights.PullRequestInsights; + #lastSync?: number; + + hasInsights: boolean; + + constructor() { + this.#sec = $state(undefined); + this.#pr = $state(undefined); + this.hasInsights = $derived((!!this.#sec || !!this.#pr) ?? false); + } + + get window() { + return this.#defaultTimeWindow; + } + + get prInsights() { + return this.#pr; + } + + get secInsights() { + return this.#sec; + } + + async refresh(orgId: number) { + await this.forceGetInsights(orgId, this.#defaultTimeWindow); + + return { sec: this.#sec, pr: this.#pr }; + } + + async getInsights(orgId: number) { + if (!this.isStale()) { + return { sec: this.#sec, pr: this.#pr }; + } + + return this.refresh(orgId); + } + + private async forceGetInsights(orgId: number, timeWindow: string) { + await this.forceGetSec(orgId, timeWindow); + await this.forceGetPR(orgId, timeWindow); + } + + private async forceGetSec(orgId: number, timeWindow: string) { + this.#sec = await GetSecurityInsightsByOrg(orgId, timeWindow); + this.#lastSync = Date.now(); + } + + private async forceGetPR(orgId: number, timeWindow: string) { + this.#pr = await GetPullRequestInsightsByOrg(orgId, timeWindow); + + this.#lastSync = Date.now(); + } + + private isStale() { + if (!this.#lastSync) { + return true; + } + + const diff = (Date.now() - this.#lastSync) / (1000 * 60); + return diff > STALE_TIMEOUT_MINUTES; + } +} diff --git a/frontend/src/routes/(orgs)/+layout.svelte b/frontend/src/routes/(orgs)/+layout.svelte index ed3aca1..fad79fe 100644 --- a/frontend/src/routes/(orgs)/+layout.svelte +++ b/frontend/src/routes/(orgs)/+layout.svelte @@ -8,7 +8,8 @@ PanelLeftClose, PanelLeftOpen, MessageSquare, - MessageSquareDot + MessageSquareDot, + ChartArea } from "@lucide/svelte"; import { cn } from "$lib/utils"; import { NavItem, NavHeader } from "$components/nav/index.js"; @@ -63,11 +64,17 @@ {/snippet} + + {#snippet icon()} + + {/snippet} + {#snippet icon()} {/snippet} + {#snippet icon()} diff --git a/frontend/src/routes/(orgs)/dashboard/+page.svelte b/frontend/src/routes/(orgs)/dashboard/+page.svelte index 25fc0a1..5d4f27f 100644 --- a/frontend/src/routes/(orgs)/dashboard/+page.svelte +++ b/frontend/src/routes/(orgs)/dashboard/+page.svelte @@ -47,7 +47,7 @@

Last sync: {timeSince.date}

+ + +
+ + + + + Pull Request Insights + + + Statistics for merged, closed, and open pull requests over the last {insightWindow} + days. + + + + {#if prInsights} +
+
+

Total Merged

+
+ +

{prInsights.merged}

+
+
+
+

Total Closed

+
+ +

{prInsights.closed}

+
+
+
+

Currently Open

+
+ +

{prInsights.open}

+
+
+
+

Avg. Time to Merge

+
+ +

+ {formatDays(prInsights.avgDaysToMerge)} +

+
+
+
+ +
+
+

Min. Time to Merge

+

+ {formatDays(prInsights.minDaysToMerge)} +

+
+
+

Max. Time to Merge

+

+ {formatDays(prInsights.maxDaysToMerge)} +

+
+
+ {:else} +
+

No PR insights available

+
+ {/if} +
+
+ + + + + + Security Insights + + + Vulnerability status and remediation metrics for the last {insightWindow} days. + + + + {#if secInsights} +
+
+

Fixed Vulnerabilities

+
+ +

{secInsights.fixed}

+
+
+
+

Open Vulnerabilities

+
+ +

{secInsights.open}

+
+
+
+

Avg. Time to Fix

+
+ +

+ {formatDays(secInsights.avgDaysToFix)} +

+
+
+
+ +
+
+

Min. Time to Fix

+

+ {formatDays(secInsights.minDaysToFix)} +

+
+
+

Max. Time to Fix

+

+ {formatDays(secInsights.maxDaysToFix)} +

+
+
+ {:else} +
+

No security insights available

+
+ {/if} +
+
+
+
diff --git a/frontend/src/routes/(orgs)/insights/+page.ts b/frontend/src/routes/(orgs)/insights/+page.ts new file mode 100644 index 0000000..7287f74 --- /dev/null +++ b/frontend/src/routes/(orgs)/insights/+page.ts @@ -0,0 +1,20 @@ +import type { PageLoad } from "./$types"; +import { orgSvc, insightsSvc } from "$lib/watchtower"; + +export const load: PageLoad = async () => { + await orgSvc.getDefault(); + const orgId = orgSvc.defaultOrg?.id; + + if (orgId) { + await insightsSvc.refresh(orgId); + } + + return { + organisation: orgSvc.defaultOrg, + insights: { + pr: insightsSvc.prInsights, + sec: insightsSvc.secInsights, + window: insightsSvc.window + } + }; +};