Skip to content

Commit b958880

Browse files
authored
feat(notifications): initial rule engine (#5783)
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.
1 parent 449de46 commit b958880

File tree

4 files changed

+697
-2
lines changed

4 files changed

+697
-2
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as semver from 'semver'
7+
import globals from '../shared/extensionGlobals'
8+
import { ConditionalClause, RuleContext, DisplayIf, CriteriaCondition, ToolkitNotification } from './types'
9+
10+
/**
11+
* Evaluates if a given version fits into the parameters specified by a notification, e.g:
12+
*
13+
* extensionVersion: {
14+
* type: 'range',
15+
* lowerInclusive: '1.21.0'
16+
* }
17+
*
18+
* will match all versions 1.21.0 and up.
19+
*
20+
* @param version the version to check
21+
* @param condition the condition to check against
22+
* @returns true if the version satisfies the condition
23+
*/
24+
function isValidVersion(version: string, condition: ConditionalClause): boolean {
25+
switch (condition.type) {
26+
case 'range': {
27+
const lowerConstraint = !condition.lowerInclusive || semver.gte(version, condition.lowerInclusive)
28+
const upperConstraint = !condition.upperExclusive || semver.lt(version, condition.upperExclusive)
29+
return lowerConstraint && upperConstraint
30+
}
31+
case 'exactMatch':
32+
return condition.values.some((v) => semver.eq(v, version))
33+
case 'or':
34+
/** Check case where any of the subconditions are true, i.e. one of multiple range or exactMatch conditions */
35+
return condition.clauses.some((clause) => isValidVersion(version, clause))
36+
default:
37+
throw new Error(`Unknown clause type: ${(condition as any).type}`)
38+
}
39+
}
40+
41+
/**
42+
* Determine whether or not to display a given notification based on whether the
43+
* notification requirements fit the extension context provided on initialization.
44+
*
45+
* Usage:
46+
* const myContext = {
47+
* extensionVersion: '4.5.6',
48+
* ...
49+
* }
50+
*
51+
* const ruleEngine = new RuleEngine(myContext)
52+
*
53+
* notifications.forEach(n => {
54+
* if (ruleEngine.shouldDisplayNotification(n)) {
55+
* // process notification
56+
* ...
57+
* }
58+
* })
59+
*
60+
*/
61+
export class RuleEngine {
62+
constructor(private readonly context: RuleContext) {}
63+
64+
public shouldDisplayNotification(payload: ToolkitNotification) {
65+
return this.evaluate(payload.displayIf)
66+
}
67+
68+
private evaluate(condition: DisplayIf): boolean {
69+
if (condition.extensionId !== globals.context.extension.id) {
70+
return false
71+
}
72+
73+
if (condition.ideVersion) {
74+
if (!isValidVersion(this.context.ideVersion, condition.ideVersion)) {
75+
return false
76+
}
77+
}
78+
if (condition.extensionVersion) {
79+
if (!isValidVersion(this.context.extensionVersion, condition.extensionVersion)) {
80+
return false
81+
}
82+
}
83+
84+
if (condition.additionalCriteria) {
85+
for (const criteria of condition.additionalCriteria) {
86+
if (!this.evaluateRule(criteria)) {
87+
return false
88+
}
89+
}
90+
}
91+
92+
return true
93+
}
94+
95+
private evaluateRule(criteria: CriteriaCondition) {
96+
const expected = criteria.values
97+
const expectedSet = new Set(expected)
98+
99+
const isExpected = (i: string) => expectedSet.has(i)
100+
const hasAnyOfExpected = (i: string[]) => i.some((v) => expectedSet.has(v))
101+
const isSuperSetOfExpected = (i: string[]) => {
102+
const s = new Set(i)
103+
return expected.every((v) => s.has(v))
104+
}
105+
const isEqualSetToExpected = (i: string[]) => {
106+
const s = new Set(i)
107+
return expected.every((v) => s.has(v)) && i.every((v) => expectedSet.has(v))
108+
}
109+
110+
// Maybe we could abstract these out into some strategy pattern with classes.
111+
// But this list is short and its unclear if we need to expand it further.
112+
// Also, we might replace this with a common implementation amongst the toolkits.
113+
// So... YAGNI
114+
switch (criteria.type) {
115+
case 'OS':
116+
return isExpected(this.context.os)
117+
case 'ComputeEnv':
118+
return isExpected(this.context.computeEnv)
119+
case 'AuthType':
120+
return hasAnyOfExpected(this.context.authTypes)
121+
case 'AuthRegion':
122+
return hasAnyOfExpected(this.context.authRegions)
123+
case 'AuthState':
124+
return hasAnyOfExpected(this.context.authStates)
125+
case 'AuthScopes':
126+
return isEqualSetToExpected(this.context.authScopes)
127+
case 'InstalledExtensions':
128+
return isSuperSetOfExpected(this.context.installedExtensions)
129+
case 'ActiveExtensions':
130+
return isSuperSetOfExpected(this.context.activeExtensions)
131+
default:
132+
throw new Error(`Unknown criteria type: ${criteria.type}`)
133+
}
134+
}
135+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as vscode from 'vscode'
7+
import { EnvType, OperatingSystem } from '../shared/telemetry/util'
8+
9+
/** Types of information that we can use to determine whether to show a notification or not. */
10+
export type Criteria =
11+
| 'OS'
12+
| 'ComputeEnv'
13+
| 'AuthType'
14+
| 'AuthRegion'
15+
| 'AuthState'
16+
| 'AuthScopes'
17+
| 'InstalledExtensions'
18+
| 'ActiveExtensions'
19+
20+
/** Generic condition where the type determines how the values are evaluated. */
21+
export interface CriteriaCondition {
22+
readonly type: Criteria
23+
readonly values: string[]
24+
}
25+
26+
/** One of the subconditions (clauses) must match to be valid. */
27+
export interface OR {
28+
readonly type: 'or'
29+
readonly clauses: (Range | ExactMatch)[]
30+
}
31+
32+
/** Version must be within the bounds to be valid. Missing bound indicates that bound is open-ended. */
33+
export interface Range {
34+
readonly type: 'range'
35+
readonly lowerInclusive?: string // null means "-inf"
36+
readonly upperExclusive?: string // null means "+inf"
37+
}
38+
39+
/** Version must be equal. */
40+
export interface ExactMatch {
41+
readonly type: 'exactMatch'
42+
readonly values: string[]
43+
}
44+
45+
export type ConditionalClause = Range | ExactMatch | OR
46+
47+
/** How to display the notification. */
48+
export interface UIRenderInstructions {
49+
content: {
50+
[`en-US`]: {
51+
title: string
52+
description: string
53+
}
54+
}
55+
// TODO actions
56+
}
57+
58+
/** Condition/criteria section of a notification. */
59+
export interface DisplayIf {
60+
extensionId: string
61+
ideVersion?: ConditionalClause
62+
extensionVersion?: ConditionalClause
63+
additionalCriteria?: CriteriaCondition[]
64+
}
65+
66+
export interface ToolkitNotification {
67+
id: string
68+
displayIf: DisplayIf
69+
uiRenderInstructions: UIRenderInstructions
70+
}
71+
72+
export interface Notifications {
73+
schemaVersion: string
74+
notifications: ToolkitNotification[]
75+
}
76+
77+
export interface RuleContext {
78+
readonly ideVersion: typeof vscode.version
79+
readonly extensionVersion: string
80+
readonly os: OperatingSystem
81+
readonly computeEnv: EnvType
82+
readonly authTypes: string[]
83+
readonly authRegions: string[]
84+
readonly authStates: string[]
85+
readonly authScopes: string[]
86+
readonly installedExtensions: string[]
87+
readonly activeExtensions: string[]
88+
}

packages/core/src/shared/telemetry/util.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ export function getUserAgent(
226226
* NOTES:
227227
* - append `-amzn` for any environment internal to Amazon
228228
*/
229-
type EnvType =
229+
export type EnvType =
230230
| 'cloud9'
231231
| 'cloud9-codecatalyst'
232232
| 'cloudDesktop-amzn'
@@ -322,12 +322,13 @@ export function getOptOutPreference() {
322322
return globals.telemetry.telemetryEnabled ? 'OPTIN' : 'OPTOUT'
323323
}
324324

325+
export type OperatingSystem = 'MAC' | 'WINDOWS' | 'LINUX'
325326
/**
326327
* Useful for populating the sendTelemetryEvent request from codewhisperer's api for publishing custom telemetry events for AB Testing.
327328
*
328329
* Returns one of the enum values of the OperatingSystem model (see SendTelemetryRequest model in the codebase)
329330
*/
330-
export function getOperatingSystem(): 'MAC' | 'WINDOWS' | 'LINUX' {
331+
export function getOperatingSystem(): OperatingSystem {
331332
const osId = os.platform() // 'darwin', 'win32', 'linux', etc.
332333
if (osId === 'darwin') {
333334
return 'MAC'

0 commit comments

Comments
 (0)