Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/core/src/notifications/rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import * as semver from 'semver'
import globals from '../shared/extensionGlobals'
import { ConditionalClause, RuleContext, DisplayIf, CriteriaCondition, ToolkitNotification } from './types'

/**
* Evaluates if a given version fits into the parameters specified by a notification, e.g:
*
* extensionVersion: {
* type: 'range',
* lowerInclusive: '1.21.0'
* }
*
* will match all versions 1.21.0 and up.
*
* @param version the version to check
* @param condition the condition to check against
* @returns true if the version satisfies the condition
*/
function isValidVersion(version: string, condition: ConditionalClause): boolean {
switch (condition.type) {
case 'range': {
const lowerConstraint = !condition.lowerInclusive || semver.gte(version, condition.lowerInclusive)
const upperConstraint = !condition.upperExclusive || semver.lt(version, condition.upperExclusive)
return lowerConstraint && upperConstraint
}
case 'exactMatch':
return condition.values.some((v) => semver.eq(v, version))
case 'or':
/** Check case where any of the subconditions are true, i.e. one of multiple range or exactMatch conditions */
return condition.clauses.some((clause) => isValidVersion(version, clause))
default:
throw new Error(`Unknown clause type: ${(condition as any).type}`)
}
}

/**
* Determine whether or not to display a given notification based on whether the
* notification requirements fit the extension context provided on initialization.
*
* Usage:
* const myContext = {
* extensionVersion: '4.5.6',
* ...
* }
*
* const ruleEngine = new RuleEngine(myContext)
*
* notifications.forEach(n => {
* if (ruleEngine.shouldDisplayNotification(n)) {
* // process notification
* ...
* }
* })
*
*/
export class RuleEngine {
constructor(private readonly context: RuleContext) {}

public shouldDisplayNotification(payload: ToolkitNotification) {
return this.evaluate(payload.displayIf)
}

private evaluate(condition: DisplayIf): boolean {
if (condition.extensionId !== globals.context.extension.id) {
return false
}

if (condition.ideVersion) {
if (!isValidVersion(this.context.ideVersion, condition.ideVersion)) {
return false
}
}
if (condition.extensionVersion) {
if (!isValidVersion(this.context.extensionVersion, condition.extensionVersion)) {
return false
}
}

if (condition.additionalCriteria) {
for (const criteria of condition.additionalCriteria) {
if (!this.evaluateRule(criteria)) {
return false
}
}
}

return true
}

private evaluateRule(criteria: CriteriaCondition) {
const expected = criteria.values
const expectedSet = new Set(expected)

const isExpected = (i: string) => expectedSet.has(i)
const hasAnyOfExpected = (i: string[]) => i.some((v) => expectedSet.has(v))
const isSuperSetOfExpected = (i: string[]) => {
const s = new Set(i)
return expected.every((v) => s.has(v))
}
const isEqualSetToExpected = (i: string[]) => {
const s = new Set(i)
return expected.every((v) => s.has(v)) && i.every((v) => expectedSet.has(v))
}

// Maybe we could abstract these out into some strategy pattern with classes.
// But this list is short and its unclear if we need to expand it further.
// Also, we might replace this with a common implementation amongst the toolkits.
// So... YAGNI
switch (criteria.type) {
case 'OS':
return isExpected(this.context.os)
case 'ComputeEnv':
return isExpected(this.context.computeEnv)
case 'AuthType':
return hasAnyOfExpected(this.context.authTypes)
case 'AuthRegion':
return hasAnyOfExpected(this.context.authRegions)
case 'AuthState':
return hasAnyOfExpected(this.context.authStates)
case 'AuthScopes':
return isEqualSetToExpected(this.context.authScopes)
case 'InstalledExtensions':
return isSuperSetOfExpected(this.context.installedExtensions)
case 'ActiveExtensions':
return isSuperSetOfExpected(this.context.activeExtensions)
default:
throw new Error(`Unknown criteria type: ${criteria.type}`)
}
}
}
88 changes: 88 additions & 0 deletions packages/core/src/notifications/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import * as vscode from 'vscode'
import { EnvType, OperatingSystem } from '../shared/telemetry/util'

/** Types of information that we can use to determine whether to show a notification or not. */
export type Criteria =
| 'OS'
| 'ComputeEnv'
| 'AuthType'
| 'AuthRegion'
| 'AuthState'
| 'AuthScopes'
| 'InstalledExtensions'
| 'ActiveExtensions'

/** Generic condition where the type determines how the values are evaluated. */
export interface CriteriaCondition {
readonly type: Criteria
readonly values: string[]
}

/** One of the subconditions (clauses) must match to be valid. */
export interface OR {
readonly type: 'or'
readonly clauses: (Range | ExactMatch)[]
}

/** Version must be within the bounds to be valid. Missing bound indicates that bound is open-ended. */
export interface Range {
readonly type: 'range'
readonly lowerInclusive?: string // null means "-inf"
readonly upperExclusive?: string // null means "+inf"
}

/** Version must be equal. */
export interface ExactMatch {
readonly type: 'exactMatch'
readonly values: string[]
}

export type ConditionalClause = Range | ExactMatch | OR

/** How to display the notification. */
export interface UIRenderInstructions {
content: {
[`en-US`]: {
title: string
description: string
}
}
// TODO actions
}

/** Condition/criteria section of a notification. */
export interface DisplayIf {
extensionId: string
ideVersion?: ConditionalClause
extensionVersion?: ConditionalClause
additionalCriteria?: CriteriaCondition[]
}

export interface ToolkitNotification {
id: string
displayIf: DisplayIf
uiRenderInstructions: UIRenderInstructions
}

export interface Notifications {
schemaVersion: string
notifications: ToolkitNotification[]
}

export interface RuleContext {
readonly ideVersion: typeof vscode.version
readonly extensionVersion: string
readonly os: OperatingSystem
readonly computeEnv: EnvType
readonly authTypes: string[]
readonly authRegions: string[]
readonly authStates: string[]
readonly authScopes: string[]
readonly installedExtensions: string[]
readonly activeExtensions: string[]
}
5 changes: 3 additions & 2 deletions packages/core/src/shared/telemetry/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export function getUserAgent(
* NOTES:
* - append `-amzn` for any environment internal to Amazon
*/
type EnvType =
export type EnvType =
| 'cloud9'
| 'cloud9-codecatalyst'
| 'cloudDesktop-amzn'
Expand Down Expand Up @@ -322,12 +322,13 @@ export function getOptOutPreference() {
return globals.telemetry.telemetryEnabled ? 'OPTIN' : 'OPTOUT'
}

export type OperatingSystem = 'MAC' | 'WINDOWS' | 'LINUX'
/**
* Useful for populating the sendTelemetryEvent request from codewhisperer's api for publishing custom telemetry events for AB Testing.
*
* Returns one of the enum values of the OperatingSystem model (see SendTelemetryRequest model in the codebase)
*/
export function getOperatingSystem(): 'MAC' | 'WINDOWS' | 'LINUX' {
export function getOperatingSystem(): OperatingSystem {
const osId = os.platform() // 'darwin', 'win32', 'linux', etc.
if (osId === 'darwin') {
return 'MAC'
Expand Down
Loading
Loading