Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f59a66e
feat(github-status): add types and storage definitions for status ind…
cheefbird Mar 13, 2026
a2a4af7
test: add tests for github-status
cheefbird Mar 13, 2026
8609d18
feat: add github status data layer and background polling
cheefbird Mar 13, 2026
fd4dfdc
feat(content): add github status indicator UI components
cheefbird Mar 13, 2026
db050d6
feat(github-status): add github status notification settings
cheefbird Mar 13, 2026
7d54cd1
fix: polish status indicator and options page
cheefbird Mar 14, 2026
13aab4e
fix(github-status): support signed-out github and harden dismissed state
cheefbird Mar 14, 2026
7c1fc2a
fix: lint and format
cheefbird Mar 14, 2026
bd75ed0
refactor(status): simplify dismiss to collapsed boolean and redesign …
cheefbird Mar 14, 2026
1b2d4b2
fix: layout
cheefbird Mar 14, 2026
6737303
chore: add a biome check script command
cheefbird Mar 14, 2026
2b0210c
refactor(github-status): new look for collapsed indicator plus moving…
cheefbird Mar 14, 2026
a27ab71
fix(github-status): fixes critical items from code review bot
cheefbird Mar 14, 2026
0794ffc
fix(github-status): harden error handling and validate API boundary
cheefbird Mar 14, 2026
e7db7a1
refactor(github-status): review bot feedback, clean and expand test c…
cheefbird Mar 14, 2026
422f6b5
fix: biome wasn't formatting html
cheefbird Mar 14, 2026
9201f78
chore: ignore big biome check fixes commit
cheefbird Mar 14, 2026
90440cd
chore: remove unused svg's
cheefbird Mar 14, 2026
338ad28
refactor(github-status): replace remount pattern with reactive wrapper
cheefbird Mar 14, 2026
327ef07
refactor(github-status): use Primer css and re-do timestamps use
cheefbird Mar 14, 2026
af0ee31
feat(github-status): setup resolved types and some logic
cheefbird Mar 14, 2026
1ff4e89
feat(github-status): add proper resolved state and adjust styles
cheefbird Mar 14, 2026
f9eab28
test: add tests for resolved state changes
cheefbird Mar 14, 2026
e4c3d94
refactor: add missing and remove extraneous tests
cheefbird Mar 14, 2026
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
1 change: 1 addition & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# bulk formatting/linting changes
fc574c991c642f2d2cafd3e4757b54c74b016239
422f6b52b895dec6579cf03087c644584346a6bc
8 changes: 6 additions & 2 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"formatter": {
"enabled": true,
"indentStyle": "space"
"indentStyle": "space",
"lineWidth": 80
},
"linter": {
"enabled": true,
Expand All @@ -31,7 +32,10 @@
}
],
"html": {
"experimentalFullSupportEnabled": true
"experimentalFullSupportEnabled": true,
"formatter": {
"enabled": true
}
},
"javascript": {
"formatter": {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"zip:firefox": "wxt zip -b firefox",
"check": "svelte-check --tsconfig ./tsconfig.json",
"format": "biome format",
"biocheck": "biome check",
"lint": "biome lint",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
15 changes: 0 additions & 15 deletions public/wxt.svg

This file was deleted.

1 change: 0 additions & 1 deletion src/assets/svelte.svg

This file was deleted.

68 changes: 68 additions & 0 deletions src/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,46 @@
import { getWorkflows } from "@/lib/github-api";
import {
collapsedStorage,
enabledStorage,
fetchGitHubStatus,
pollIntervalStorage,
statusStorage,
} from "@/lib/github-status";
import type { ExtensionMessage } from "@/lib/messages";

const ALARM_NAME = "github-status-poll";
const DEFAULT_POLL_MINUTES = 15;

function sanitizeInterval(value: unknown): number {
const num = Number(value);
if (!Number.isFinite(num) || num < 1) return DEFAULT_POLL_MINUTES;
return num;
}

async function pollGitHubStatus() {
const result = await fetchGitHubStatus();
if (!result.ok) return;

await statusStorage.setValue(result.data);

if (result.data.incidents.length === 0) {
await collapsedStorage.setValue(false);
}
}

async function startPolling() {
const enabled = await enabledStorage.getValue();
if (!enabled) return;

const interval = sanitizeInterval(await pollIntervalStorage.getValue());
await browser.alarms.create(ALARM_NAME, { periodInMinutes: interval });
await pollGitHubStatus();
}

async function stopPolling() {
await browser.alarms.clear(ALARM_NAME);
}

export default defineBackground(() => {
browser.runtime.onMessage.addListener(
(message: ExtensionMessage, _sender, sendResponse) => {
Expand All @@ -10,4 +50,32 @@ export default defineBackground(() => {
}
},
);

browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === ALARM_NAME) {
pollGitHubStatus().catch(() => {});
}
});

enabledStorage.watch((enabled) => {
if (enabled) {
startPolling().catch(() => {});
} else {
stopPolling().catch(() => {});
}
});

pollIntervalStorage.watch(async (interval) => {
try {
const enabled = await enabledStorage.getValue();
if (!enabled) return;
await browser.alarms.clear(ALARM_NAME);
await browser.alarms.create(ALARM_NAME, {
periodInMinutes: sanitizeInterval(interval),
});
await pollGitHubStatus();
} catch {}
});

startPolling().catch(() => {});
});
254 changes: 254 additions & 0 deletions src/entrypoints/github.content/StatusBanner.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
<script lang="ts">
import {
type GitHubStatusData,
indicatorColor,
RESOLVED_COLOR,
type StatusIndicator,
timeSince,
} from "@/lib/github-status";
import StatusPopover from "./StatusPopover.svelte";
import StatusStrip from "./StatusStrip.svelte";

let {
statusData,
collapsed,
oncollapse,
onexpand,
}: {
statusData: GitHubStatusData | null;
collapsed: boolean;
oncollapse: () => void;
onexpand: () => void;
} = $props();

let popoverOpen = $state(false);

let hasIncidents = $derived((statusData?.incidents.length ?? 0) > 0);

const SEVERITY_ORDER: StatusIndicator[] = [
"critical",
"major",
"minor",
"none",
];
// Returns "none" when only resolved incidents remain.
// The UI handles this via isResolved + RESOLVED_COLOR overrides.
let severity = $derived.by<StatusIndicator>(() => {
if (!statusData || !hasIncidents) return "none";
const active = statusData.incidents.filter((i) => i.status !== "resolved");
if (active.length === 0) return "none";
for (const level of SEVERITY_ORDER) {
if (active.some((i) => i.impact === level)) return level;
}
return statusData.indicator;
});

let affectedNames = $derived.by(() => {
const names = new Set<string>();
for (const incident of statusData?.incidents ?? []) {
if (incident.status === "resolved") continue;
for (const c of incident.components) {
names.add(c.name);
}
}
return [...names];
});

let summaryText = $derived.by(() => {
const names = affectedNames;
if (names.length === 0) return "";
if (names.length <= 3) return names.join(", ");
return `${names.slice(0, 3).join(", ")} +${names.length - 3} more`;
});

const STATUS_LABELS: Record<string, string> = {
investigating: "Investigating",
identified: "Identified",
monitoring: "Monitoring",
resolved: "Resolved",
};

let { statusLabel, startedTime, isResolved } = $derived.by(() => {
if (!statusData || !hasIncidents)
return { statusLabel: "", startedTime: "", isResolved: false };

// Prefer worst active incident
for (const level of SEVERITY_ORDER) {
const match = statusData.incidents.find(
(i) => i.impact === level && i.status !== "resolved",
);
if (match) {
return {
statusLabel: STATUS_LABELS[match.status] ?? match.status,
startedTime: match.started_at ? timeSince(match.started_at) : "",
isResolved: false,
};
}
}

// All resolved — use first resolved incident
const resolved = statusData.incidents.find((i) => i.status === "resolved");
if (resolved) {
const ts =
resolved.resolved_at || resolved.updated_at || resolved.started_at;
return {
statusLabel: "Resolved",
startedTime: ts ? timeSince(ts) : "",
isResolved: true,
};
}

const fallback = statusData.incidents[0];
return {
statusLabel: fallback?.status ?? "",
startedTime: fallback?.started_at ? timeSince(fallback.started_at) : "",
isResolved: false,
};
});

let accentColor = $derived(
isResolved ? RESOLVED_COLOR : indicatorColor(severity),
);

function handleCollapse() {
popoverOpen = false;
oncollapse();
}

function handleExpand() {
onexpand();
}

function handleTogglePopover() {
popoverOpen = !popoverOpen;
}

function handleClosePopover() {
popoverOpen = false;
}
</script>

{#if hasIncidents && !collapsed}
<div class="banner-root" role="status">
<div class="drawer-card">
<div class="accent-bar" style:background={accentColor}></div>
<span class="severity-dot" style:color={accentColor}>&#9679;</span>
<div class="info">
<span class="title"
>GitHub incident — {isResolved ? "Resolved" : summaryText}</span
>
<span class="subtitle"
>{statusLabel}
{#if startedTime}
&middot; {isResolved ? "" : "Started "}{startedTime}
{/if}</span
>
</div>
<span class="actions">
<button
type="button"
class="details-btn"
onclick={handleTogglePopover}
aria-expanded={popoverOpen}
>
Details
</button>
<button
type="button"
class="dismiss-btn"
onclick={handleCollapse}
aria-label="Dismiss"
>
&#x2715;
</button>
{#if popoverOpen && statusData}
<StatusPopover
incidents={statusData.incidents}
onclose={handleClosePopover}
/>
{/if}
</span>
</div>
</div>
{:else if hasIncidents && collapsed}
<StatusStrip {severity} resolved={isResolved} onexpand={handleExpand} />
{/if}

<style>
.banner-root {
display: flex;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

.drawer-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--bgColor-inset, #161b22);
border: 1px solid var(--borderColor-default, #30363d);
border-top: none;
border-radius: 0 0 8px 8px;
padding: 8px 16px;
box-shadow: var(--shadow-floating-small, 0 4px 12px rgba(0, 0, 0, 0.4));
}

.accent-bar {
width: 3px;
height: 32px;
border-radius: 2px;
flex-shrink: 0;
}

.severity-dot {
font-size: 14px;
flex-shrink: 0;
}

.info {
display: flex;
flex-direction: column;
gap: 2px;
}

.title {
color: var(--fgColor-default, #e6edf3);
font-weight: 500;
white-space: nowrap;
}

.subtitle {
color: var(--fgColor-muted, #8b949e);
font-size: 11px;
}

.actions {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
margin-left: 12px;
position: relative;
}

.details-btn {
background: var(--button-default-bgColor-rest, #21262d);
color: var(--button-default-fgColor-rest, #e6edf3);
padding: 3px var(--control-small-paddingInline-condensed, 0.5rem);
border-radius: var(--borderRadius-medium, 0.375rem);
font-size: 12px;
border: 1px solid var(--button-default-borderColor-rest, #30363d);
cursor: pointer;
font-family: inherit;
}

.dismiss-btn {
background: none;
border: none;
color: var(--fgColor-muted, #8b949e);
cursor: pointer;
font-size: 14px;
padding: 0;
line-height: 1;
}
</style>
Loading
Loading