mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-30 22:21:58 +01:00
feat: own, organization and starred switches for new repository notifications (#468)
Adds notifyOnNewOwnRepo, notifyOnNewOrgRepo and notifyOnNewStarredRepo under the existing new repository switch. They default to on, so saved configs keep their current behavior without a migration.
This commit is contained in:
1 parent
8edf91b570
commit
15bb4a671c
11 files changed
+268
-43
No files matched your search
@@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Raised the dependency floors for the advisories published on 2026-09-08: Astro 7.2.8 (remote code execution through AVIF image optimization, and an authorization bypass when stripping the configured base), @xmldom/xmldom 0.8.15 (eight parser and serializer issues), sharp 0.35.4 (libheif), svgo 4.1.0 (removeScripts sanitization) and js-yaml 4.3.2 (merge-key CPU use). Applied to both the application and the documentation site.
|
||||
|
||||
### Added
|
||||
- Notifications for newly discovered repositories
|
||||
- "New repositories discovered" on the Notifications page, with three sub-switches under it: new repositories in your own account, in one of your organizations, and starred repositories
|
||||
- The scheduled sync, the boot auto-start and Sync Organization send them once per group with the repositories that were imported; all stars share a single "Starred" alert because their owners are arbitrary, and a long list is cut to the first 10 names plus an "and N more" line
|
||||
- The stored configuration gains `notifyOnNewOwnRepo`, `notifyOnNewOrgRepo` and `notifyOnNewStarredRepo` (default on) below the existing `notifyOnNewRepo` master switch; no migration is needed because they live inside the existing JSON column, and a row saved before them behaves as all three on
|
||||
- List view for the Organizations page (#428)
|
||||
- A cards/list switch in the toolbar; the list shows one row per organization with its role, destination, repository counts, last mirrored time, status, the same actions as the cards and the source and destination links, so a few dozen organizations fit on one screen
|
||||
- The choice is remembered per browser, next to the theme and time format preferences
|
||||
|
||||
@@ -107,9 +107,12 @@ The secret is encrypted at rest.
|
||||
|-------|---------|-------------|
|
||||
| Sync errors | On | A mirror job failed |
|
||||
| Sync success | Off | A mirror job completed successfully |
|
||||
| New repo discovered | Off | New repositories were discovered and imported |
|
||||
| New repositories discovered | Off | New repositories were discovered and imported; the three switches below select which ones |
|
||||
| In your account | On | Repositories owned by your account |
|
||||
| In an organization | On | Repositories of one of your organizations |
|
||||
| Starred repositories | On | Repositories you starred, all stars in one alert |
|
||||
|
||||
The "New repo discovered" event is sent once per owner (account or organization) per discovery pass (scheduled sync, boot auto-start and the manual "Mirror Organization" sync), listing the repositories that were imported. Starred repositories are the exception: they arrive as a single "Starred" notification because their owners are arbitrary. A large import is capped to the first 10 names plus an "and N more" line so one pass never floods your provider.
|
||||
The three group switches are sub-options of "New repositories discovered": they only apply while that switch is on, and a configuration saved before they existed counts as all three on (they are keys inside the stored settings JSON, so no database migration is involved). Each enabled group is sent once per group per discovery pass (scheduled sync, boot auto-start and the manual "Mirror Organization" sync), listing the repositories that were imported: one alert per account, one per organization, and a single "Starred" alert for every star because their owners are arbitrary. A large import is capped to the first 10 names plus an "and N more" line so one pass never floods your provider.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@ export function ConfigTabs() {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: false,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: true,
|
||||
notifyOnNewOrgRepo: true,
|
||||
notifyOnNewStarredRepo: true,
|
||||
},
|
||||
});
|
||||
const { user } = useAuth();
|
||||
@@ -614,8 +617,10 @@ export function ConfigTabs() {
|
||||
},
|
||||
advancedOptions:
|
||||
response.advancedOptions || prev.advancedOptions,
|
||||
notificationConfig:
|
||||
(response as any).notificationConfig || prev.notificationConfig,
|
||||
notificationConfig: {
|
||||
...prev.notificationConfig,
|
||||
...((response as any).notificationConfig ?? {}), // Merge so a row saved before a switch existed keeps its default
|
||||
},
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Bell, Activity, Send, Info } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { NotificationConfig } from "@/types/config";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
SettingsCard,
|
||||
SectionTitle,
|
||||
@@ -502,8 +503,8 @@ export function NotificationSettings({
|
||||
<CardDivider />
|
||||
<CardSection>
|
||||
<SwitchRow
|
||||
label="New repository discovered"
|
||||
description="Notify when new repositories are discovered and imported"
|
||||
label="New repositories discovered"
|
||||
description="Notify when new repositories are discovered and imported; choose below which ones"
|
||||
checked={notificationConfig.notifyOnNewRepo}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
@@ -512,6 +513,50 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* Sub-options of the switch above: they only apply while it is on. */}
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-4 border-l-2 border-border pl-5",
|
||||
notificationConfig.notifyOnNewRepo ? "" : "opacity-60"
|
||||
)}
|
||||
>
|
||||
<SwitchRow
|
||||
label="In your account"
|
||||
description="Repositories owned by your account"
|
||||
checked={notificationConfig.notifyOnNewOwnRepo}
|
||||
disabled={!notificationConfig.notifyOnNewRepo}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
notifyOnNewOwnRepo: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SwitchRow
|
||||
label="In an organization"
|
||||
description="Repositories of one of your organizations"
|
||||
checked={notificationConfig.notifyOnNewOrgRepo}
|
||||
disabled={!notificationConfig.notifyOnNewRepo}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
notifyOnNewOrgRepo: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SwitchRow
|
||||
label="Starred repositories"
|
||||
description="Repositories you starred; all stars share one notification"
|
||||
checked={notificationConfig.notifyOnNewStarredRepo}
|
||||
disabled={!notificationConfig.notifyOnNewRepo}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
notifyOnNewStarredRepo: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardSection>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
@@ -212,7 +212,11 @@ export const notificationConfigSchema = z.object({
|
||||
provider: z.enum(["ntfy", "apprise", "gotify", "webhook"]).default("ntfy"),
|
||||
notifyOnSyncError: z.boolean().default(true),
|
||||
notifyOnSyncSuccess: z.boolean().default(false),
|
||||
// Master switch; the three group switches below only apply while it is on.
|
||||
notifyOnNewRepo: z.boolean().default(false),
|
||||
notifyOnNewOwnRepo: z.boolean().default(true),
|
||||
notifyOnNewOrgRepo: z.boolean().default(true),
|
||||
notifyOnNewStarredRepo: z.boolean().default(true),
|
||||
ntfy: ntfyConfigSchema.optional(),
|
||||
apprise: appriseConfigSchema.optional(),
|
||||
gotify: gotifyConfigSchema.optional(),
|
||||
@@ -471,6 +475,9 @@ export const configs = sqliteTable("configs", {
|
||||
.$type<z.infer<typeof cleanupConfigSchema>>()
|
||||
.notNull(),
|
||||
|
||||
// Do not add the group switches to this default: SQLite can only change a column
|
||||
// default by rebuilding the configs table, and the schema defaults already cover a
|
||||
// row written without them.
|
||||
notificationConfig: text("notification_config", { mode: "json" })
|
||||
.$type<z.infer<typeof notificationConfigSchema>>()
|
||||
.notNull()
|
||||
|
||||
@@ -18,7 +18,7 @@ mock.module("@/lib/utils/encryption", () => ({
|
||||
}));
|
||||
|
||||
// Import after mocks are set up — db is already mocked via setup.bun.ts
|
||||
import { sendNotification, testNotification, buildNewRepositoriesNotificationEvent, MAX_LISTED_NEW_REPOSITORIES, STARRED_GROUP_LABEL } from "./notification-service";
|
||||
import { sendNotification, testNotification, buildNewRepositoriesNotificationEvent, isNewRepositoryGroupEnabled, MAX_LISTED_NEW_REPOSITORIES, STARRED_GROUP_LABEL } from "./notification-service";
|
||||
import type { NotificationConfig } from "@/types/config";
|
||||
|
||||
describe("sendNotification", () => {
|
||||
@@ -29,6 +29,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
@@ -54,6 +57,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
apprise: {
|
||||
url: "http://apprise:8000",
|
||||
token: "my-token",
|
||||
@@ -78,6 +84,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
gotify: {
|
||||
url: "https://gotify.example.com",
|
||||
token: "my-app-token",
|
||||
@@ -104,6 +113,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
webhook: {
|
||||
url: "https://example.com/hooks/gitea-mirror",
|
||||
},
|
||||
@@ -133,6 +145,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
@@ -155,6 +170,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "",
|
||||
@@ -178,6 +196,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
apprise: {
|
||||
url: "",
|
||||
token: "my-token",
|
||||
@@ -200,6 +221,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
gotify: {
|
||||
url: "https://gotify.example.com",
|
||||
token: "",
|
||||
@@ -223,6 +247,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
webhook: {
|
||||
url: "",
|
||||
},
|
||||
@@ -246,6 +273,9 @@ describe("testNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
webhook: {
|
||||
url: "",
|
||||
},
|
||||
@@ -263,6 +293,9 @@ describe("testNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
@@ -282,6 +315,9 @@ describe("testNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "",
|
||||
@@ -306,6 +342,9 @@ describe("testNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
@@ -325,6 +364,9 @@ describe("testNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
};
|
||||
|
||||
const result = await testNotification(config);
|
||||
@@ -336,6 +378,7 @@ describe("testNotification", () => {
|
||||
describe("buildNewRepositoriesNotificationEvent", () => {
|
||||
test("reports the discovered repositories of one owner in a single event", () => {
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
kind: "organization",
|
||||
groupLabel: "gitea",
|
||||
repositoryNames: ["gitea/alpha", "gitea/beta"],
|
||||
details: "Discovered during scheduled sync on source github.",
|
||||
@@ -351,18 +394,19 @@ describe("buildNewRepositoriesNotificationEvent", () => {
|
||||
|
||||
test("uses the singular wording for a single repository", () => {
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
groupLabel: "gitea",
|
||||
repositoryNames: ["gitea/alpha"],
|
||||
kind: "own",
|
||||
groupLabel: "octocat",
|
||||
repositoryNames: ["octocat/alpha"],
|
||||
});
|
||||
|
||||
expect(event.message).toContain("1 new repository discovered for gitea:");
|
||||
expect(event.message).toContain("1 new repository discovered for octocat:");
|
||||
expect(event.message).not.toContain("more");
|
||||
});
|
||||
|
||||
test("labels starred imports without naming an owner", () => {
|
||||
// Stars come from any number of owners, so they are reported as one group.
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
starred: true,
|
||||
kind: "starred",
|
||||
repositoryNames: ["alpha/one", "beta/two"],
|
||||
});
|
||||
|
||||
@@ -374,6 +418,7 @@ describe("buildNewRepositoriesNotificationEvent", () => {
|
||||
test("caps the listed repositories so a large import stays readable", () => {
|
||||
const names = Array.from({ length: MAX_LISTED_NEW_REPOSITORIES + 5 }, (_, i) => `gitea/repo-${i}`);
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
kind: "organization",
|
||||
groupLabel: "gitea",
|
||||
repositoryNames: names,
|
||||
});
|
||||
@@ -386,9 +431,80 @@ describe("buildNewRepositoriesNotificationEvent", () => {
|
||||
|
||||
test("falls back to a placeholder when the group is unknown", () => {
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
kind: "own",
|
||||
repositoryNames: ["someone/repo"],
|
||||
});
|
||||
|
||||
expect(event.title).toBe("New Repositories: Unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNewRepositoryGroupEnabled", () => {
|
||||
const baseConfig: NotificationConfig = {
|
||||
enabled: true,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: false,
|
||||
notifyOnNewRepo: true,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
};
|
||||
|
||||
test("each group switch enables only its own kind", () => {
|
||||
expect(isNewRepositoryGroupEnabled(baseConfig, "own")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled(baseConfig, "organization")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled(baseConfig, "starred")).toBe(false);
|
||||
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOwnRepo: true }, "own")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOwnRepo: true }, "organization")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOwnRepo: true }, "starred")).toBe(false);
|
||||
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOrgRepo: true }, "organization")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOrgRepo: true }, "own")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOrgRepo: true }, "starred")).toBe(false);
|
||||
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewStarredRepo: true }, "starred")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewStarredRepo: true }, "own")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewStarredRepo: true }, "organization")).toBe(false);
|
||||
});
|
||||
|
||||
test("the master switch gates every group", () => {
|
||||
const allGroupsOn: NotificationConfig = {
|
||||
...baseConfig,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: true,
|
||||
notifyOnNewOrgRepo: true,
|
||||
notifyOnNewStarredRepo: true,
|
||||
};
|
||||
|
||||
expect(isNewRepositoryGroupEnabled(allGroupsOn, "own")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled(allGroupsOn, "organization")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled(allGroupsOn, "starred")).toBe(false);
|
||||
|
||||
const masterOn = { ...allGroupsOn, notifyOnNewRepo: true };
|
||||
expect(isNewRepositoryGroupEnabled(masterOn, "own")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled(masterOn, "organization")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled(masterOn, "starred")).toBe(true);
|
||||
|
||||
// The provider-wide switch still wins over both discovery switches.
|
||||
expect(isNewRepositoryGroupEnabled({ ...masterOn, enabled: false }, "own")).toBe(false);
|
||||
});
|
||||
|
||||
test("a stored row without the group switches keeps every group on", () => {
|
||||
// Rows saved before the sub-options existed carry the master switch only; a
|
||||
// missing group switch matches the schema default and must not silence a
|
||||
// user who had already turned the discovery alerts on.
|
||||
const legacyRow = {
|
||||
enabled: true,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: false,
|
||||
notifyOnNewRepo: true,
|
||||
} as NotificationConfig;
|
||||
|
||||
expect(isNewRepositoryGroupEnabled(legacyRow, "own")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled(legacyRow, "organization")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled(legacyRow, "starred")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -260,29 +260,63 @@ export const MAX_LISTED_NEW_REPOSITORIES = 10;
|
||||
/** Label for the single group that collects every starred import. */
|
||||
export const STARRED_GROUP_LABEL = "Starred";
|
||||
|
||||
/**
|
||||
* Which group of discovered repositories a notification belongs to. Stars come
|
||||
* from any number of owners, so they form one group of their own.
|
||||
*/
|
||||
export type NewRepositoryGroupKind = "own" | "organization" | "starred";
|
||||
|
||||
/**
|
||||
* Whether one group reports. The group switches are sub-options of
|
||||
* notifyOnNewRepo, so a group only reports while the master switch is on. A
|
||||
* stored row that predates them has no group keys, which counts as on.
|
||||
*/
|
||||
export function isNewRepositoryGroupEnabled(
|
||||
config: Pick<
|
||||
NotificationConfig,
|
||||
| "enabled"
|
||||
| "notifyOnNewRepo"
|
||||
| "notifyOnNewOwnRepo"
|
||||
| "notifyOnNewOrgRepo"
|
||||
| "notifyOnNewStarredRepo"
|
||||
>,
|
||||
kind: NewRepositoryGroupKind,
|
||||
): boolean {
|
||||
if (!config.enabled || config.notifyOnNewRepo !== true) {
|
||||
return false;
|
||||
}
|
||||
if (kind === "starred") {
|
||||
return config.notifyOnNewStarredRepo !== false;
|
||||
}
|
||||
if (kind === "organization") {
|
||||
return config.notifyOnNewOrgRepo !== false;
|
||||
}
|
||||
return config.notifyOnNewOwnRepo !== false;
|
||||
}
|
||||
|
||||
export function buildNewRepositoriesNotificationEvent({
|
||||
kind,
|
||||
groupLabel,
|
||||
starred = false,
|
||||
repositoryNames,
|
||||
details,
|
||||
}: {
|
||||
kind: NewRepositoryGroupKind;
|
||||
groupLabel?: string | null;
|
||||
/** Starred imports share one group, so the wording drops the owner. */
|
||||
starred?: boolean;
|
||||
repositoryNames: string[];
|
||||
details?: string;
|
||||
}): NotificationEvent {
|
||||
const label = groupLabel || (starred ? STARRED_GROUP_LABEL : "Unknown");
|
||||
const label = kind === "starred" ? STARRED_GROUP_LABEL : groupLabel || "Unknown";
|
||||
const count = repositoryNames.length;
|
||||
const noun = count === 1 ? "repository" : "repositories";
|
||||
const listed = repositoryNames.slice(0, MAX_LISTED_NEW_REPOSITORIES);
|
||||
const overflow = repositoryNames.length - listed.length;
|
||||
|
||||
return {
|
||||
title: `New Repositories: ${label}`,
|
||||
message: [
|
||||
starred
|
||||
? `${count} new starred ${count === 1 ? "repository" : "repositories"} discovered:`
|
||||
: `${count} new ${count === 1 ? "repository" : "repositories"} discovered for ${label}:`,
|
||||
kind === "starred"
|
||||
? `${count} new starred ${noun} discovered:`
|
||||
: `${count} new ${noun} discovered for ${label}:`,
|
||||
listed.map((name) => `\n- ${name}`).join(""),
|
||||
overflow > 0 ? `\n- and ${overflow} more` : "",
|
||||
details ? `\nDetails: ${details}` : "",
|
||||
@@ -293,22 +327,22 @@ export function buildNewRepositoriesNotificationEvent({
|
||||
|
||||
/**
|
||||
* Notifies the user about repositories that were newly discovered and
|
||||
* imported (the "New repository discovered" event). Imports are reported per
|
||||
* owner or organization, except starred repositories, which arrive as one
|
||||
* group because their owners are arbitrary.
|
||||
* imported. The notifyOnNewRepo switch gates the alerts and the three group
|
||||
* switches (own, organization, starred) select which groups report; each group
|
||||
* arrives as a single notification instead of one per repository.
|
||||
*
|
||||
* Called fire-and-forget from the discovery passes, so it NEVER throws.
|
||||
*/
|
||||
export async function triggerNewRepositoriesNotification({
|
||||
userId,
|
||||
kind,
|
||||
groupLabel,
|
||||
starred = false,
|
||||
repositoryNames,
|
||||
details,
|
||||
}: {
|
||||
userId: string;
|
||||
kind: NewRepositoryGroupKind;
|
||||
groupLabel?: string | null;
|
||||
starred?: boolean;
|
||||
/** Full names of the repositories that were imported. */
|
||||
repositoryNames: string[];
|
||||
details?: string;
|
||||
@@ -320,13 +354,13 @@ export async function triggerNewRepositoriesNotification({
|
||||
|
||||
const notificationConfig = await loadNotificationConfig(userId);
|
||||
|
||||
if (!notificationConfig?.enabled || !notificationConfig.notifyOnNewRepo) {
|
||||
if (!notificationConfig || !isNewRepositoryGroupEnabled(notificationConfig, kind)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
kind,
|
||||
groupLabel,
|
||||
starred,
|
||||
repositoryNames,
|
||||
details,
|
||||
});
|
||||
|
||||
@@ -154,11 +154,12 @@ export async function rediscoverRepositoriesForOrganization({
|
||||
}
|
||||
console.log(`${logPrefix} Re-discovered ${repoRecords.length} new repositories for organization ${organization.name} on source ${source.name} for user ${userId} during ${phase}`);
|
||||
|
||||
// "New repository discovered" notification (settings > notifications).
|
||||
// New repository notification for an organization (settings > notifications).
|
||||
// Fire-and-forget: a failing provider must never break discovery, and
|
||||
// the service itself swallows every error.
|
||||
triggerNewRepositoriesNotification({
|
||||
userId,
|
||||
kind: 'organization',
|
||||
groupLabel: organization.name,
|
||||
repositoryNames: repoRecords.map(repo => repo.fullName ?? '').filter(Boolean),
|
||||
details: `Discovered during ${phase} on source ${source.name}.`,
|
||||
|
||||
@@ -510,6 +510,7 @@ describe.skipIf(!isChild)("Scheduler public-only sources (WP4)", () => {
|
||||
expect(mockTriggerNewRepositoriesNotification).toHaveBeenCalledTimes(1);
|
||||
expect(mockTriggerNewRepositoriesNotification.mock.calls[0][0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
kind: "organization",
|
||||
groupLabel: "pinned-org",
|
||||
repositoryNames: ["pinned-org/newly-published"],
|
||||
});
|
||||
@@ -594,6 +595,7 @@ describe.skipIf(!isChild)("Scheduler public-only sources (WP4)", () => {
|
||||
expect(calls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
kind: "own",
|
||||
groupLabel: "majmcccloud",
|
||||
repositoryNames: ["majmcccloud/own-alpha", "majmcccloud/own-beta"],
|
||||
})
|
||||
@@ -601,7 +603,7 @@ describe.skipIf(!isChild)("Scheduler public-only sources (WP4)", () => {
|
||||
expect(calls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
starred: true,
|
||||
kind: "starred",
|
||||
repositoryNames: ["other-org/starred-gamma", "another-user/starred-delta"],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -272,38 +272,42 @@ async function importRepositoriesFromSources(
|
||||
}
|
||||
console.log(`[Scheduler] Successfully imported ${newRepos.length} new repositories for user ${userId} from source ${source.name}`);
|
||||
|
||||
// "New repository discovered" notifications (settings > notifications).
|
||||
// Grouped per owner/organization so a large import produces one alert
|
||||
// per owner instead of one per repository. Starred repositories are the
|
||||
// exception: they can come from any number of owners, so they share a
|
||||
// single "Starred" alert. Fire-and-forget: the service swallows every
|
||||
// error, and a failing provider must not break the run.
|
||||
const newReposByOwner = new Map<string, string[]>();
|
||||
// New repository notifications (settings > notifications). The
|
||||
// notifyOnNewRepo master switch gates them and the own/organization/
|
||||
// starred sub-switches select the groups; every group produces one alert
|
||||
// instead of one per repository.
|
||||
// Starred repositories always share a single group because their owners
|
||||
// are arbitrary. Fire-and-forget: the service swallows every error, and
|
||||
// a failing provider must not break the run.
|
||||
const newReposByGroup = new Map<string, { kind: 'own' | 'organization'; label: string; names: string[] }>();
|
||||
const starredRepoNames: string[] = [];
|
||||
for (const repo of newRepos) {
|
||||
if (repo.isStarred) {
|
||||
starredRepoNames.push(repo.fullName);
|
||||
continue;
|
||||
}
|
||||
const ownerLabel = repo.organization || repo.owner || 'Unknown';
|
||||
const names = newReposByOwner.get(ownerLabel) ?? [];
|
||||
names.push(repo.fullName);
|
||||
newReposByOwner.set(ownerLabel, names);
|
||||
const kind: 'own' | 'organization' = repo.organization ? 'organization' : 'own';
|
||||
const label = repo.organization || repo.owner || 'Unknown';
|
||||
const groupKey = `${kind}:${label}`;
|
||||
const group = newReposByGroup.get(groupKey) ?? { kind, label, names: [] };
|
||||
group.names.push(repo.fullName);
|
||||
newReposByGroup.set(groupKey, group);
|
||||
}
|
||||
for (const [ownerLabel, names] of newReposByOwner) {
|
||||
for (const group of newReposByGroup.values()) {
|
||||
triggerNewRepositoriesNotification({
|
||||
userId,
|
||||
groupLabel: ownerLabel,
|
||||
repositoryNames: names,
|
||||
kind: group.kind,
|
||||
groupLabel: group.label,
|
||||
repositoryNames: group.names,
|
||||
details: `Auto-imported during ${phase} from source ${source.name}.`,
|
||||
}).catch(notificationError => {
|
||||
console.error(`[Scheduler] Failed to send the new repository notification for ${ownerLabel}:`, notificationError);
|
||||
console.error(`[Scheduler] Failed to send the new repository notification for ${group.label}:`, notificationError);
|
||||
});
|
||||
}
|
||||
if (starredRepoNames.length > 0) {
|
||||
triggerNewRepositoriesNotification({
|
||||
userId,
|
||||
starred: true,
|
||||
kind: 'starred',
|
||||
repositoryNames: starredRepoNames,
|
||||
details: `Auto-imported during ${phase} from source ${source.name}.`,
|
||||
}).catch(notificationError => {
|
||||
|
||||
@@ -187,7 +187,11 @@ export interface NotificationConfig {
|
||||
provider: "ntfy" | "apprise" | "gotify" | "webhook";
|
||||
notifyOnSyncError: boolean;
|
||||
notifyOnSyncSuccess: boolean;
|
||||
/** Master switch for the discovery alerts below. */
|
||||
notifyOnNewRepo: boolean;
|
||||
notifyOnNewOwnRepo: boolean;
|
||||
notifyOnNewOrgRepo: boolean;
|
||||
notifyOnNewStarredRepo: boolean;
|
||||
ntfy?: NtfyConfig;
|
||||
apprise?: AppriseConfig;
|
||||
gotify?: GotifyConfig;
|
||||
|
||||
Reference in new issue
Block a user