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
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ it.effect("reports implemented tools separately from locally available executabl
checkedAt: github.versionAdvisory?.checkedAt ?? null,
message:
"This GitHub CLI version can briefly open terminal windows during background telemetry on Windows and is below the recommended security-fix release.",
notificationKey: "github-cli:security:2.97.0",
notificationKey: "github-cli:2.98.0",
actions: [
{
label: "Update now",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,42 @@ it.effect("turns latest-release fetch failures into a null version", () => {
assert.strictEqual(requestCount, 1);
}).pipe(Effect.provide(httpLayer));
});

it.effect("keys update notifications on the latest release, security floor or not", () =>
Effect.gen(function* () {
const item = (version: string): SourceControlProviderDiscoveryItem => ({
kind: "github",
label: "GitHub",
executable: "gh",
status: "available",
version: Option.some(`gh version ${version}`),
installHint: "Install GitHub CLI.",
detail: Option.none(),
auth: {
status: "authenticated",
account: Option.some("octocat"),
host: Option.some("github.com"),
detail: Option.none(),
},
});
const enrich = (version: string) =>
withSourceControlToolVersionAdvisory({
platform: "darwin",
packageManager: "homebrew",
canRunUpdate: true,
latestVersionResolver: () => Effect.succeed("2.98.0"),
item: item(version),
});

const behindLatest = yield* enrich("2.97.0");
assert.strictEqual(behindLatest.versionAdvisory?.status, "behind_latest");
assert.strictEqual(behindLatest.versionAdvisory?.notificationKey, "github-cli:2.98.0");

const belowSecurityFloor = yield* enrich("2.92.0");
assert.strictEqual(belowSecurityFloor.versionAdvisory?.status, "recommended_update");
assert.strictEqual(belowSecurityFloor.versionAdvisory?.notificationKey, "github-cli:2.98.0");

const current = yield* enrich("2.98.0");
assert.strictEqual(current.versionAdvisory?.notificationKey, null);
}),
);
35 changes: 31 additions & 4 deletions apps/server/src/sourceControl/SourceControlToolVersionAdvisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ const LatestGitHubReleaseResponse = Schema.Struct({
tag_name: Schema.String,
});

// The client keys toast dismissals on this. Keying on the latest release
// rather than the hard-coded security floor means a closed toast returns when
// the next release ships, the same way provider update prompts behave.
function updateNotificationKey(
target: SourceControlToolVersionTarget,
version: string | null,
fallbackVersion: string,
): string {
return `${target}:${version ?? fallbackVersion}`;
}

function nonEmpty(value: string | null | undefined): string | null {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
Expand Down Expand Up @@ -424,7 +435,11 @@ function createGitHubCliAdvisory(input: {
recommendedVersion: GH_SECURITY_VERSION,
checkedAt: input.checkedAt,
message: availabilityNote ? `${baseMessage} ${availabilityNote}` : baseMessage,
notificationKey: `github-cli:security:${GH_SECURITY_VERSION}`,
notificationKey: updateNotificationKey(
"github-cli",
input.latestVersion,
GH_SECURITY_VERSION,
),
actions,
});
}
Expand All @@ -451,7 +466,11 @@ function createGitHubCliAdvisory(input: {
recommendedVersion: input.latestVersion,
checkedAt: input.checkedAt,
message: availabilityNote ?? "A newer GitHub CLI version is available for this environment.",
notificationKey: null,
notificationKey: updateNotificationKey(
"github-cli",
input.latestVersion,
input.latestVersion,
),
actions,
});
}
Expand Down Expand Up @@ -503,7 +522,11 @@ function createGitForWindowsAdvisory(input: {
checkedAt: input.checkedAt,
message:
"This Git for Windows version is below the recommended security-fix release. The official updater may close open Git Bash windows during installation.",
notificationKey: `git-for-windows:security:${GIT_FOR_WINDOWS_SECURITY_VERSION}`,
notificationKey: updateNotificationKey(
"git-for-windows",
input.latestVersion,
GIT_FOR_WINDOWS_SECURITY_VERSION,
),
actions,
});
}
Expand All @@ -522,7 +545,11 @@ function createGitForWindowsAdvisory(input: {
checkedAt: input.checkedAt,
message:
"A newer Git for Windows release is available. The official updater may close open Git Bash windows during installation.",
notificationKey: null,
notificationKey: updateNotificationKey(
"git-for-windows",
input.latestVersion,
input.latestVersion,
),
actions,
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type {
SourceControlDiscoveryResult,
SourceControlToolVersionAdvisory,
} from "@threadlines/contracts";
import * as Option from "effect/Option";
import { describe, expect, it } from "vitest";

import {
collectSourceControlToolUpdateNotices,
sourceControlToolUpdateToastCopy,
} from "./SourceControlToolUpdateLaunchNotification.logic";

function advisory(
overrides: Partial<SourceControlToolVersionAdvisory>,
): SourceControlToolVersionAdvisory {
return {
status: "behind_latest",
severity: "info",
currentVersion: "2.97.0",
latestVersion: "2.98.0",
recommendedVersion: "2.98.0",
checkedAt: null,
message: null,
notificationKey: "github-cli:2.98.0",
actions: [],
...overrides,
};
}

function discovery(
input: {
readonly git?: SourceControlToolVersionAdvisory;
readonly github?: SourceControlToolVersionAdvisory;
} = {},
): SourceControlDiscoveryResult {
return {
versionControlSystems: [
{
kind: "git",
label: "Git",
implemented: true,
status: "available",
version: Option.some("git version 2.55.0.windows.3"),
installHint: "Install Git.",
detail: Option.none(),
...(input.git ? { versionAdvisory: input.git } : {}),
},
],
sourceControlProviders: [
{
kind: "github",
label: "GitHub",
status: "available",
version: Option.some("gh version 2.97.0"),
installHint: "Install GitHub CLI.",
detail: Option.none(),
auth: {
status: "authenticated",
account: Option.some("octocat"),
host: Option.some("github.com"),
detail: Option.none(),
},
...(input.github ? { versionAdvisory: input.github } : {}),
},
],
};
}

describe("collectSourceControlToolUpdateNotices", () => {
it("notifies for plain newer releases, not only security floors", () => {
const notices = collectSourceControlToolUpdateNotices({
discovery: discovery({
github: advisory({}),
git: advisory({ status: "current", notificationKey: null }),
}),
environmentKey: "environment:env-1",
});

expect(notices.map((notice) => notice.dismissalKey)).toEqual([
"environment:env-1:github-cli:2.98.0",
]);
});
});

describe("sourceControlToolUpdateToastCopy", () => {
it("reads as available for plain releases and recommended once a security floor is involved", () => {
const github = {
label: "GitHub",
advisory: advisory({}),
dismissalKey: "environment:env-1:github-cli:2.98.0",
};
const git = {
label: "Git",
advisory: advisory({
status: "recommended_update",
severity: "warning",
message: "This Git for Windows version is below the recommended security-fix release.",
notificationKey: "git-for-windows:2.56.0.windows.1",
}),
dismissalKey: "environment:env-1:git-for-windows:2.56.0.windows.1",
};

expect(sourceControlToolUpdateToastCopy([github])).toEqual({
type: "info",
title: "GitHub update available",
description: "A newer GitHub release is available.",
});
expect(sourceControlToolUpdateToastCopy([git])).toEqual({
type: "warning",
title: "Git update recommended",
description: "This Git for Windows version is below the recommended security-fix release.",
});
expect(sourceControlToolUpdateToastCopy([git, github])).toEqual({
type: "warning",
title: "2 source control updates recommended",
description: "Git and GitHub have newer releases, including a recommended security fix.",
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,27 @@ import type {

import { sourceControlToolAdvisoryDismissalKey } from "../sourceControlToolAdvisoryDismissal";

export interface SourceControlToolUpdateWarning {
export interface SourceControlToolUpdateNotice {
readonly label: string;
readonly advisory: SourceControlToolVersionAdvisory;
readonly dismissalKey: string;
}

export function collectSourceControlToolUpdateWarnings(input: {
/**
* Every git / GitHub CLI advisory the launch toast should mention: security
* floors and plain newer releases alike. The server marks notifiable
* advisories with a key derived from the latest release, so the dismissal
* expires on its own when the next release ships.
*/
export function collectSourceControlToolUpdateNotices(input: {
readonly discovery: SourceControlDiscoveryResult;
readonly environmentKey: string;
}): ReadonlyArray<SourceControlToolUpdateWarning> {
}): ReadonlyArray<SourceControlToolUpdateNotice> {
return [...input.discovery.versionControlSystems, ...input.discovery.sourceControlProviders]
.flatMap((item) => {
const advisory = item.versionAdvisory;
if (
advisory?.status !== "recommended_update" ||
advisory.severity !== "warning" ||
(advisory?.status !== "recommended_update" && advisory?.status !== "behind_latest") ||
advisory.notificationKey === null
) {
return [];
Expand All @@ -35,8 +40,40 @@ export function collectSourceControlToolUpdateWarnings(input: {
.sort((left, right) => left.dismissalKey.localeCompare(right.dismissalKey));
}

export function sourceControlToolUpdateWarningSetKey(
warnings: ReadonlyArray<SourceControlToolUpdateWarning>,
export function sourceControlToolUpdateNoticeSetKey(
notices: ReadonlyArray<SourceControlToolUpdateNotice>,
): string | null {
return warnings.length > 0 ? warnings.map((warning) => warning.dismissalKey).join("|") : null;
return notices.length > 0 ? notices.map((notice) => notice.dismissalKey).join("|") : null;
}

export interface SourceControlToolUpdateToastCopy {
readonly type: "warning" | "info";
readonly title: string;
readonly description: string;
}

/** Toast wording: security floors read as recommended, plain releases as available. */
export function sourceControlToolUpdateToastCopy(
notices: ReadonlyArray<SourceControlToolUpdateNotice>,
): SourceControlToolUpdateToastCopy {
const hasSecurityNotice = notices.some((notice) => notice.advisory.severity === "warning");
const verb = hasSecurityNotice ? "recommended" : "available";
const labels = notices.map((notice) => notice.label).join(" and ");

if (notices.length === 1) {
const notice = notices[0]!;
return {
type: hasSecurityNotice ? "warning" : "info",
title: `${notice.label} update ${verb}`,
description: notice.advisory.message ?? `A newer ${notice.label} release is available.`,
};
}

return {
type: hasSecurityNotice ? "warning" : "info",
title: `${notices.length} source control updates ${verb}`,
description: hasSecurityNotice
? `${labels} have newer releases, including a recommended security fix.`
: `${labels} have newer releases.`,
};
}
Loading
Loading