Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 |
Expand All @@ -26,21 +31,23 @@ Issues, suggestions can be raised in the [issues tab](https://github.com/code-go
## Getting started

## List tasks

List all available tasks

```bash
task
```

### Install

Install all dependencies and tools

```bash
task go-install
task frontend-install
```

### Run
### Run

- Generates sqlc code
- Runs the generate command
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/lib/wailsjs/go/models.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/lib/wailsjs/go/watchtower/Service.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -35,10 +36,14 @@ export function GetProductRepos(arg1:number):Promise<Array<products.RepositoryDT

export function GetPullRequestByOrganisation(arg1:number):Promise<Array<products.PullRequestDTO>>;

export function GetPullRequestInsightsByOrg(arg1:number,arg2:string):Promise<insights.PullRequestInsights>;

export function GetSecurityByOrganisation(arg1:number):Promise<Array<products.SecurityDTO>>;

export function GetSecurityByProductID(arg1:number):Promise<Array<products.SecurityDTO>>;

export function GetSecurityInsightsByOrg(arg1:number,arg2:string):Promise<insights.SecurityInsights>;

export function GetUnreadNotifications():Promise<Array<notifications.Notification>>;

export function MarkNotificationAsRead(arg1:number):Promise<void>;
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/lib/wailsjs/go/watchtower/Service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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']();
}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/watchtower/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
121 changes: 121 additions & 0 deletions frontend/src/lib/watchtower/insights.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
72 changes: 72 additions & 0 deletions frontend/src/lib/watchtower/insights.svelte.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
9 changes: 8 additions & 1 deletion frontend/src/routes/(orgs)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -63,11 +64,17 @@
<LayoutDashboard size={24} />
{/snippet}
</NavItem>
<NavItem to="/insights" {expand} label="Insights">
{#snippet icon()}
<ChartArea size={24} />
{/snippet}
</NavItem>
<NavItem to="/products" {expand} label="Products">
{#snippet icon()}
<Package size={24} />
{/snippet}
</NavItem>

<NavItem to="/organisations" {expand} label="Organisations">
{#snippet icon()}
<Castle size={24} />
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/routes/(orgs)/dashboard/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
<div class="flex items-center gap-2">
<p class="text-xs text-muted-foreground">Last sync: {timeSince.date}</p>
<Button
onclick={(e: Event) => {
onclick={(e) => {
e.preventDefault();
searchBarOpen = !searchBarOpen;
}}
Expand Down
Loading