Skip to content
Open
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
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,13 @@ POSTHOG_HOST=https://eu.i.posthog.com
# 0 turns the sweep off entirely without a release, which is also what the e2e suite runs with:
# a timer reaching for GitHub in the background would make every spec's mocks a race.
INBOX_WARM_SWEEP_INTERVAL_MS=300000

# --- Daily digest ---
# How often the sweep looks for somebody whose chosen hour has come, in milliseconds. Not how
# often anybody is messaged: one digest per person per local day is settled by a conditional
# write whatever this is set to, so it only decides how soon after their hour they hear.
#
# A quarter hour is the coarsest that still honours zones offset by half and three-quarter hours.
# 0 turns it off entirely without a release, which is what the e2e suite runs with - and more so
# than the sweep above, because this timer posts to Slack rather than only reading.
DIGEST_SWEEP_INTERVAL_MS=900000
2 changes: 1 addition & 1 deletion backend/src/analytics/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,4 @@ export type AnalyticsEvent =
* the dashboard's test button and the message that proves a fresh connection works both go out
* the same pipe, and both are worth telling apart from a real poke rather than hiding.
*/
export type PokeTrigger = 'github_webhook' | 'test' | 'welcome';
export type PokeTrigger = 'github_webhook' | 'test' | 'welcome' | 'digest';
24 changes: 23 additions & 1 deletion backend/src/analytics/metrics-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ export type CounterName =
| 'proke.poke.delivered'
| 'proke.cache.lookups'
| 'proke.inbox.warmed'
| 'proke.inbox.warm.sweeps';
| 'proke.inbox.warm.sweeps'
| 'proke.digest.sent'
| 'proke.digest.sweeps';

/** Gauges. A value that moves both ways, read at a moment. */
export type GaugeName = 'proke.event_loop.delay';
Expand All @@ -61,6 +63,7 @@ export type GaugeName = 'proke.event_loop.delay';
*/
export type HistogramName =
| 'proke.inbox.warm.duration'
| 'proke.digest.duration'
| 'proke.webhook.duration'
| 'proke.poke.latency'
| 'proke.github.request.duration'
Expand Down Expand Up @@ -151,6 +154,22 @@ export type WarmOutcome = 'refreshed' | 'no_token' | 'github_unavailable' | 'fai
*/
export type WarmSweepOutcome = 'completed' | 'overlapped' | 'failed';

/**
* What one person's digest came to.
*
* `empty` is the healthy majority rather than a failure, and kept apart from `sent` because the
* ratio between them says whether the digest is worth sending at all. `claimed_already` on a
* single-instance deploy would mean the claim is not doing its job.
*/
export type DigestOutcome =
| 'sent'
| 'empty'
| 'claimed_already'
| 'no_token'
| 'github_unavailable'
| 'undeliverable'
| 'failed';

/**
* Which GitHub call this was. Hand-written labels rather than URLs, which carry ids.
*
Expand Down Expand Up @@ -250,6 +269,9 @@ export interface MetricAttributeMap {
// Undimensioned: there is one sweep, and splitting it by anything would only make the series
// smaller without making it answer a different question.
'proke.inbox.warm.duration': Record<string, never>;
'proke.digest.sent': { outcome: DigestOutcome };
'proke.digest.sweeps': { outcome: WarmSweepOutcome };
'proke.digest.duration': Record<string, never>;
'proke.event_loop.delay': { quantile: EventLoopQuantile };
'http.server.duration': { route: string; method: string; status: string };
}
Expand Down
3 changes: 3 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AuthCoreModule } from './auth/core/auth-core.module';
import { ConnectionsModule } from './connections/connections.module';
import { InboxModule } from './inbox/inbox.module';
import { InboxWarmModule } from './inbox/warm/inbox-warm.module';
import { DigestModule } from './notifications/digest/digest.module';
import { PokeSettingsModule } from './notifications/settings/poke-settings.module';
import { getEnvConfig } from './shared/configs/env-configs';
import { HttpMetricsModule } from './shared/http/http-metrics.middleware';
Expand All @@ -30,6 +31,8 @@ import { SlackEventsModule } from './webhooks/slack/slack-events.module';
// What kinds of poke somebody wants, account-wide. The delivery side of notifications is
// wired in under the webhook module; this is only the settings route.
PokeSettingsModule,
// The other scheduler: the daily list of what is still waiting on somebody.
DigestModule,
SlackModule,
GithubWebhookModule,
SlackEventsModule,
Expand Down
3 changes: 3 additions & 0 deletions backend/src/inbox/core/entities/inbox.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export interface InboxPullRequest {
title: string;
url: string;
isDraft: boolean;
/** ISO 8601, as GitHub gave it. When the pull request was opened, not when it last moved. */
createdAt: string;
changedFiles: number;
repositoryId: string;
repositoryFullName: string;
author: InboxAuthor;
Expand Down
6 changes: 6 additions & 0 deletions backend/src/inbox/dto/inbox.response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export class InboxPullRequestResponse {
@ApiProperty()
isDraft: boolean;

@ApiProperty({ description: 'ISO 8601. When the pull request was opened, not when it moved.' })
createdAt: string;

@ApiProperty({ description: 'How many files it touches.' })
changedFiles: number;

@ApiProperty({ description: "GitHub's numeric repository id, as a string" })
repositoryId: string;

Expand Down
35 changes: 25 additions & 10 deletions backend/src/inbox/github-inbox-data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface GithubInboxPullRequest {
* and it answers the question the ordering is actually asking - what moved most recently.
*/
updatedAt: string;
/**
* When it was opened, and the clock the digest ages rows by. Not `updatedAt`, which moves on
* every comment, so the row argued about hardest would read as the newest.
*/
createdAt: string;
changedFiles: number;
repositoryId: string;
repositoryFullName: string;
authorLogin: string;
Expand Down Expand Up @@ -91,17 +97,22 @@ export class GithubInboxDataService {
let response: Response;

try {
response = await githubFetch(this.metrics, 'graphql_inbox', 'https://api.github.com/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
response = await githubFetch(
this.metrics,
'graphql_inbox',
'https://api.github.com/graphql',
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: INBOX_QUERY,
variables: { yours: MAX_YOURS, waiting: MAX_WAITING, threads: MAX_THREADS },
}),
},
body: JSON.stringify({
query: INBOX_QUERY,
variables: { yours: MAX_YOURS, waiting: MAX_WAITING, threads: MAX_THREADS },
}),
});
);
} catch (error) {
this.logger.warn(`Could not reach GitHub for an inbox: ${error}`);
return null;
Expand Down Expand Up @@ -159,6 +170,8 @@ function normalizeSearch(search: any): GithubInboxPullRequest[] {
url: node.url ?? '',
isDraft: Boolean(node.isDraft),
updatedAt: node.updatedAt ?? '',
createdAt: node.createdAt ?? '',
changedFiles: Number(node.changedFiles ?? 0),
repositoryId: node.repository.id,
repositoryFullName: node.repository.nameWithOwner,
authorLogin: login,
Expand Down Expand Up @@ -231,6 +244,8 @@ fragment Row on PullRequest {
url
isDraft
updatedAt
createdAt
changedFiles
reviewDecision
repository { id nameWithOwner }
author { __typename login avatarUrl }
Expand Down
11 changes: 4 additions & 7 deletions backend/src/inbox/inbox-classifier.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {
GithubInboxPullRequest,
GithubInbox,
} from './github-inbox-data.service';
import { GithubInboxPullRequest, GithubInbox } from './github-inbox-data.service';
import {
InboxBuildFilters,
InboxViewFilters,
Expand Down Expand Up @@ -161,6 +158,8 @@ function toPullRequest(pullRequest: GithubInboxPullRequest): InboxPullRequest {
title: pullRequest.title,
url: pullRequest.url,
isDraft: pullRequest.isDraft,
createdAt: pullRequest.createdAt,
changedFiles: pullRequest.changedFiles,
repositoryId: pullRequest.repositoryId,
repositoryFullName: pullRequest.repositoryFullName,
author: {
Expand Down Expand Up @@ -224,9 +223,7 @@ function group(
keys: readonly InboxSectionKey[],
sectionOf: (pullRequest: GithubInboxPullRequest) => InboxSectionKey,
): InboxSectionContent[] {
const buckets = new Map<InboxSectionKey, GithubInboxPullRequest[]>(
keys.map((key) => [key, []]),
);
const buckets = new Map<InboxSectionKey, GithubInboxPullRequest[]>(keys.map((key) => [key, []]));

for (const pullRequest of pullRequests) {
buckets.get(sectionOf(pullRequest))?.push(pullRequest);
Expand Down
20 changes: 1 addition & 19 deletions backend/src/inbox/warm/inbox-warmer.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { MetricsService } from '../../analytics/metrics.service';
import { pool } from '../../shared/async/pool';
import { getEnvConfig } from '../../shared/configs/env-configs';
import { InboxWarmTarget, UserReadService } from '../../user/read/user-read.service';
import { buildFiltersOf } from '../core/entities/inbox-filters.interface';
Expand Down Expand Up @@ -198,25 +199,6 @@ export class InboxWarmerService implements OnApplicationBootstrap, OnModuleDestr
}
}

/**
* Runs `work` over `items`, at most `limit` at a time.
*
* Workers pulling from a shared cursor rather than fixed slices, so one slow user delays the
* next item and not a whole quarter of the list. Never rejects: `warm` handles its own
* failures, and a pool that threw would take the sweep down with it.
*/
async function pool<T>(items: T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {
let next = 0;

const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
for (let index = next++; index < items.length; index = next++) {
await work(items[index]);
}
});

await Promise.all(workers);
}

function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
36 changes: 35 additions & 1 deletion backend/src/notifications/core/poke-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,42 @@ export function isReviewRequestResolution(value: unknown): value is ReviewReques
export interface PokeSettings {
mutedTypes: NotificationType[];
reviewRequestResolution: ReviewRequestResolution;
digestEnabled: boolean;
/** Nought to twenty-three, read in the timezone on the user row. */
digestHour: number;
}

/** Opting in is already an explicit act; the useful default afterwards is everything. */
/**
* Opting in is already an explicit act; the useful default afterwards is everything.
*
* Except the digest, which is a message on a schedule rather than an answer to a webhook, and so
* is asked for rather than assumed.
*/
export const DEFAULT_POKE_SETTINGS: PokeSettings = {
mutedTypes: [],
reviewRequestResolution: 'any_review',
digestEnabled: false,
digestHour: 9,
};

export function isDigestHour(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 23;
}

/**
* A save, which is the whole set for the mutes and only what it mentions for the digest.
*
* Absence means different things: a type left out of `mutedTypes` is how unmuting is spelled,
* while a body without the digest fields is a client that predates them, and a stale tab must
* not be able to undo a schedule it has never heard of.
*/
export interface PokeSettingsUpdate {
mutedTypes: NotificationType[];
reviewRequestResolution: ReviewRequestResolution;
digestEnabled?: boolean;
digestHour?: number;
}

/**
* The settings as they sit on the user row: absent for anybody who has never moved a switch,
* and plain strings rather than members of the enum.
Expand All @@ -72,6 +100,8 @@ export const DEFAULT_POKE_SETTINGS: PokeSettings = {
export interface PokeStoredSettings {
mutedTypes?: string[];
reviewRequestResolution?: string;
digestEnabled?: boolean;
digestHour?: number;
}

/**
Expand All @@ -96,6 +126,10 @@ export function normalizePokeSettings(stored: PokeStoredSettings | null | undefi
reviewRequestResolution: isReviewRequestResolution(resolution)
? resolution
: DEFAULT_POKE_SETTINGS.reviewRequestResolution,
digestEnabled: stored?.digestEnabled === true,
digestHour: isDigestHour(stored?.digestHour)
? stored.digestHour
: DEFAULT_POKE_SETTINGS.digestHour,
};
}

Expand Down
Loading