Skip to content

Commit

Permalink
feat(notifications): initial rule engine (#5783)
Browse files Browse the repository at this point in the history
Initial code for a rule engine to determine whether or not to show an
in-IDE notification. A notification is a JSON payload with a set amount
of criteria. The rule engine accepts context from the currently running
extension then determines if the notification payload's criteria will
fit the provided context.

The types match the commonly designed schema, but may change in future
commits.

Future work:
- More docs
- Updates to types and/or criteria
- Code that will use this

---

<!--- REMINDER: Ensure that your PR meets the guidelines in
CONTRIBUTING.md -->

License: I confirm that my contribution is made under the terms of the
Apache 2.0 license.
  • Loading branch information
hayemaxi authored Oct 17, 2024
1 parent 449de46 commit b958880
Show file tree
Hide file tree
Showing 4 changed files with 697 additions and 2 deletions.
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

0 comments on commit b958880

Please sign in to comment.