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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@
## 0.1.0

- First release: tray app, native toasts, sounds, bell animation

- 20 s timeout on GitHub requests; a hung request no longer stalls polling
19 changes: 16 additions & 3 deletions src/core/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import type { GhEvent } from "./types";

const API = "https://api.github.com";

/** A request that never answers must not freeze the poll loop. */
const TIMEOUT_MS = 20_000;

async function timedFetch(input: string, init: RequestInit = {}): Promise<Response> {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
try {
return await fetch(input, { ...init, signal: ctl.signal });
} finally {
clearTimeout(timer);
}
}

/** Turn a user-facing target string into the endpoint we hit. */
export function endpointFor(target: string, login: string | null): string | null {
if (target === "@me") {
Expand Down Expand Up @@ -54,7 +67,7 @@ export async function fetchEvents(
token: string,
etag: string | null,
): Promise<FetchResult> {
const res = await fetch(url, { headers: headers(token, etag) });
const res = await timedFetch(url, { headers: headers(token, etag) });
const num = (k: string) => {
const v = res.headers.get(k);
return v === null ? null : Number(v);
Expand Down Expand Up @@ -84,7 +97,7 @@ export interface Profile {

/** Validate a token by asking who it belongs to. */
export async function whoAmI(token: string): Promise<Profile> {
const res = await fetch(`${API}/user`, { headers: headers(token) });
const res = await timedFetch(`${API}/user`, { headers: headers(token) });
if (!res.ok) throw new GhError(res.status, await res.text().catch(() => ""), null, null);
const data = (await res.json()) as { login: string; name: string | null; avatar_url: string | null };
return { login: data.login, name: data.name ?? null, avatarUrl: data.avatar_url ?? null };
Expand Down Expand Up @@ -115,7 +128,7 @@ export interface Suggestions {
/** Smart defaults for onboarding: the user's busiest repos and their orgs. */
export async function fetchSuggestions(token: string): Promise<Suggestions> {
const get = async (path: string) => {
const res = await fetch(`${API}${path}`, { headers: headers(token) });
const res = await timedFetch(`${API}${path}`, { headers: headers(token) });
if (!res.ok) throw new GhError(res.status, await res.text().catch(() => ""), null, null);
return res.json();
};
Expand Down
2 changes: 2 additions & 0 deletions src/core/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const dict = {
// poller
"poller.noToken": "Falta el token de GitHub",
"poller.rateLimit": "Rate limit de GitHub",
"poller.noAccess": "sin acceso o no existe (¿el token tiene el permiso repo?)",
"poller.more": "…y {n} evento más. Abre la app para verlos.",
"poller.morePlural": "…y {n} eventos más. Abre la app para verlos.",
// event phrases
Expand Down Expand Up @@ -268,6 +269,7 @@ const dict = {
"empty.body": "Pushes, PRs, reviews… from the repos you watch. The bell is ready.",
"poller.noToken": "GitHub token missing",
"poller.rateLimit": "GitHub rate limit",
"poller.noAccess": "no access or doesn't exist (does the token have the repo scope?)",
"poller.more": "…and {n} more event. Open the app to see it.",
"poller.morePlural": "…and {n} more events. Open the app to see them.",
"ev.push.title": "{who} pushed to {repo}",
Expand Down
16 changes: 12 additions & 4 deletions src/core/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export class Poller {
this.cb.onStatus({ kind: "polling" });
const fresh: Notice[] = [];
let remaining: number | null = null;
const failed: string[] = [];

for (const target of targets) {
const url = endpointFor(target, login);
Expand All @@ -126,9 +127,12 @@ export class Poller {
});
return;
}
const msg = e instanceof Error ? e.message : String(e);
this.cb.onStatus({ kind: "error", message: `${target}: ${msg}` });
// keep going with the other targets, one bad repo shouldn't kill the loop
// keep going with the other targets, one bad repo shouldn't kill
// the loop, but don't hide it behind a green "ok" either
const msg = e instanceof GhError && e.status === 404
? t("poller.noAccess")
: e instanceof Error ? e.message : String(e);
failed.push(`${target}: ${msg}`);
}
}

Expand All @@ -141,7 +145,11 @@ export class Poller {
await this.fire(unique);
this.cb.onNotices(unique);
}
this.cb.onStatus({ kind: "ok", at: new Date(), remaining });
if (failed.length) {
this.cb.onStatus({ kind: "error", message: failed.join(" · ") });
} else {
this.cb.onStatus({ kind: "ok", at: new Date(), remaining });
}
}

/** Returns notices for events newer than what we've seen for this target. */
Expand Down
Loading