From 20b568a44adf1fd7b91ab300a799f7127a9224e2 Mon Sep 17 00:00:00 2001 From: Aarush Arora Date: Tue, 7 Jul 2026 15:13:38 -0700 Subject: [PATCH 1/3] feat(notifications): add hosted file-based in-IDE notifications Adds a client-side notifications feature to the Eclipse plugin, matching the schema and behavior of the other Amazon Q IDE plugins. On startup (after the language server is ready) a background poller fetches a hosted JSON file over HTTPS every 10 minutes and shows targeted in-IDE toasts. - Schema 2.x "combined" payload model + a polymorphic condition DSL (==, !=, >, >=, <, <=, anyOf, noneOf, and, or, not) with a single Jackson deserializer. - Rules engine gates display on compute/os/ide/extension/authx conditions (semver for ide/extension versions; SNAPSHOT builds and not-installed extensions are never shown). - ETag-cached fetcher that degrades gracefully: any failure (absent file, 403/404, empty, malformed, offline) resolves to "show nothing" and only logs. Supports a file:// endpoint for local testing. - Toasts: Info/Warning auto-dismiss; Critical persists until dismissed. Actions: ShowUrl, UpdateExtension, OpenChangelog, plus More and Dismiss. Dismissals persist for 60 days; emergencies re-show until dismissed. - User preference "Show Amazon Q notifications" (default on) as a kill switch; telemetry (toolkit_showNotification / toolkit_invokeAction) is independent of notification polling and respects the telemetry opt-in. - 42 unit tests covering parsing, the DSL, rules, fetch/degradation, dismissal, and filtering/dedup. Wired into LspStartupActivity (start) and Activator.stop (clean shutdown). --- .../amazonq/lsp/LspStartupActivity.java | 2 + .../AmazonQNotificationPopup.java | 141 ++++++++++++ .../notifications/DismissedNotification.java | 36 +++ .../notifications/FeatureAuthDetails.java | 8 + .../NotificationActionFactory.java | 73 ++++++ .../notifications/NotificationConstants.java | 28 +++ .../notifications/NotificationData.java | 107 +++++++++ .../NotificationDismissalConfiguration.java | 29 +++ .../NotificationDismissalStore.java | 65 ++++++ .../notifications/NotificationExpression.java | 51 +++++ .../NotificationExpressionDeserializer.java | 92 ++++++++ .../NotificationPollingService.java | 85 +++++++ .../NotificationPreferences.java | 34 +++ .../NotificationTelemetryProvider.java | 66 ++++++ .../notifications/NotificationsFetcher.java | 189 ++++++++++++++++ .../notifications/NotificationsList.java | 17 ++ .../notifications/ProcessNotifications.java | 119 ++++++++++ .../amazonq/notifications/RulesEngine.java | 165 ++++++++++++++ .../amazonq/notifications/SystemDetails.java | 18 ++ .../notifications/SystemDetailsCollector.java | 109 +++++++++ .../eclipse/amazonq/plugin/Activator.java | 2 + .../AmazonQPreferenceInitializer.java | 2 + .../preferences/AmazonQPreferencePage.java | 16 ++ .../eclipse/amazonq/util/Constants.java | 2 + .../NotificationActionFactoryTest.java | 93 ++++++++ .../NotificationDismissalStoreTest.java | 80 +++++++ .../notifications/NotificationMiscTest.java | 55 +++++ .../NotificationParsingTest.java | 211 ++++++++++++++++++ .../NotificationsFetcherTest.java | 126 +++++++++++ .../ProcessNotificationsTest.java | 118 ++++++++++ .../notifications/RulesEngineTest.java | 153 +++++++++++++ 31 files changed, 2292 insertions(+) create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/DismissedNotification.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/FeatureAuthDetails.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactory.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationConstants.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationData.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalConfiguration.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStore.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpression.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpressionDeserializer.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPollingService.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPreferences.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationTelemetryProvider.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcher.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsList.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotifications.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngine.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetails.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetailsCollector.java create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactoryTest.java create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStoreTest.java create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationMiscTest.java create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationParsingTest.java create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcherTest.java create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotificationsTest.java create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngineTest.java diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java index 9b661a14a..3a43ed19a 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java @@ -18,6 +18,7 @@ import org.eclipse.ui.PlatformUI; import software.aws.toolkits.eclipse.amazonq.broker.events.QDeveloperProfileState; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationPollingService; import software.aws.toolkits.eclipse.amazonq.plugin.Activator; import software.aws.toolkits.eclipse.amazonq.providers.browser.AmazonQBrowserProvider; import software.aws.toolkits.eclipse.amazonq.telemetry.ToolkitTelemetryProvider; @@ -83,6 +84,7 @@ private void schedulePostStartupJobs() { Display.getDefault().asyncExec(() -> attachAutoTriggerListenersIfApplicable()); Display.getDefault().asyncExec(() -> showKiroSunsetNotification()); checkForUpdates(); + NotificationPollingService.getInstance().start(); }); } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java new file mode 100644 index 000000000..83cf88e76 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java @@ -0,0 +1,141 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.List; + +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationSeverity; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.util.ToolkitNotification; + +/** + * A toast notification that renders a severity icon, wrapped description, and N action buttons built from a hosted + * notification's actions. INFO/WARNING keep the base auto-close timer; CRITICAL overrides {@link #scheduleAutoClose()} + * to a no-op so it persists until the user dismisses it (the COE "reach the user" requirement). + */ +public final class AmazonQNotificationPopup extends ToolkitNotification { + + /** A rendered action button: a label plus the handler to run when clicked. */ + public record NotificationAction(String label, Runnable onClick) { } + + private final String description; + // Mylyn's default auto-close is 8s, which is too short to read a multi-line known-issue message. + private static final long TRANSIENT_DELAY_CLOSE_MS = 20_000L; + + private final NotificationSeverity severity; + private final boolean persistent; + private final List actions; + + public AmazonQNotificationPopup(final Display display, final String title, final String description, + final NotificationSeverity severity, final List actions) { + super(display, title, description); + this.description = description; + this.severity = severity; + this.persistent = severity == NotificationSeverity.CRITICAL; + this.actions = actions == null ? List.of() : List.copyOf(actions); + // CRITICAL persists until dismissed (delayClose = 0 => scheduleAutoClose is a no-op); others stay readable. + final long delayClose = persistent ? 0L : TRANSIENT_DELAY_CLOSE_MS; + setDelayClose(delayClose); + Activator.getLogger().info("AmazonQNotificationPopup created: severity=" + severity + + " persistent=" + persistent + " delayCloseMs=" + delayClose); + } + + @Override + protected void scheduleAutoClose() { + // Belt-and-suspenders: never schedule an auto-close for a persistent (CRITICAL) notification. + if (!persistent) { + super.scheduleAutoClose(); + } + } + + @Override + protected void createContentArea(final Composite parent) { + final Composite container = new Composite(parent, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + final Label iconLabel = new Label(container, SWT.NONE); + final Image icon = createSeverityImage(severity); + if (icon != null) { + iconLabel.setImage(icon); + // close() is final in the base class, so dispose the icon via a listener instead of overriding close(). + iconLabel.addDisposeListener(e -> { + if (!icon.isDisposed()) { + icon.dispose(); + } + }); + } + iconLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false)); + + final Label messageLabel = new Label(container, SWT.WRAP); + messageLabel.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + messageLabel.setText(description != null ? description : ""); + + if (!actions.isEmpty()) { + createActionButtons(parent); + } + } + + private void createActionButtons(final Composite parent) { + final Composite buttonRow = new Composite(parent, SWT.NONE); + final GridLayout layout = new GridLayout(actions.size(), false); + layout.marginWidth = 0; + layout.marginHeight = 0; + buttonRow.setLayout(layout); + buttonRow.setLayoutData(new GridData(SWT.END, SWT.CENTER, true, false)); + + for (final NotificationAction action : actions) { + final Button button = new Button(buttonRow, SWT.PUSH); + button.setText(action.label()); + button.setLayoutData(new GridData(SWT.END, SWT.CENTER, false, false)); + button.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(final SelectionEvent e) { + action.onClick().run(); + close(); + } + }); + } + } + + private static Image createSeverityImage(final NotificationSeverity severity) { + try { + final ISharedImages sharedImages = PlatformUI.getWorkbench().getSharedImages(); + final ImageDescriptor descriptor = sharedImages.getImageDescriptor(iconKey(severity)); + if (descriptor == null) { + return null; + } + // createImage(false) returns null (rather than throwing) if the image can't be loaded. + return descriptor.createImage(false); + } catch (Exception e) { + return null; + } + } + + static String iconKey(final NotificationSeverity severity) { + switch (severity) { + case CRITICAL: + return ISharedImages.IMG_OBJS_ERROR_TSK; + case WARNING: + return ISharedImages.IMG_OBJS_WARN_TSK; + case INFO: + default: + return ISharedImages.IMG_OBJS_INFO_TSK; + } + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/DismissedNotification.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/DismissedNotification.java new file mode 100644 index 000000000..4e2d9cf08 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/DismissedNotification.java @@ -0,0 +1,36 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +/** A dismissed notification id plus the epoch-millis timestamp it was dismissed (for retention cleanup). Gson-friendly. */ +public final class DismissedNotification { + + private String id; + private long dismissedAtEpochMs; + + public DismissedNotification() { + // no-arg constructor for Gson + } + + public DismissedNotification(final String id, final long dismissedAtEpochMs) { + this.id = id; + this.dismissedAtEpochMs = dismissedAtEpochMs; + } + + public String getId() { + return id; + } + + public void setId(final String id) { + this.id = id; + } + + public long getDismissedAtEpochMs() { + return dismissedAtEpochMs; + } + + public void setDismissedAtEpochMs(final long dismissedAtEpochMs) { + this.dismissedAtEpochMs = dismissedAtEpochMs; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/FeatureAuthDetails.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/FeatureAuthDetails.java new file mode 100644 index 000000000..f77c792fd --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/FeatureAuthDetails.java @@ -0,0 +1,8 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +/** Snapshot of a feature's auth/connection state, used by the rules engine's {@code authx} matching. */ +public record FeatureAuthDetails(String connectionType, String region, String connectionState) { +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactory.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactory.java new file mode 100644 index 000000000..997ef2447 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactory.java @@ -0,0 +1,73 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.swt.widgets.Display; + +import software.aws.toolkits.eclipse.amazonq.notifications.AmazonQNotificationPopup.NotificationAction; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationFollowupAction; +import software.aws.toolkits.eclipse.amazonq.util.Constants; +import software.aws.toolkits.eclipse.amazonq.util.PluginUtils; + +/** + * Builds the rendered action buttons for a notification from its hosted {@code actions[]}, ports the JetBrains mapping: + * {@code ShowUrl} (case-sensitive) only supplies the URL for the always-present "More" button; {@code UpdateExtension} + * and {@code OpenChangelog} open pages; unknown types are ignored. + */ +public final class NotificationActionFactory { + + private static final String SHOW_URL = "ShowUrl"; + private static final String UPDATE_EXTENSION = "UpdateExtension"; + private static final String OPEN_CHANGELOG = "OpenChangelog"; + + private NotificationActionFactory() { + // prevent instantiation + } + + public static List createActions(final String notificationId, + final List followupActions, final String title, final String description) { + final List result = new ArrayList<>(); + String moreUrl = null; + + if (followupActions != null) { + for (final NotificationFollowupAction action : followupActions) { + final String type = action.type(); + if (SHOW_URL.equals(type)) { + if (action.content() != null && action.content().enUs() != null) { + moreUrl = action.content().enUs().url(); + } + } else if (UPDATE_EXTENSION.equals(type)) { + result.add(new NotificationAction("Update", () -> { + NotificationTelemetryProvider.emitInvokeAction(notificationId, UPDATE_EXTENSION); + PluginUtils.openWebpage(Constants.AMAZON_Q_UPDATE_SITE_URL); + })); + } else if (OPEN_CHANGELOG.equals(type)) { + result.add(new NotificationAction("View changelog", () -> { + NotificationTelemetryProvider.emitInvokeAction(notificationId, OPEN_CHANGELOG); + PluginUtils.openWebpage(Constants.AMAZON_Q_CHANGELOG_URL); + })); + } + } + } + + final String capturedUrl = moreUrl; + result.add(new NotificationAction("More", () -> { + NotificationTelemetryProvider.emitInvokeAction(notificationId, "More"); + showMoreDialog(title, description, capturedUrl); + })); + return result; + } + + private static void showMoreDialog(final String title, final String description, final String url) { + if (url != null && !url.isBlank()) { + PluginUtils.handleExternalLinkClick(url); + } else { + MessageDialog.openInformation(Display.getDefault().getActiveShell(), title, description); + } + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationConstants.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationConstants.java new file mode 100644 index 000000000..91b592c91 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationConstants.java @@ -0,0 +1,28 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +/** Endpoint, cache, and storage-key constants for the hosted-file notifications feature. */ +public final class NotificationConstants { + + /** Production hosted-file endpoint for Eclipse notifications (schema 2.x combined). */ + public static final String NOTIFICATIONS_ENDPOINT = + "https://idetoolkits-hostedfiles.amazonaws.com/Notifications/Eclipse/combined/2.x.json"; + + /** Subdirectory (under the plugin state dir) that holds the cached notifications file. */ + public static final String NOTIFICATIONS_SUBDIRECTORY = "notifications"; + + /** Filename of the cached notifications payload. */ + public static final String NOTIFICATIONS_CACHE_FILENAME = "notifications.json"; + + /** PluginStore key under which dismissed-notification state is persisted. */ + public static final String DISMISSAL_STORAGE_KEY = "qNotificationDismissals"; + + /** Environment variable that overrides the endpoint (for local dev/testing). */ + public static final String NOTIFICATIONS_ENDPOINT_ENV = "AMAZONQ_NOTIFICATIONS_ENDPOINT"; + + private NotificationConstants() { + // prevent instantiation + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationData.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationData.java new file mode 100644 index 000000000..0ce5e2b94 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationData.java @@ -0,0 +1,107 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.List; +import java.util.Locale; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A single hosted notification (schema 2.x). Ported from the JetBrains notification model so payloads + * stay compatible across IDEs. Unknown JSON keys are ignored (the shared mapper disables + * FAIL_ON_UNKNOWN_PROPERTIES), so a missing optional block deserializes to {@code null}. + */ +public record NotificationData( + String id, + NotificationSchedule schedule, + String severity, + NotificationDisplayCondition condition, + NotificationContent content, + List actions) { + + /** How often the notification is shown. */ + public enum NotificationScheduleType { + /** Shown once per IDE session (on the first poll). */ + STARTUP, + /** Shown on every poll until dismissed. */ + EMERGENCY; + + /** + * Maps the raw JSON value case-insensitively: only {@code "startup"} yields + * {@link #STARTUP}; anything else (including typos and {@code null}) yields {@link #EMERGENCY}. + */ + @JsonCreator + public static NotificationScheduleType fromString(final String value) { + return value != null && "startup".equals(value.toLowerCase(Locale.ROOT)) + ? STARTUP + : EMERGENCY; + } + } + + /** Notification severity; drives the toast style. */ + public enum NotificationSeverity { + INFO, + WARNING, + CRITICAL; + + /** Maps the exact-case JSON value; any unrecognized or {@code null} value yields {@link #INFO}. */ + public static NotificationSeverity fromString(final String value) { + if ("Critical".equals(value)) { + return CRITICAL; + } + if ("Warning".equals(value)) { + return WARNING; + } + return INFO; + } + } + + /** Wrapper around the schedule type as it appears in JSON: {@code { "type": "Startup" }}. */ + public record NotificationSchedule(NotificationScheduleType type) { } + + /** + * Display conditions. All present blocks must match (logical AND); a {@code null} block is skipped. + * Note {@code extension} is a singular-named array, matching the JetBrains field the rules engine reads. + */ + public record NotificationDisplayCondition( + ComputeType compute, + SystemType os, + SystemType ide, + List extension, + List authx) { } + + /** Compute-environment condition. */ + public record ComputeType(NotificationExpression type, NotificationExpression architecture) { } + + /** OS or IDE condition (type + version). */ + public record SystemType(NotificationExpression type, NotificationExpression version) { } + + /** Installed-extension condition, matched by id + optional version expression. */ + public record ExtensionType(String id, NotificationExpression version) { } + + /** Authentication/connection condition for a feature (for example {@code "q"}). */ + public record AuthxType( + String feature, + NotificationExpression type, + NotificationExpression region, + NotificationExpression connectionState, + NotificationExpression ssoScopes) { } + + /** Localized notification content. Only the {@code en-US} locale is consumed. */ + public record NotificationContent(@JsonProperty("en-US") LocalizedContent enUs) { } + + /** Title/description for a single locale. */ + public record LocalizedContent(String title, String description) { } + + /** A follow-up action (button) on the notification. */ + public record NotificationFollowupAction(String type, NotificationFollowupActionContent content) { } + + /** Localized action content. */ + public record NotificationFollowupActionContent(@JsonProperty("en-US") LocalizedAction enUs) { } + + /** Title and optional URL for a single locale's action. */ + public record LocalizedAction(String title, String url) { } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalConfiguration.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalConfiguration.java new file mode 100644 index 000000000..4b61eb6a5 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalConfiguration.java @@ -0,0 +1,29 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.ArrayList; +import java.util.List; + +/** + * Concrete wrapper persisted via {@code PluginStore.putObject}/{@code getObject}. A concrete class (rather than a raw + * generic collection) is required because {@code getObject(key, Class)} deserializes with Gson reflecting into the + * declared field type, which correctly recovers the {@link DismissedNotification} element type. + */ +public final class NotificationDismissalConfiguration { + + private List dismissedNotifications = new ArrayList<>(); + + public NotificationDismissalConfiguration() { + // no-arg constructor for Gson + } + + public List getDismissedNotifications() { + return dismissedNotifications; + } + + public void setDismissedNotifications(final List dismissedNotifications) { + this.dismissedNotifications = dismissedNotifications; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStore.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStore.java new file mode 100644 index 000000000..b1f44909f --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStore.java @@ -0,0 +1,65 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; + +/** + * Persists dismissed-notification ids (with a 60-day retention) via {@link PluginStore}. All mutations funnel through + * synchronized methods so a load-modify-write (which spans two PluginStore calls) cannot lose entries under concurrency. + */ +public final class NotificationDismissalStore { + + private static final Duration RETENTION = Duration.ofDays(60); + + private final PluginStore pluginStore; + + public NotificationDismissalStore() { + this(Activator.getPluginStore()); + } + + public NotificationDismissalStore(final PluginStore pluginStore) { + this.pluginStore = pluginStore; + } + + public synchronized boolean isDismissed(final String id) { + return loadAndClean().getDismissedNotifications().stream().anyMatch(d -> d.getId().equals(id)); + } + + public synchronized void dismiss(final String id) { + final NotificationDismissalConfiguration config = loadAndClean(); + final List dismissed = config.getDismissedNotifications(); + if (dismissed.stream().anyMatch(d -> d.getId().equals(id))) { + return; + } + dismissed.add(new DismissedNotification(id, Instant.now().toEpochMilli())); + pluginStore.putObject(NotificationConstants.DISMISSAL_STORAGE_KEY, config); + } + + private NotificationDismissalConfiguration loadAndClean() { + NotificationDismissalConfiguration config; + try { + config = pluginStore.getObject(NotificationConstants.DISMISSAL_STORAGE_KEY, + NotificationDismissalConfiguration.class); + } catch (Exception e) { + Activator.getLogger().warn("Corrupt notification dismissal state; resetting", e); + config = null; + } + if (config == null || config.getDismissedNotifications() == null) { + return new NotificationDismissalConfiguration(); + } + final Instant cutoff = Instant.now().minus(RETENTION); + final boolean removedAny = config.getDismissedNotifications() + .removeIf(d -> Instant.ofEpochMilli(d.getDismissedAtEpochMs()).isBefore(cutoff)); + if (removedAny) { + pluginStore.putObject(NotificationConstants.DISMISSAL_STORAGE_KEY, config); + } + return config; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpression.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpression.java new file mode 100644 index 000000000..97b8130dc --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpression.java @@ -0,0 +1,51 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * A notification display-condition expression, encoded in the hosted JSON as a single-key wrapper + * object (for example { "==": "1.0" } or { "and": [ ... ] }). The operator + * is the wrapper key; the value is a bare string, an array of strings, or nested expressions. + * Ported from the JetBrains schema-2.x notification model so the rules engine can match 1:1. + */ +@JsonDeserialize(using = NotificationExpressionDeserializer.class) +public sealed interface NotificationExpression { + + /** Matches when the actual value equals the given value (==). */ + record ComparisonCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value does not equal the given value (!=). */ + record NotEqualsCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value is greater than the given value (>). */ + record GreaterThanCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value is greater than or equal to the given value (>=). */ + record GreaterThanOrEqualsCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value is less than the given value (<). */ + record LessThanCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value is less than or equal to the given value (<=). */ + record LessThanOrEqualsCondition(String value) implements NotificationExpression { } + + /** Matches when the actual value is contained in the given list (anyOf). */ + record AnyOfCondition(List value) implements NotificationExpression { } + + /** Matches when the actual value is not contained in the given list (noneOf). */ + record NoneOfCondition(List value) implements NotificationExpression { } + + /** Matches when every nested expression matches (and). */ + record AndCondition(List expectedValueList) implements NotificationExpression { } + + /** Matches when any nested expression matches (or). */ + record OrCondition(List expectedValueList) implements NotificationExpression { } + + /** Matches when the nested expression does not match (not). */ + record NotCondition(NotificationExpression expectedValue) implements NotificationExpression { } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpressionDeserializer.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpressionDeserializer.java new file mode 100644 index 000000000..7535b7a76 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationExpressionDeserializer.java @@ -0,0 +1,92 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Deserializes a {@link NotificationExpression} from its single-key operator-wrapper object form, + * for example { ">=": "1.0" }, { "anyOf": ["a", "b"] }, or + * { "and": [ { ">=": "1.0" }, { "<": "2.0" } ] }. The and, + * or, and not operators nest recursively. + */ +public final class NotificationExpressionDeserializer extends JsonDeserializer { + + @Override + public NotificationExpression deserialize(final JsonParser parser, final DeserializationContext ctxt) throws IOException { + JsonNode node = parser.getCodec().readTree(parser); + if (node == null || !node.isObject() || node.size() != 1) { + throw new JsonMappingException(parser, "Notification expression must be a single-key operator object"); + } + + Map.Entry entry = node.fields().next(); + String operator = entry.getKey(); + JsonNode value = entry.getValue(); + + switch (operator) { + case "==": + return new NotificationExpression.ComparisonCondition(value.asText()); + case "!=": + return new NotificationExpression.NotEqualsCondition(value.asText()); + case ">": + return new NotificationExpression.GreaterThanCondition(value.asText()); + case ">=": + return new NotificationExpression.GreaterThanOrEqualsCondition(value.asText()); + case "<": + return new NotificationExpression.LessThanCondition(value.asText()); + case "<=": + return new NotificationExpression.LessThanOrEqualsCondition(value.asText()); + case "anyOf": + return new NotificationExpression.AnyOfCondition(toStringList(parser, value, operator)); + case "noneOf": + return new NotificationExpression.NoneOfCondition(toStringList(parser, value, operator)); + case "and": + return new NotificationExpression.AndCondition(toExpressionList(parser, value, operator)); + case "or": + return new NotificationExpression.OrCondition(toExpressionList(parser, value, operator)); + case "not": + return new NotificationExpression.NotCondition(toExpression(parser, value)); + default: + throw new JsonMappingException(parser, "Unknown notification expression operator: " + operator); + } + } + + private List toStringList(final JsonParser parser, final JsonNode value, final String operator) throws JsonMappingException { + if (!value.isArray()) { + throw new JsonMappingException(parser, operator + " must contain an array of values"); + } + List values = new ArrayList<>(); + for (JsonNode element : value) { + values.add(element.asText()); + } + return values; + } + + private List toExpressionList(final JsonParser parser, final JsonNode value, final String operator) + throws IOException { + if (!value.isArray()) { + throw new JsonMappingException(parser, operator + " must contain an array of expressions"); + } + List expressions = new ArrayList<>(); + Iterator elements = value.elements(); + while (elements.hasNext()) { + expressions.add(toExpression(parser, elements.next())); + } + return expressions; + } + + private NotificationExpression toExpression(final JsonParser parser, final JsonNode value) throws IOException { + return parser.getCodec().treeToValue(value, NotificationExpression.class); + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPollingService.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPollingService.java new file mode 100644 index 000000000..10eafbab0 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPollingService.java @@ -0,0 +1,85 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.time.Duration; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils; + +/** + * App-level singleton that polls the notifications endpoint every 10 minutes on the shared worker pool, self-rescheduling + * after each poll. The poll body is total (fetch never throws; the work is wrapped so an escaped error cannot cancel the + * loop) and the re-arm happens in a {@code finally}. {@link #stop()} must be called early in {@code Activator.stop()} to + * cancel the pending future and prevent a re-arm during teardown. + */ +public final class NotificationPollingService { + + private static final NotificationPollingService INSTANCE = new NotificationPollingService(); + private static final long POLL_INTERVAL_MS = Duration.ofMinutes(10).toMillis(); + + private final AtomicBoolean started = new AtomicBoolean(false); + private volatile boolean stopped; + private volatile ScheduledFuture scheduledPoll; + private volatile NotificationsFetcher fetcher; + private volatile ProcessNotifications processor; + + private NotificationPollingService() { + // singleton + } + + public static NotificationPollingService getInstance() { + return INSTANCE; + } + + /** Starts polling once per app lifetime; no-op if the kill-switch is off or polling already started. */ + public void start() { + if (!NotificationPreferences.isNotificationsEnabled()) { + return; + } + if (!started.compareAndSet(false, true)) { + return; + } + this.fetcher = new NotificationsFetcher(NotificationPreferences.resolveEndpoint()); + this.processor = new ProcessNotifications(new NotificationDismissalStore()); + pollOnce(); + } + + private void pollOnce() { + if (stopped || !NotificationPreferences.isNotificationsEnabled()) { + return; + } + try { + fetcher.fetch().ifPresent(processor::process); + } catch (Throwable t) { + Activator.getLogger().warn("Notifications poll failed", t); + NotificationTelemetryProvider.emitPollFailure("Failed to poll for notifications"); + } finally { + reschedule(); + } + } + + private void reschedule() { + if (stopped || !NotificationPreferences.isNotificationsEnabled()) { + return; + } + try { + scheduledPoll = (ScheduledFuture) ThreadingUtils.scheduleAsyncTaskWithDelay(this::pollOnce, POLL_INTERVAL_MS); + } catch (RejectedExecutionException e) { + Activator.getLogger().info("Notifications polling stopped (worker pool shutting down)"); + } + } + + /** Cancels the pending poll and prevents further rescheduling; safe to call during shutdown. */ + public void stop() { + stopped = true; + final ScheduledFuture current = scheduledPoll; + if (current != null) { + current.cancel(false); + } + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPreferences.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPreferences.java new file mode 100644 index 000000000..8b1e9b043 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPreferences.java @@ -0,0 +1,34 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.preferences.AmazonQPreferencePage; + +/** Reads the notifications kill-switch preference and resolves the endpoint (preference > env > prod default). */ +public final class NotificationPreferences { + + private NotificationPreferences() { + // prevent instantiation + } + + /** Whether the notifications feature is enabled (kill-switch); defaults to {@code true}. */ + public static boolean isNotificationsEnabled() { + return Activator.getDefault().getPreferenceStore().getBoolean(AmazonQPreferencePage.NOTIFICATIONS_OPT_IN); + } + + /** Resolves the endpoint URL. Precedence: preference override -> environment variable -> production default. */ + public static String resolveEndpoint() { + final String pref = Activator.getDefault().getPreferenceStore() + .getString(AmazonQPreferencePage.NOTIFICATIONS_ENDPOINT_OVERRIDE); + if (pref != null && !pref.isBlank()) { + return pref; + } + final String env = System.getenv(NotificationConstants.NOTIFICATIONS_ENDPOINT_ENV); + if (env != null && !env.isBlank()) { + return env; + } + return NotificationConstants.NOTIFICATIONS_ENDPOINT; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationTelemetryProvider.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationTelemetryProvider.java new file mode 100644 index 000000000..2e9f305ee --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationTelemetryProvider.java @@ -0,0 +1,66 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.time.Instant; + +import software.amazon.awssdk.services.toolkittelemetry.model.MetricDatum; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.telemetry.TelemetryDefinitions.Component; +import software.aws.toolkits.telemetry.TelemetryDefinitions.Result; +import software.aws.toolkits.telemetry.ToolkitTelemetry; + +/** + * Emits notification telemetry ({@code toolkit_showNotification} / {@code toolkit_invokeAction}). Emission routes + * through {@code DefaultTelemetryService.emitMetric}, which respects the telemetry opt-in independently of the + * notifications feature. The metric {@code id} is the raw notification id (no {@code TARGETED_NOTIFICATION:} prefix). + */ +public final class NotificationTelemetryProvider { + + private NotificationTelemetryProvider() { + // prevent instantiation + } + + /** A notification was shown to the user. */ + public static void emitShowNotification(final String notificationId) { + final MetricDatum datum = ToolkitTelemetry.ShowNotificationEvent() + .id(notificationId) + .component(Component.INFOBAR) + .result(Result.SUCCEEDED) + .passive(true) + .createTime(Instant.now()) + .value(1.0) + .build(); + Activator.getTelemetryService().emitMetric(datum); + } + + /** A poll cycle failed to retrieve notifications. */ + public static void emitPollFailure(final String reason) { + final MetricDatum datum = ToolkitTelemetry.ShowNotificationEvent() + .id("") + .component(Component.FILESYSTEM) + .result(Result.FAILED) + .reason(reason) + .passive(true) + .createTime(Instant.now()) + .value(1.0) + .build(); + Activator.getTelemetryService().emitMetric(datum); + } + + /** The user clicked an action button on a notification. */ + public static void emitInvokeAction(final String notificationId, final String actionType) { + final MetricDatum datum = ToolkitTelemetry.InvokeActionEvent() + .id(notificationId) + .source(notificationId) + .action(actionType) + .component(Component.INFOBAR) + .result(Result.SUCCEEDED) + .passive(false) + .createTime(Instant.now()) + .value(1.0) + .build(); + Activator.getTelemetryService().emitMetric(datum); + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcher.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcher.java new file mode 100644 index 000000000..bfa304329 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcher.java @@ -0,0 +1,189 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.Optional; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.util.HttpClientFactory; +import software.aws.toolkits.eclipse.amazonq.util.ObjectMapperFactory; +import software.aws.toolkits.eclipse.amazonq.util.PluginUtils; + +/** + * Fetches the hosted notifications payload with ETag conditional GET + on-disk caching, modeled on + * {@code VersionManifestFetcher}. {@link #fetch()} is a TOTAL function: any failure (absent file, 403/404, empty body, + * malformed JSON, network error) resolves to {@link Optional#empty()} and is only logged — it never throws and never + * surfaces a user-facing popup, so a not-yet-deployed endpoint is a silent no-op. + */ +public final class NotificationsFetcher { + + private static final int TIMEOUT_SECONDS = 30; + private static final int MAX_RETRIES = 3; + private static final long RETRY_BASE_DELAY_MS = 1000L; + private static final ObjectMapper OBJECT_MAPPER = ObjectMapperFactory.getInstance(); + + private final String endpointUrl; + private final HttpClient httpClient; + private final Path cachePath; + + public NotificationsFetcher(final String endpointUrl) { + this(endpointUrl, null, null); + } + + public NotificationsFetcher(final String endpointUrl, final HttpClient httpClient, final Path cachePath) { + // Trim stray whitespace/newlines (a common copy-paste artifact when the endpoint is set via env var / preference). + this.endpointUrl = endpointUrl == null ? null : endpointUrl.trim(); + this.httpClient = httpClient != null ? httpClient : HttpClientFactory.getInstance(); + this.cachePath = cachePath != null ? cachePath + : PluginUtils.getPluginDir(NotificationConstants.NOTIFICATIONS_SUBDIRECTORY) + .resolve(NotificationConstants.NOTIFICATIONS_CACHE_FILENAME); + } + + /** Never throws. Returns the parsed notifications, or empty when there is nothing to show. */ + public Optional fetch() { + try { + if (endpointUrl == null || endpointUrl.isBlank()) { + return getResourceFromCache(); + } + if (endpointUrl.regionMatches(true, 0, "file:", 0, 5)) { + return readLocalFile(endpointUrl); + } + return fetchRemoteWithRetries(); + } catch (Exception e) { + Activator.getLogger().warn("Unexpected error fetching notifications", e); + return Optional.empty(); + } + } + + private Optional fetchRemoteWithRetries() { + final Optional cached = getResourceFromCache(); + final String cachedEtag = Activator.getPluginStore().get(endpointUrl); + final String etagToRequest = cached.isPresent() && cachedEtag != null ? cachedEtag : null; + + Exception lastTransient = null; + for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + final HttpResponse response = getResourceFromRemote(etagToRequest); + final int status = response.statusCode(); + + if (status == HttpURLConnection.HTTP_NOT_MODIFIED) { + if (cached.isPresent()) { + return cached; + } + // ETag stored but cache is gone/invalid: clear it so the next poll re-fetches fresh. + Activator.getLogger().warn("Notifications returned 304 but cache is missing; clearing ETag"); + Activator.getPluginStore().remove(endpointUrl); + return Optional.empty(); + } + if (status == HttpURLConnection.HTTP_OK) { + return validateAndCache(response.body()); + } + // 403/404 (file not deployed yet) and any other non-2xx: not an error condition, show nothing. + Activator.getLogger().info("No notifications available (HTTP " + status + ")"); + return Optional.empty(); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + return cached; + } + lastTransient = e; + sleepBeforeRetry(attempt); + } + } + Activator.getLogger().warn("Failed to fetch notifications after retries; using cache if present", lastTransient); + return cached; + } + + private HttpResponse getResourceFromRemote(final String etag) throws IOException, InterruptedException { + final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(endpointUrl)) + .timeout(Duration.ofSeconds(TIMEOUT_SECONDS)); + Optional.ofNullable(etag).ifPresent(tag -> requestBuilder.header("If-None-Match", tag)); + return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private Optional readLocalFile(final String fileUrl) { + try { + // Prefer strict URI parsing; fall back to stripping the scheme for a plain path if the URI is not + // strictly legal (e.g. an un-encoded path pasted as file:///...). + Path path; + try { + path = Path.of(URI.create(fileUrl)); + } catch (IllegalArgumentException e) { + path = Path.of(fileUrl.replaceFirst("(?i)^file://", "")); + } + return validate(Files.readString(path)); + } catch (Exception e) { + Activator.getLogger().warn("Failed to read local notifications file: " + fileUrl, e); + return Optional.empty(); + } + } + + private Optional getResourceFromCache() { + try { + if (Files.exists(cachePath)) { + final Optional parsed = validate(Files.readString(cachePath)); + if (parsed.isEmpty()) { + Files.deleteIfExists(cachePath); + Activator.getLogger().info("Deleted corrupt cached notifications file"); + } + return parsed; + } + } catch (Exception e) { + Activator.getLogger().warn("Error reading cached notifications", e); + } + return Optional.empty(); + } + + private Optional validate(final String content) { + if (content == null || content.isBlank()) { + return Optional.empty(); + } + try { + return Optional.ofNullable(OBJECT_MAPPER.readValue(content, NotificationsList.class)); + } catch (Exception e) { + Activator.getLogger().warn("Failed to parse notifications payload", e); + return Optional.empty(); + } + } + + private Optional validateAndCache(final String body) { + final Optional parsed = validate(body); + if (parsed.isEmpty()) { + // Do not cache a bad body; keep any prior valid cache untouched. + return getResourceFromCache(); + } + try { + final Path tmp = cachePath.resolveSibling(cachePath.getFileName() + ".tmp"); + Files.createDirectories(cachePath.getParent()); + Files.writeString(tmp, body); + Files.move(tmp, cachePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (Exception e) { + Activator.getLogger().warn("Failed to cache notifications file", e); + } + return parsed; + } + + private void sleepBeforeRetry(final int attempt) { + if (attempt >= MAX_RETRIES - 1) { + return; + } + try { + Thread.sleep(RETRY_BASE_DELAY_MS * (1L << attempt)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsList.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsList.java new file mode 100644 index 000000000..fc128e8ce --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsList.java @@ -0,0 +1,17 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.List; + +/** + * Root of the hosted notifications file: {@code { "schema": { "version": "2.0" }, "notifications": [ ... ] }}. + * The schema version is parsed but not validated (matching the JetBrains client), and {@code notifications} + * may be {@code null} or empty when there is nothing to show. + */ +public record NotificationsList(Schema schema, List notifications) { + + /** Schema descriptor; only the version string is carried. */ + public record Schema(String version) { } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotifications.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotifications.java new file mode 100644 index 000000000..c94656561 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotifications.java @@ -0,0 +1,119 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.PlatformUI; + +import software.aws.toolkits.eclipse.amazonq.notifications.AmazonQNotificationPopup.NotificationAction; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.LocalizedContent; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationScheduleType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationSeverity; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; + +/** + * Filters a fetched notifications payload and shows the survivors. Runs on the poll (worker) thread: it snapshots the + * system/auth state once, applies STARTUP-once + dismissal + rules + in-session dedup, then marshals each surviving + * toast onto the SWT UI thread. STARTUP notifications show only on the first poll of a session; an undismissed EMERGENCY + * shows once per session (guarded by an in-memory set) rather than re-toasting on every poll. + */ +public final class ProcessNotifications { + + /** Renders a notification that has passed all filtering. Injectable so tests can observe without SWT. */ + public interface NotificationDisplay { + void show(String id, NotificationData notification, LocalizedContent content, + List actions); + } + + private final AtomicBoolean isFirstPoll = new AtomicBoolean(true); + private final Set shownThisSession = ConcurrentHashMap.newKeySet(); + private final NotificationDismissalStore dismissalStore; + private final NotificationDisplay display; + + public ProcessNotifications(final NotificationDismissalStore dismissalStore) { + this(dismissalStore, ProcessNotifications::showToast); + } + + public ProcessNotifications(final NotificationDismissalStore dismissalStore, final NotificationDisplay display) { + this.dismissalStore = dismissalStore; + this.display = display; + } + + public void process(final NotificationsList list) { + if (list == null || list.notifications() == null || list.notifications().isEmpty()) { + return; + } + final boolean isStartupPoll = isFirstPoll.compareAndSet(true, false); + final SystemDetails sys = SystemDetailsCollector.collect(); + + for (final NotificationData notification : list.notifications()) { + processOne(notification, isStartupPoll, sys); + } + } + + private void processOne(final NotificationData notification, final boolean isStartupPoll, final SystemDetails sys) { + final String id = notification.id(); + if (id == null) { + return; + } + final boolean isStartup = notification.schedule() != null + && notification.schedule().type() == NotificationScheduleType.STARTUP; + if (isStartup && !isStartupPoll) { + return; + } + if (dismissalStore.isDismissed(id)) { + return; + } + if (!RulesEngine.displayNotification(notification, sys)) { + return; + } + if (notification.content() == null || notification.content().enUs() == null) { + Activator.getLogger().info("Skipping notification with no en-US content: " + id); + return; + } + final LocalizedContent content = notification.content().enUs(); + if (isBlank(content.title()) || isBlank(content.description())) { + Activator.getLogger().info("Skipping notification with blank title/description: " + id); + return; + } + if (!shownThisSession.add(id)) { + return; + } + final List actions = new ArrayList<>(NotificationActionFactory.createActions( + id, notification.actions(), content.title(), content.description())); + // The explicit "Dismiss" button persists the dismissal so the notification does not reappear; + // closing/auto-fading or clicking another action does NOT dismiss (an emergency re-shows next session). + actions.add(new NotificationAction("Dismiss", () -> dismissalStore.dismiss(id))); + NotificationTelemetryProvider.emitShowNotification(id); + display.show(id, notification, content, actions); + } + + private static void showToast(final String id, final NotificationData notification, final LocalizedContent content, + final List actions) { + final NotificationSeverity severity = NotificationSeverity.fromString(notification.severity()); + Activator.getLogger().info("Showing notification toast: " + id + " (severity=" + severity + ")"); + Display.getDefault().asyncExec(() -> { + if (!PlatformUI.isWorkbenchRunning()) { + Activator.getLogger().info("Workbench not running; skipping notification toast: " + id); + return; + } + try { + new AmazonQNotificationPopup(Display.getCurrent(), content.title(), content.description(), severity, + actions).open(); + } catch (Exception e) { + Activator.getLogger().error("Failed to render notification toast: " + id, e); + } + }); + } + + private static boolean isBlank(final String s) { + return s == null || s.isBlank(); + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngine.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngine.java new file mode 100644 index 000000000..38b150d77 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngine.java @@ -0,0 +1,165 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.List; +import java.util.Map; + +import org.apache.maven.artifact.versioning.ArtifactVersion; + +import software.aws.toolkits.eclipse.amazonq.lsp.manager.fetcher.ArtifactUtils; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.AuthxType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.ComputeType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.ExtensionType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationDisplayCondition; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.SystemType; + +/** + * Pure evaluator that decides whether a notification's display conditions match the current system/auth state. + * Ported 1:1 from the JetBrains RulesEngine: a null condition matches everyone; a present condition is an AND of its + * five optional blocks; version comparisons use semver only for {@code ide.version} and {@code extension.version}. + */ +public final class RulesEngine { + + private static final String CLEAN_SEMVER = "^\\d+(\\.\\d+)*$"; + + private RulesEngine() { + // prevent instantiation + } + + /** {@code condition == null} shows the notification to everyone. */ + public static boolean displayNotification(final NotificationData notification, final SystemDetails sys) { + final NotificationDisplayCondition condition = notification.condition(); + return condition == null || matchesAllRules(condition, sys); + } + + static boolean matchesAllRules(final NotificationDisplayCondition c, final SystemDetails sys) { + final boolean compute = c.compute() == null + || matchesCompute(c.compute(), sys.computeType(), sys.computeArchitecture()); + final boolean os = c.os() == null || matchesOs(c.os(), sys.osType(), sys.osVersion()); + final boolean ide = c.ide() == null || matchesIde(c.ide(), sys.ideType(), sys.ideVersion()); + final boolean extension = matchesExtension(c.extension(), sys.pluginVersions()); + final boolean authx = matchesAuth(c.authx(), sys); + return compute && os && ide && extension && authx; + } + + private static boolean matchesCompute(final ComputeType nc, final String type, final String arch) { + final boolean typeMatch = nc.type() == null || evaluateNotificationExpression(nc.type(), type); + final boolean archMatch = nc.architecture() == null || evaluateNotificationExpression(nc.architecture(), arch); + return typeMatch && archMatch; + } + + private static boolean matchesOs(final SystemType no, final String os, final String osVersion) { + final boolean typeMatch = no.type() == null || evaluateNotificationExpression(no.type(), os); + final boolean versionMatch = no.version() == null || evaluateNotificationExpression(no.version(), osVersion); + return typeMatch && versionMatch; + } + + private static boolean matchesIde(final SystemType ni, final String ide, final String ideVersion) { + final boolean typeMatch = ni.type() == null || evaluateNotificationExpression(ni.type(), ide); + final boolean versionMatch = ni.version() == null || evaluateNotificationExpression(ni.version(), ideVersion, true); + return typeMatch && versionMatch; + } + + private static boolean matchesExtension(final List ne, final Map installedVersions) { + if (ne == null || ne.isEmpty()) { + return true; + } + boolean anyInstalled = false; + for (final ExtensionType ext : ne) { + final String installed = installedVersions.get(ext.id()); + if (installed == null) { + continue; + } + anyInstalled = true; + // Development builds must never receive notifications. + if (installed.toLowerCase(java.util.Locale.ROOT).contains("snapshot")) { + return false; + } + if (ext.version() != null && !evaluateNotificationExpression(ext.version(), installed, true)) { + return false; + } + } + // Declared but none of the extensions are installed -> do not show. + return anyInstalled; + } + + private static boolean matchesAuth(final List na, final SystemDetails sys) { + if (na == null || na.isEmpty()) { + return true; + } + for (final AuthxType feature : na) { + if (!"q".equals(feature.feature())) { + // Faithful to JetBrains: any non-"q" feature passes. + continue; + } + final FeatureAuthDetails auth = sys.qAuth(); + if (auth == null) { + return false; + } + final boolean typeMatch = feature.type() == null + || evaluateNotificationExpression(feature.type(), auth.connectionType()); + final boolean regionMatch = feature.region() == null + || evaluateNotificationExpression(feature.region(), auth.region()); + final boolean stateMatch = feature.connectionState() == null + || evaluateNotificationExpression(feature.connectionState(), auth.connectionState()); + if (!(typeMatch && regionMatch && stateMatch)) { + return false; + } + } + return true; + } + + /** Evaluates an expression against an actual value, using string comparison for ordering operators. */ + public static boolean evaluateNotificationExpression(final NotificationExpression expr, final String value) { + return evaluateNotificationExpression(expr, value, false); + } + + /** Evaluates an expression; when {@code useSemver} is true, ordering operators compare versions semantically. */ + public static boolean evaluateNotificationExpression(final NotificationExpression expr, final String value, + final boolean useSemver) { + if (expr instanceof NotificationExpression.ComparisonCondition c) { + return c.value().equals(value); + } else if (expr instanceof NotificationExpression.NotEqualsCondition c) { + return !c.value().equals(value); + } else if (expr instanceof NotificationExpression.GreaterThanCondition c) { + return compare(value, c.value(), useSemver) > 0; + } else if (expr instanceof NotificationExpression.GreaterThanOrEqualsCondition c) { + return compare(value, c.value(), useSemver) >= 0; + } else if (expr instanceof NotificationExpression.LessThanCondition c) { + return compare(value, c.value(), useSemver) < 0; + } else if (expr instanceof NotificationExpression.LessThanOrEqualsCondition c) { + return compare(value, c.value(), useSemver) <= 0; + } else if (expr instanceof NotificationExpression.AnyOfCondition c) { + return c.value().contains(value); + } else if (expr instanceof NotificationExpression.NoneOfCondition c) { + return !c.value().contains(value); + } else if (expr instanceof NotificationExpression.NotCondition c) { + return !evaluateNotificationExpression(c.expectedValue(), value, useSemver); + } else if (expr instanceof NotificationExpression.OrCondition c) { + return c.expectedValueList().stream().anyMatch(e -> evaluateNotificationExpression(e, value, useSemver)); + } else if (expr instanceof NotificationExpression.AndCondition c) { + return c.expectedValueList().stream().allMatch(e -> evaluateNotificationExpression(e, value, useSemver)); + } + return true; + } + + private static int compare(final String actual, final String expected, final boolean useSemver) { + return useSemver ? compareSemver(actual, expected) : actual.compareTo(expected); + } + + private static int compareSemver(final String actual, final String expected) { + // Match JetBrains: fall back to lexical comparison when either side is not clean numeric semver. + if (!isCleanSemver(actual) || !isCleanSemver(expected)) { + return actual.compareTo(expected); + } + final ArtifactVersion actualVersion = ArtifactUtils.parseVersion(actual); + final ArtifactVersion expectedVersion = ArtifactUtils.parseVersion(expected); + return actualVersion.compareTo(expectedVersion); + } + + private static boolean isCleanSemver(final String v) { + return v != null && v.matches(CLEAN_SEMVER); + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetails.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetails.java new file mode 100644 index 000000000..c69d022d6 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetails.java @@ -0,0 +1,18 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.Map; + +/** Immutable snapshot of the current system + auth state that a notification's conditions are evaluated against. */ +public record SystemDetails( + String computeType, + String computeArchitecture, + String osType, + String osVersion, + String ideType, + String ideVersion, + Map pluginVersions, + FeatureAuthDetails qAuth) { +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetailsCollector.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetailsCollector.java new file mode 100644 index 000000000..03c94f023 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetailsCollector.java @@ -0,0 +1,109 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import java.util.Map; + +import org.eclipse.core.runtime.Platform; +import org.osgi.framework.Bundle; +import org.osgi.framework.FrameworkUtil; +import org.osgi.framework.Version; + +import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.AuthState; +import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.AuthStateType; +import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.LoginType; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; + +/** Resolves the current system + auth state into an immutable {@link SystemDetails} snapshot for the rules engine. */ +public final class SystemDetailsCollector { + + private static final String PLATFORM_BUNDLE_ID = "org.eclipse.platform"; + private static final String UNKNOWN = "Unknown"; + + private SystemDetailsCollector() { + // prevent instantiation + } + + /** Snapshots {@code getAuthState()} once and resolves everything into an immutable {@link SystemDetails}. */ + public static SystemDetails collect() { + final Bundle pluginBundle = FrameworkUtil.getBundle(SystemDetailsCollector.class); + final String pluginId = pluginBundle != null ? pluginBundle.getSymbolicName() : UNKNOWN; + final String pluginVersion = pluginBundle != null ? pluginBundle.getVersion().toString() : UNKNOWN; + + return new SystemDetails( + "Local", + Platform.getOSArch(), + System.getProperty("os.name"), + System.getProperty("os.version"), + "Eclipse", + resolveIdeVersion(), + Map.of(pluginId, pluginVersion), + resolveQAuth()); + } + + /** Amazon Q Eclipse bundle symbolic name — the key notification payloads use for {@code extension.id}. */ + public static String pluginId() { + final Bundle pluginBundle = FrameworkUtil.getBundle(SystemDetailsCollector.class); + return pluginBundle != null ? pluginBundle.getSymbolicName() : UNKNOWN; + } + + private static String resolveIdeVersion() { + final Bundle platform = Platform.getBundle(PLATFORM_BUNDLE_ID); + if (platform == null) { + return UNKNOWN; + } + final Version v = platform.getVersion(); + return v.getMajor() + "." + v.getMinor() + "." + v.getMicro(); + } + + private static FeatureAuthDetails resolveQAuth() { + final AuthState authState = Activator.getLoginService().getAuthState(); + if (authState == null) { + return new FeatureAuthDetails(UNKNOWN, UNKNOWN, "NotConnected"); + } + return new FeatureAuthDetails( + mapConnectionType(authState.loginType()), + mapRegion(authState), + mapConnectionState(authState.authStateType())); + } + + private static String mapConnectionType(final LoginType loginType) { + if (loginType == null) { + return UNKNOWN; + } + switch (loginType) { + case BUILDER_ID: + return "BuilderId"; + case IAM_IDENTITY_CENTER: + return "Idc"; + default: + return UNKNOWN; + } + } + + private static String mapConnectionState(final AuthStateType authStateType) { + if (authStateType == null) { + return "NotConnected"; + } + switch (authStateType) { + case LOGGED_IN: + return "Connected"; + case EXPIRED: + return "Expired"; + case LOGGED_OUT: + default: + return "NotConnected"; + } + } + + private static String mapRegion(final AuthState authState) { + if (authState.loginParams() != null && authState.loginParams().getLoginIdcParams() != null) { + final String region = authState.loginParams().getLoginIdcParams().getRegion(); + if (region != null && !region.isBlank()) { + return region; + } + } + return UNKNOWN; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java index 233bfb2a5..39f965adf 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java @@ -22,6 +22,7 @@ import software.aws.toolkits.eclipse.amazonq.util.DefaultCodeReferenceLoggingService; import software.aws.toolkits.eclipse.amazonq.util.LoggingService; import software.aws.toolkits.eclipse.amazonq.util.PluginLogger; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationPollingService; import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils; import software.aws.toolkits.eclipse.amazonq.views.router.ViewRouter; import software.aws.toolkits.eclipse.workspace.WorkspaceChangeListener; @@ -63,6 +64,7 @@ public Activator() { @Override public final void stop(final BundleContext context) throws Exception { + NotificationPollingService.getInstance().stop(); AmazonQBrowserProvider.getInstance().dispose(); super.stop(context); plugin = null; diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java index fc40c1512..63f700700 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java @@ -22,6 +22,8 @@ public final void initializeDefaultPreferences() { store.setDefault(AmazonQPreferencePage.Q_DATA_SHARING, true); store.setDefault(AmazonQPreferencePage.HTTPS_PROXY, ""); store.setDefault(AmazonQPreferencePage.CA_CERT, ""); + store.setDefault(AmazonQPreferencePage.NOTIFICATIONS_OPT_IN, true); + store.setDefault(AmazonQPreferencePage.NOTIFICATIONS_ENDPOINT_OVERRIDE, ""); store.addPropertyChangeListener(event -> { ThreadingUtils.executeAsyncTask(() -> { Activator.getLspProvider().getAmazonQServer() diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java index 7f55c482f..7dee941f9 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java @@ -40,6 +40,8 @@ public class AmazonQPreferencePage extends FieldEditorPreferencePage implements public static final String Q_DATA_SHARING = "qDataSharing"; public static final String HTTPS_PROXY = "httpsProxy"; public static final String CA_CERT = "customCaCert"; + public static final String NOTIFICATIONS_OPT_IN = "notificationsOptIn"; + public static final String NOTIFICATIONS_ENDPOINT_OVERRIDE = "notificationsEndpointOverride"; private Boolean isTelemetryOptInChecked; private Boolean isQDataSharingOptInChecked; @@ -74,6 +76,8 @@ protected final void createFieldEditors() { createTelemetryOptInField(); createHorizontalSeparator(); createQDataSharingField(); + createHeading("Notifications"); + createNotificationsOptInField(); createHeading("Proxy Settings"); createHttpsProxyField(); createCaCertField(); @@ -154,6 +158,18 @@ public void widgetSelected(final SelectionEvent event) { }); } + private void createNotificationsOptInField() { + Composite notificationsOptInComposite = new Composite(getFieldEditorParent(), SWT.NONE); + notificationsOptInComposite.setLayout(new GridLayout(2, false)); + GridData notificationsOptInCompositeData = new GridData(SWT.FILL, SWT.CENTER, true, false); + notificationsOptInCompositeData.horizontalIndent = 20; + notificationsOptInComposite.setLayoutData(notificationsOptInCompositeData); + + BooleanFieldEditor notificationsOptIn = new BooleanFieldEditor(NOTIFICATIONS_OPT_IN, + "Show Amazon Q notifications about known issues and available fixes", notificationsOptInComposite); + addField(notificationsOptIn); + } + private void createQDataSharingField() { Composite qDataSharingComposite = new Composite(getFieldEditorParent(), SWT.NONE); qDataSharingComposite.setLayout(new GridLayout(2, false)); diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java index e2b009eb7..8b3b24a25 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java @@ -59,4 +59,6 @@ private Constants() { public static final String INLINE_CHAT_EXPIRED_AUTH_BODY = "Login status expired; please open Q plugin window to reauthenticate."; public static final String INLINE_CHAT_CONTEXT_ID = "org.eclipse.ui.inlineChatContext"; public static final String INLINE_SUGGESTIONS_CONTEXT_ID = "org.eclipse.ui.suggestionsContext"; + public static final String AMAZON_Q_CHANGELOG_URL = "https://github.com/aws/amazon-q-eclipse/blob/main/CHANGELOG.md"; + public static final String AMAZON_Q_UPDATE_SITE_URL = "https://marketplace.eclipse.org/content/amazon-q"; } diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactoryTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactoryTest.java new file mode 100644 index 000000000..68a7ab313 --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationActionFactoryTest.java @@ -0,0 +1,93 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.MockedStatic; + +import software.aws.toolkits.eclipse.amazonq.extensions.implementation.ActivatorStaticMockExtension; +import software.aws.toolkits.eclipse.amazonq.notifications.AmazonQNotificationPopup.NotificationAction; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.LocalizedAction; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationFollowupAction; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationFollowupActionContent; +import software.aws.toolkits.eclipse.amazonq.util.Constants; +import software.aws.toolkits.eclipse.amazonq.util.PluginUtils; + +/** Coverage for the pure action-to-button mapping (ShowUrl-is-not-a-button, always-append-More, unknown-ignored). */ +public final class NotificationActionFactoryTest { + + @RegisterExtension + private static ActivatorStaticMockExtension activatorExtension = new ActivatorStaticMockExtension(); + + private static NotificationFollowupAction action(final String type, final String title, final String url) { + return new NotificationFollowupAction(type, + new NotificationFollowupActionContent(new LocalizedAction(title, url))); + } + + @Test + void noActionsStillYieldsMoreButton() { + final List result = NotificationActionFactory.createActions("id", null, "t", "d"); + assertEquals(1, result.size()); + assertEquals("More", result.get(0).label()); + } + + @Test + void updateAndChangelogBecomeButtonsPlusMore() { + final List result = NotificationActionFactory.createActions("id", + List.of(action("UpdateExtension", "Update", null), action("OpenChangelog", "Changelog", null)), + "t", "d"); + assertEquals(3, result.size()); + assertEquals("Update", result.get(0).label()); + assertEquals("View changelog", result.get(1).label()); + assertEquals("More", result.get(2).label()); + } + + @Test + void showUrlSuppliesMoreUrlButNoOwnButton() { + final List result = NotificationActionFactory.createActions("id", + List.of(action("ShowUrl", "Click me", "https://x.test")), "t", "d"); + assertEquals(1, result.size()); + assertEquals("More", result.get(0).label()); + + try (MockedStatic pluginUtils = mockStatic(PluginUtils.class)) { + result.get(0).onClick().run(); + pluginUtils.verify(() -> PluginUtils.handleExternalLinkClick(eq("https://x.test"))); + } + } + + @Test + void unknownActionTypeIgnored() { + final List result = NotificationActionFactory.createActions("id", + List.of(action("ShowMarketplace", "Go", null)), "t", "d"); + assertEquals(1, result.size()); + assertEquals("More", result.get(0).label()); + } + + @Test + void lowercaseShowurlIsNotTreatedAsShowUrl() { + // Case-sensitive: "showurl" is unknown, so it is ignored (no URL captured, only More remains). + final List result = NotificationActionFactory.createActions("id", + List.of(action("showurl", "x", "https://x.test")), "t", "d"); + assertEquals(1, result.size()); + } + + @Test + void updateButtonOpensUpdateSite() { + final List result = NotificationActionFactory.createActions("id", + List.of(action("UpdateExtension", "Update", null)), "t", "d"); + try (MockedStatic pluginUtils = mockStatic(PluginUtils.class)) { + result.get(0).onClick().run(); + pluginUtils.verify(() -> PluginUtils.openWebpage(eq(Constants.AMAZON_Q_UPDATE_SITE_URL))); + } + assertTrue(true); + } +} diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStoreTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStoreTest.java new file mode 100644 index 000000000..3a53c1241 --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationDismissalStoreTest.java @@ -0,0 +1,80 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.eclipse.core.internal.preferences.EclipsePreferences; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import software.aws.toolkits.eclipse.amazonq.configuration.DefaultPluginStore; +import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore; +import software.aws.toolkits.eclipse.amazonq.extensions.implementation.ActivatorStaticMockExtension; + +/** Round-trip + retention + concurrency-safety coverage for the dismissal store, using a real Gson-backed PluginStore. */ +public final class NotificationDismissalStoreTest { + + @RegisterExtension + private static ActivatorStaticMockExtension activatorExtension = new ActivatorStaticMockExtension(); + + private PluginStore pluginStore; + + @BeforeEach + void setUp() { + pluginStore = new DefaultPluginStore(new EclipsePreferences()); + } + + @Test + void dismissThenIsDismissedRoundTrips() { + final NotificationDismissalStore store = new NotificationDismissalStore(pluginStore); + assertFalse(store.isDismissed("n1")); + store.dismiss("n1"); + assertTrue(store.isDismissed("n1")); + // A fresh store reading the same PluginStore sees the persisted dismissal. + assertTrue(new NotificationDismissalStore(pluginStore).isDismissed("n1")); + } + + @Test + void dismissIsIdempotent() { + final NotificationDismissalStore store = new NotificationDismissalStore(pluginStore); + store.dismiss("n1"); + store.dismiss("n1"); + final NotificationDismissalConfiguration config = + pluginStore.getObject(NotificationConstants.DISMISSAL_STORAGE_KEY, NotificationDismissalConfiguration.class); + assertTrue(config.getDismissedNotifications().size() == 1); + } + + @Test + void expiredDismissalsAreCleanedOnRead() { + final NotificationDismissalConfiguration config = new NotificationDismissalConfiguration(); + final long old = Instant.now().minus(Duration.ofDays(61)).toEpochMilli(); + config.setDismissedNotifications(new java.util.ArrayList<>(List.of(new DismissedNotification("stale", old)))); + pluginStore.putObject(NotificationConstants.DISMISSAL_STORAGE_KEY, config); + + final NotificationDismissalStore store = new NotificationDismissalStore(pluginStore); + assertFalse(store.isDismissed("stale")); + } + + @Test + void recentDismissalsSurviveCleanup() { + final NotificationDismissalConfiguration config = new NotificationDismissalConfiguration(); + final long recent = Instant.now().minus(Duration.ofDays(5)).toEpochMilli(); + config.setDismissedNotifications(new java.util.ArrayList<>(List.of(new DismissedNotification("fresh", recent)))); + pluginStore.putObject(NotificationConstants.DISMISSAL_STORAGE_KEY, config); + + assertTrue(new NotificationDismissalStore(pluginStore).isDismissed("fresh")); + } + + @Test + void noStateReturnsNotDismissed() { + assertFalse(new NotificationDismissalStore(pluginStore).isDismissed("anything")); + } +} diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationMiscTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationMiscTest.java new file mode 100644 index 000000000..4853591de --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationMiscTest.java @@ -0,0 +1,55 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.ui.ISharedImages; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import software.aws.toolkits.eclipse.amazonq.extensions.implementation.ActivatorStaticMockExtension; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationSeverity; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.preferences.AmazonQPreferencePage; + +/** Coverage for the icon mapping and the preference/endpoint resolver. */ +public final class NotificationMiscTest { + + @RegisterExtension + private static ActivatorStaticMockExtension activatorExtension = new ActivatorStaticMockExtension(); + + @Test + void iconKeyMapsSeverity() { + assertEquals(ISharedImages.IMG_OBJS_ERROR_TSK, AmazonQNotificationPopup.iconKey(NotificationSeverity.CRITICAL)); + assertEquals(ISharedImages.IMG_OBJS_WARN_TSK, AmazonQNotificationPopup.iconKey(NotificationSeverity.WARNING)); + assertEquals(ISharedImages.IMG_OBJS_INFO_TSK, AmazonQNotificationPopup.iconKey(NotificationSeverity.INFO)); + } + + @Test + void notificationsEnabledReadsPreference() { + final IPreferenceStore store = Activator.getDefault().getPreferenceStore(); + when(store.getBoolean(eq(AmazonQPreferencePage.NOTIFICATIONS_OPT_IN))).thenReturn(true); + assertTrue(NotificationPreferences.isNotificationsEnabled()); + + when(store.getBoolean(eq(AmazonQPreferencePage.NOTIFICATIONS_OPT_IN))).thenReturn(false); + assertFalse(NotificationPreferences.isNotificationsEnabled()); + } + + @Test + void resolveEndpointPrefersOverrideThenDefault() { + final IPreferenceStore store = Activator.getDefault().getPreferenceStore(); + when(store.getString(eq(AmazonQPreferencePage.NOTIFICATIONS_ENDPOINT_OVERRIDE))).thenReturn("https://override.test/x.json"); + assertEquals("https://override.test/x.json", NotificationPreferences.resolveEndpoint()); + + when(store.getString(eq(AmazonQPreferencePage.NOTIFICATIONS_ENDPOINT_OVERRIDE))).thenReturn(""); + // With no override and (in a test JVM) no env var, falls through to the prod default. + assertEquals(NotificationConstants.NOTIFICATIONS_ENDPOINT, NotificationPreferences.resolveEndpoint()); + } +} diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationParsingTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationParsingTest.java new file mode 100644 index 000000000..fccf606b6 --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationParsingTest.java @@ -0,0 +1,211 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.AuthxType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.ExtensionType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationScheduleType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationSeverity; +import software.aws.toolkits.eclipse.amazonq.util.ObjectMapperFactory; + +/** + * Parse-only coverage for the notification data model and the condition-DSL deserializer. This is the + * Phase-1 de-risking test: it proves the JetBrains schema-2.x payload (including the polymorphic operator + * DSL) round-trips through Jackson in Java. + */ +public final class NotificationParsingTest { + + private final ObjectMapper mapper = ObjectMapperFactory.getInstance(); + + // The canonical JetBrains example payload (exercises every operator + nesting). Note the file uses the + // "extensions" (plural) key, which is a latent bug in that fixture: our model reads the singular + // "extension", so this block deserializes to null here (asserted below), matching the JetBrains client. + private static final String EXAMPLE_JSON = """ + { + "schema": { "version": "2.0" }, + "notifications": [ + { + "id": "example_id_12344", + "schedule": { "type": "StartUp" }, + "severity": "Critical", + "condition": { + "compute": { + "type": { "or": [ { "==": "ec2" }, { "==": "desktop" } ] }, + "architecture": { "!=": "x64" } + }, + "os": { + "type": { "anyOf": [ "Darwin", "Linux" ] }, + "version": { "<=": "23.0.1.0" } + }, + "ide": { + "type": { "noneOf": [ "PyCharm", "IDEA" ] }, + "version": { "and": [ { ">=": "1.0" }, { "<": "2.0" } ] } + }, + "extension": [ + { "id": "aws.toolkit", "version": { "!=": "1.3334" } }, + { "id": "amazon.q", "version": { "!=": "3.37.0" } } + ], + "authx": [ { + "feature": "q", + "type": { "anyOf": [ "IamIdentityCenter", "AwsBuilderId" ] }, + "region": { "==": "us-east-1" }, + "connectionState": { "!=": "Connected" }, + "ssoScopes": { "noneOf": [ "codewhisperer:scope1", "sso:account:access" ] } + } ] + }, + "content": { + "en-US": { "title": "Look at this!", "description": "Some bug is there" } + }, + "actions": [ + { "type": "ShowMarketplace", "content": { "en-US": { "title": "Go to market" } } }, + { "type": "ShowUrl", "content": { "en-US": { "title": "Click me!", "url": "http://nowhere" } } } + ] + } + ] + } + """; + + @Test + void parsesFullExamplePayload() throws Exception { + NotificationsList list = mapper.readValue(EXAMPLE_JSON, NotificationsList.class); + + assertEquals("2.0", list.schema().version()); + assertEquals(1, list.notifications().size()); + + NotificationData n = list.notifications().get(0); + assertEquals("example_id_12344", n.id()); + assertEquals(NotificationScheduleType.STARTUP, n.schedule().type()); + assertEquals(NotificationSeverity.CRITICAL, NotificationSeverity.fromString(n.severity())); + assertEquals("Look at this!", n.content().enUs().title()); + assertEquals("Some bug is there", n.content().enUs().description()); + } + + @Test + void parsesEveryDslOperator() throws Exception { + NotificationsList list = mapper.readValue(EXAMPLE_JSON, NotificationsList.class); + NotificationData.NotificationDisplayCondition cond = list.notifications().get(0).condition(); + + // or / == (compute.type), != (compute.architecture) + assertInstanceOf(NotificationExpression.OrCondition.class, cond.compute().type()); + NotificationExpression.OrCondition or = (NotificationExpression.OrCondition) cond.compute().type(); + assertEquals(2, or.expectedValueList().size()); + assertInstanceOf(NotificationExpression.ComparisonCondition.class, or.expectedValueList().get(0)); + assertEquals("ec2", ((NotificationExpression.ComparisonCondition) or.expectedValueList().get(0)).value()); + assertInstanceOf(NotificationExpression.NotEqualsCondition.class, cond.compute().architecture()); + + // anyOf (os.type), <= (os.version) + NotificationExpression.AnyOfCondition anyOf = (NotificationExpression.AnyOfCondition) cond.os().type(); + assertEquals(List.of("Darwin", "Linux"), anyOf.value()); + assertInstanceOf(NotificationExpression.LessThanOrEqualsCondition.class, cond.os().version()); + + // noneOf (ide.type), and[>=, <] (ide.version) + assertInstanceOf(NotificationExpression.NoneOfCondition.class, cond.ide().type()); + NotificationExpression.AndCondition and = (NotificationExpression.AndCondition) cond.ide().version(); + assertEquals(2, and.expectedValueList().size()); + assertInstanceOf(NotificationExpression.GreaterThanOrEqualsCondition.class, and.expectedValueList().get(0)); + assertInstanceOf(NotificationExpression.LessThanCondition.class, and.expectedValueList().get(1)); + } + + @Test + void parsesSingularExtensionArray() throws Exception { + // The example fixture uses the plural "extension" key here (we corrected it in EXAMPLE_JSON), and our + // model reads the singular field name — so the array is populated, not silently dropped. + NotificationsList list = mapper.readValue(EXAMPLE_JSON, NotificationsList.class); + List extensions = list.notifications().get(0).condition().extension(); + + assertEquals(2, extensions.size()); + assertEquals("aws.toolkit", extensions.get(0).id()); + assertEquals("amazon.q", extensions.get(1).id()); + assertInstanceOf(NotificationExpression.NotEqualsCondition.class, extensions.get(0).version()); + } + + @Test + void parsesAuthxAndActions() throws Exception { + NotificationsList list = mapper.readValue(EXAMPLE_JSON, NotificationsList.class); + NotificationData n = list.notifications().get(0); + + AuthxType authx = n.condition().authx().get(0); + assertEquals("q", authx.feature()); + assertInstanceOf(NotificationExpression.AnyOfCondition.class, authx.type()); + assertInstanceOf(NotificationExpression.NoneOfCondition.class, authx.ssoScopes()); + + // Both actions parse (ShowMarketplace is unknown to the handler but must still deserialize cleanly). + assertEquals(2, n.actions().size()); + assertEquals("ShowMarketplace", n.actions().get(0).type()); + assertEquals("ShowUrl", n.actions().get(1).type()); + assertEquals("http://nowhere", n.actions().get(1).content().enUs().url()); + } + + @Test + void scheduleTypeMapsCaseInsensitivelyAndDefaultsToEmergency() { + assertEquals(NotificationScheduleType.STARTUP, NotificationScheduleType.fromString("startup")); + assertEquals(NotificationScheduleType.STARTUP, NotificationScheduleType.fromString("StartUp")); + assertEquals(NotificationScheduleType.EMERGENCY, NotificationScheduleType.fromString("Emergency")); + assertEquals(NotificationScheduleType.EMERGENCY, NotificationScheduleType.fromString("typo")); + assertEquals(NotificationScheduleType.EMERGENCY, NotificationScheduleType.fromString(null)); + } + + @Test + void severityMapsExactCaseAndDefaultsToInfo() { + assertEquals(NotificationSeverity.CRITICAL, NotificationSeverity.fromString("Critical")); + assertEquals(NotificationSeverity.WARNING, NotificationSeverity.fromString("Warning")); + assertEquals(NotificationSeverity.INFO, NotificationSeverity.fromString("Info")); + assertEquals(NotificationSeverity.INFO, NotificationSeverity.fromString("critical")); + assertEquals(NotificationSeverity.INFO, NotificationSeverity.fromString(null)); + } + + @Test + void parsesEmptyNotificationsList() throws Exception { + NotificationsList list = mapper.readValue( + "{ \"schema\": { \"version\": \"2.0\" }, \"notifications\": [] }", NotificationsList.class); + assertTrue(list.notifications().isEmpty()); + } + + @Test + void ignoresUnknownTopLevelKeysAndAllowsNullCondition() throws Exception { + NotificationsList list = mapper.readValue(""" + { + "schema": { "version": "2.0" }, + "futureField": "ignored", + "notifications": [ + { "id": "n1", "schedule": { "type": "Emergency" }, "severity": "Info", + "content": { "en-US": { "title": "t", "description": "d" } } } + ] + } + """, NotificationsList.class); + + NotificationData n = list.notifications().get(0); + assertNull(n.condition()); + assertNull(n.actions()); + assertEquals(NotificationScheduleType.EMERGENCY, n.schedule().type()); + } + + @Test + void rejectsMalformedExpression() { + // An operator object with two keys is not a valid single-operator expression. + String badJson = """ + { + "schema": { "version": "2.0" }, + "notifications": [ + { "id": "n1", "schedule": { "type": "Emergency" }, "severity": "Info", + "condition": { "os": { "type": { "==": "a", "!=": "b" } } }, + "content": { "en-US": { "title": "t", "description": "d" } } } + ] + } + """; + assertThrows(Exception.class, () -> mapper.readValue(badJson, NotificationsList.class)); + } +} diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcherTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcherTest.java new file mode 100644 index 000000000..7c3aadff6 --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcherTest.java @@ -0,0 +1,126 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; + +import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore; +import software.aws.toolkits.eclipse.amazonq.extensions.implementation.ActivatorStaticMockExtension; + +/** Covers the ETag fetch + graceful-degradation matrix with an injected mock HttpClient. */ +public final class NotificationsFetcherTest { + + private static final String URL = "https://example.com/Notifications/Eclipse/combined/2.x.json"; + private static final String VALID_JSON = + "{ \"schema\": { \"version\": \"2.0\" }, \"notifications\": [] }"; + + @RegisterExtension + private static ActivatorStaticMockExtension activatorExtension = new ActivatorStaticMockExtension(); + + private HttpClient httpClient; + private PluginStore pluginStore; + + @TempDir + private Path cacheDir; + + @BeforeEach + void setUp() { + httpClient = mock(HttpClient.class); + pluginStore = activatorExtension.getMock(PluginStore.class); + } + + private NotificationsFetcher fetcher() { + return new NotificationsFetcher(URL, httpClient, cacheDir.resolve("notifications.json")); + } + + @SuppressWarnings("unchecked") + private HttpResponse response(final int status, final String body) { + final HttpResponse resp = mock(HttpResponse.class); + when(resp.statusCode()).thenReturn(status); + when(resp.body()).thenReturn(body); + when(resp.headers()).thenReturn(java.net.http.HttpHeaders.of(java.util.Map.of(), (a, b) -> true)); + return resp; + } + + @Test + @SuppressWarnings("unchecked") + void ok200ParsesAndCaches() throws Exception { + doReturn(response(200, VALID_JSON)).when(httpClient).send(any(HttpRequest.class), any()); + final Optional result = fetcher().fetch(); + assertTrue(result.isPresent()); + assertTrue(Files.exists(cacheDir.resolve("notifications.json"))); + } + + @Test + @SuppressWarnings("unchecked") + void notFound404IsSilentNoOp() throws Exception { + doReturn(response(404, "")).when(httpClient).send(any(HttpRequest.class), any()); + assertTrue(fetcher().fetch().isEmpty()); + verify(activatorExtension.getMock(software.aws.toolkits.eclipse.amazonq.util.LoggingService.class), never()) + .error(any(String.class), any(Throwable.class)); + } + + @Test + @SuppressWarnings("unchecked") + void malformed200FallsBackToEmptyWhenNoCache() throws Exception { + doReturn(response(200, "{ not json")).when(httpClient).send(any(HttpRequest.class), any()); + assertTrue(fetcher().fetch().isEmpty()); + // A bad body must not be cached. + assertFalse(Files.exists(cacheDir.resolve("notifications.json"))); + } + + @Test + @SuppressWarnings("unchecked") + void notModified304WithMissingCacheClearsEtag() throws Exception { + when(pluginStore.get(URL)).thenReturn("\"etag-1\""); + doReturn(response(304, "")).when(httpClient).send(any(HttpRequest.class), any()); + assertTrue(fetcher().fetch().isEmpty()); + verify(pluginStore).remove(URL); + } + + @Test + @SuppressWarnings("unchecked") + void timeoutReturnsEmptyWithoutThrowing() throws Exception { + doThrow(new java.io.IOException("timeout")).when(httpClient).send(any(HttpRequest.class), any()); + assertDoesNotThrow(() -> assertTrue(fetcher().fetch().isEmpty())); + } + + @Test + void fileSchemeReadsLocalFileWithoutHttp() throws Exception { + final Path local = cacheDir.resolve("local.json"); + Files.writeString(local, VALID_JSON); + final NotificationsFetcher fileFetcher = + new NotificationsFetcher(local.toUri().toString(), httpClient, cacheDir.resolve("notifications.json")); + assertTrue(fileFetcher.fetch().isPresent()); + verify(httpClient, never()).send(any(), any()); + } + + @Test + void blankEndpointReadsCacheOnly() throws Exception { + final NotificationsFetcher blank = new NotificationsFetcher("", httpClient, cacheDir.resolve("notifications.json")); + assertEquals(Optional.empty(), blank.fetch()); + } +} diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotificationsTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotificationsTest.java new file mode 100644 index 000000000..659c649a9 --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotificationsTest.java @@ -0,0 +1,118 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import software.aws.toolkits.eclipse.amazonq.extensions.implementation.ActivatorStaticMockExtension; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.LocalizedContent; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationContent; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationSchedule; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationScheduleType; + +/** + * Covers the filtering/dedup/dismissal decisions that are impractical to verify manually (they depend on + * multiple 10-minute polls). Uses an injected display callback to capture which notifications would be shown. + */ +public final class ProcessNotificationsTest { + + @RegisterExtension + private static ActivatorStaticMockExtension activatorExtension = new ActivatorStaticMockExtension(); + + private NotificationDismissalStore dismissalStore; + private List shown; + private ProcessNotifications processor; + + @BeforeEach + void setUp() { + dismissalStore = mock(NotificationDismissalStore.class); + shown = new ArrayList<>(); + processor = new ProcessNotifications(dismissalStore, (id, n, c, actions) -> shown.add(id)); + } + + private static NotificationData notif(final String id, final NotificationScheduleType type) { + return new NotificationData(id, new NotificationSchedule(type), "Info", null, + new NotificationContent(new LocalizedContent("Title", "Description")), null); + } + + private static NotificationsList list(final NotificationData... notifications) { + return new NotificationsList(new NotificationsList.Schema("2.0"), List.of(notifications)); + } + + @Test + void startupShownOnlyOnFirstPoll() { + final NotificationsList payload = list(notif("startup1", NotificationScheduleType.STARTUP)); + processor.process(payload); + assertEquals(List.of("startup1"), shown); + + // Second poll: startup notification must NOT be shown again. + shown.clear(); + processor.process(payload); + assertTrue(shown.isEmpty()); + } + + @Test + void emergencyNotReshownInSameSession() { + final NotificationsList payload = list(notif("emerg1", NotificationScheduleType.EMERGENCY)); + processor.process(payload); + assertEquals(List.of("emerg1"), shown); + + // Same undismissed emergency on the next poll is suppressed by the in-session guard. + shown.clear(); + processor.process(payload); + assertTrue(shown.isEmpty()); + } + + @Test + void newEmergencyStillShownAfterAPriorOne() { + processor.process(list(notif("emerg1", NotificationScheduleType.EMERGENCY))); + shown.clear(); + processor.process(list(notif("emerg2", NotificationScheduleType.EMERGENCY))); + assertEquals(List.of("emerg2"), shown); + } + + @Test + void dismissedNotificationNeverShown() { + when(dismissalStore.isDismissed("emerg1")).thenReturn(true); + processor.process(list(notif("emerg1", NotificationScheduleType.EMERGENCY))); + assertTrue(shown.isEmpty()); + } + + @Test + void ruleFilteredNotificationNotShown() { + final NotificationData gated = new NotificationData("gated", + new NotificationSchedule(NotificationScheduleType.EMERGENCY), "Info", + new NotificationData.NotificationDisplayCondition(null, + new NotificationData.SystemType(new NotificationExpression.ComparisonCondition("NoSuchOS"), null), + null, null, null), + new NotificationContent(new LocalizedContent("t", "d")), null); + processor.process(list(gated)); + assertTrue(shown.isEmpty()); + } + + @Test + void blankContentSkipped() { + final NotificationData blank = new NotificationData("blank", + new NotificationSchedule(NotificationScheduleType.EMERGENCY), "Info", null, + new NotificationContent(new LocalizedContent("", "")), null); + processor.process(list(blank)); + assertTrue(shown.isEmpty()); + } + + @Test + void emptyListIsNoOp() { + processor.process(new NotificationsList(new NotificationsList.Schema("2.0"), List.of())); + assertTrue(shown.isEmpty()); + } +} diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngineTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngineTest.java new file mode 100644 index 000000000..31272c76c --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngineTest.java @@ -0,0 +1,153 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.notifications; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.AuthxType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.ComputeType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.ExtensionType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationDisplayCondition; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationSchedule; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationScheduleType; +import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.SystemType; + +/** Pure, deterministic coverage for the rules engine (top JaCoCo contributor; no mocks needed). */ +public final class RulesEngineTest { + + private static final String PLUGIN_ID = "amazon-q-eclipse"; + + private static SystemDetails sys(final String pluginVersion, final FeatureAuthDetails qAuth) { + return new SystemDetails("Local", "aarch64", "Mac OS X", "14.5.0", "Eclipse", "4.30.0", + Map.of(PLUGIN_ID, pluginVersion), qAuth); + } + + private static SystemDetails defaultSys() { + return sys("1.70.0", new FeatureAuthDetails("BuilderId", "us-east-1", "Connected")); + } + + private static NotificationData notification(final NotificationDisplayCondition condition) { + return new NotificationData("id", new NotificationSchedule(NotificationScheduleType.EMERGENCY), "Info", + condition, null, null); + } + + private static NotificationExpression eq(final String v) { + return new NotificationExpression.ComparisonCondition(v); + } + + @Test + void nullConditionShowsToEveryone() { + assertTrue(RulesEngine.displayNotification(notification(null), defaultSys())); + } + + @Test + void equalsAndNotEquals() { + assertTrue(RulesEngine.evaluateNotificationExpression(eq("a"), "a")); + assertFalse(RulesEngine.evaluateNotificationExpression(eq("a"), "b")); + assertTrue(RulesEngine.evaluateNotificationExpression(new NotificationExpression.NotEqualsCondition("a"), "b")); + assertFalse(RulesEngine.evaluateNotificationExpression(new NotificationExpression.NotEqualsCondition("a"), "a")); + } + + @Test + void semverOrderingForVersions() { + // 3.101.0 is NEWER than 3.74.0 numerically (lexical would say "1" < "7"). + assertTrue(RulesEngine.evaluateNotificationExpression( + new NotificationExpression.GreaterThanCondition("3.74.0"), "3.101.0", true)); + assertFalse(RulesEngine.evaluateNotificationExpression( + new NotificationExpression.LessThanCondition("3.74.0"), "3.101.0", true)); + assertTrue(RulesEngine.evaluateNotificationExpression( + new NotificationExpression.GreaterThanOrEqualsCondition("1.0"), "1.0", true)); + assertTrue(RulesEngine.evaluateNotificationExpression( + new NotificationExpression.LessThanOrEqualsCondition("2.0"), "2.0", true)); + } + + @Test + void malformedSemverFallsBackToLexical() { + // "abc" is not clean semver -> lexical compare: "abc" > "1.0". + assertTrue(RulesEngine.evaluateNotificationExpression( + new NotificationExpression.GreaterThanCondition("1.0"), "abc", true)); + } + + @Test + void anyOfNoneOfNotOrAnd() { + assertTrue(RulesEngine.evaluateNotificationExpression( + new NotificationExpression.AnyOfCondition(List.of("Darwin", "Linux")), "Darwin")); + assertFalse(RulesEngine.evaluateNotificationExpression( + new NotificationExpression.NoneOfCondition(List.of("Darwin")), "Darwin")); + assertTrue(RulesEngine.evaluateNotificationExpression( + new NotificationExpression.NotCondition(eq("x")), "y")); + assertTrue(RulesEngine.evaluateNotificationExpression( + new NotificationExpression.OrCondition(List.of(eq("a"), eq("b"))), "b")); + assertFalse(RulesEngine.evaluateNotificationExpression( + new NotificationExpression.AndCondition(List.of(eq("a"), eq("b"))), "a")); + } + + @Test + void computeOsIdeConditionsAreAnded() { + final NotificationDisplayCondition cond = new NotificationDisplayCondition( + new ComputeType(eq("Local"), null), + new SystemType(new NotificationExpression.AnyOfCondition(List.of("Mac OS X")), null), + new SystemType(eq("Eclipse"), new NotificationExpression.GreaterThanOrEqualsCondition("4.0.0")), + null, null); + assertTrue(RulesEngine.displayNotification(notification(cond), defaultSys())); + + final NotificationDisplayCondition mismatch = new NotificationDisplayCondition( + new ComputeType(eq("Remote"), null), null, null, null, null); + assertFalse(RulesEngine.displayNotification(notification(mismatch), defaultSys())); + } + + @Test + void extensionNoneInstalledDoesNotShow() { + final NotificationDisplayCondition cond = new NotificationDisplayCondition(null, null, null, + List.of(new ExtensionType("not.installed.ext", null)), null); + assertFalse(RulesEngine.displayNotification(notification(cond), defaultSys())); + } + + @Test + void extensionInstalledWithMatchingVersionShows() { + final NotificationDisplayCondition cond = new NotificationDisplayCondition(null, null, null, + List.of(new ExtensionType(PLUGIN_ID, new NotificationExpression.LessThanCondition("2.0.0"))), null); + assertTrue(RulesEngine.displayNotification(notification(cond), defaultSys())); + } + + @Test + void snapshotPluginVersionNeverShows() { + final NotificationDisplayCondition cond = new NotificationDisplayCondition(null, null, null, + List.of(new ExtensionType(PLUGIN_ID, null)), null); + assertFalse(RulesEngine.displayNotification(notification(cond), sys("1.70.0-SNAPSHOT", + new FeatureAuthDetails("BuilderId", "us-east-1", "Connected")))); + } + + @Test + void authxMatchingForQFeature() { + final NotificationDisplayCondition cond = new NotificationDisplayCondition(null, null, null, null, + List.of(new AuthxType("q", null, null, eq("Connected"), null))); + assertTrue(RulesEngine.displayNotification(notification(cond), defaultSys())); + + final NotificationDisplayCondition wantExpired = new NotificationDisplayCondition(null, null, null, null, + List.of(new AuthxType("q", null, null, eq("Expired"), null))); + assertFalse(RulesEngine.displayNotification(notification(wantExpired), defaultSys())); + } + + @Test + void authxNonQFeaturePasses() { + final NotificationDisplayCondition cond = new NotificationDisplayCondition(null, null, null, null, + List.of(new AuthxType("codeCatalyst", eq("whatever"), null, null, null))); + assertTrue(RulesEngine.displayNotification(notification(cond), defaultSys())); + } + + @Test + void loggedOutIsEvaluableAsNotConnected() { + final SystemDetails loggedOut = sys("1.70.0", new FeatureAuthDetails("Unknown", "Unknown", "NotConnected")); + final NotificationDisplayCondition cond = new NotificationDisplayCondition(null, null, null, null, + List.of(new AuthxType("q", null, null, eq("NotConnected"), null))); + assertTrue(RulesEngine.displayNotification(notification(cond), loggedOut)); + } +} From 26c67f7cf9178771886ddbce5f6d84edbd4bfe9f Mon Sep 17 00:00:00 2001 From: Aarush Arora Date: Wed, 15 Jul 2026 12:27:22 -0700 Subject: [PATCH 2/3] fix(notifications): point changelog action at the releases page The OpenChangelog notification action opened https://github.com/aws/amazon-q-eclipse/blob/main/CHANGELOG.md, which 404s (there is no CHANGELOG.md in the repo). Point AMAZON_Q_CHANGELOG_URL at the GitHub releases page (the de-facto changelog for the repo) so 'View changelog' resolves. --- .../software/aws/toolkits/eclipse/amazonq/util/Constants.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java index 8b3b24a25..82294da69 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java @@ -59,6 +59,6 @@ private Constants() { public static final String INLINE_CHAT_EXPIRED_AUTH_BODY = "Login status expired; please open Q plugin window to reauthenticate."; public static final String INLINE_CHAT_CONTEXT_ID = "org.eclipse.ui.inlineChatContext"; public static final String INLINE_SUGGESTIONS_CONTEXT_ID = "org.eclipse.ui.suggestionsContext"; - public static final String AMAZON_Q_CHANGELOG_URL = "https://github.com/aws/amazon-q-eclipse/blob/main/CHANGELOG.md"; + public static final String AMAZON_Q_CHANGELOG_URL = "https://github.com/aws/amazon-q-eclipse/releases"; public static final String AMAZON_Q_UPDATE_SITE_URL = "https://marketplace.eclipse.org/content/amazon-q"; } From 6158f7464b92c09bed995d96a59356e4564d62f0 Mon Sep 17 00:00:00 2001 From: Aarush Arora Date: Wed, 15 Jul 2026 12:44:21 -0700 Subject: [PATCH 3/3] docs(notifications): reword persistence comment to drop internal term Replace an internal-only reference in the CRITICAL-persistence javadoc with a provider-neutral phrasing; no behavior change. --- .../eclipse/amazonq/notifications/AmazonQNotificationPopup.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java index 83cf88e76..c54dfbbac 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/AmazonQNotificationPopup.java @@ -26,7 +26,7 @@ /** * A toast notification that renders a severity icon, wrapped description, and N action buttons built from a hosted * notification's actions. INFO/WARNING keep the base auto-close timer; CRITICAL overrides {@link #scheduleAutoClose()} - * to a no-op so it persists until the user dismisses it (the COE "reach the user" requirement). + * to a no-op so it persists until the user dismisses it, ensuring critical alerts reach the user. */ public final class AmazonQNotificationPopup extends ToolkitNotification {