diff --git a/docs/docs/env-vars.md b/docs/docs/env-vars.md index 1b49ea55..58561f6c 100644 --- a/docs/docs/env-vars.md +++ b/docs/docs/env-vars.md @@ -18,6 +18,8 @@ CodeRoad has a number of configurations: - `CODEROAD_CONTENT_SECURITY_POLICY_EXEMPTIONS` - a list of CSP exemption hashes. For multiples, separate the list with a space. +- `CODEROAD_WEBHOOK_TOKEN` - an optional token for authenticating/authorizing webhook endpoints. Passed to the webhook endpoint in a `CodeRoad-User-Token` header. + ## How to Use Variables ### Local diff --git a/src/actions/onRunReset.ts b/src/actions/onRunReset.ts index 890b084e..4ae140a2 100644 --- a/src/actions/onRunReset.ts +++ b/src/actions/onRunReset.ts @@ -32,7 +32,10 @@ const onRunReset = async (action: ResetAction, context: Context): Promise // if tutorial.config.reset.command, run it const resetActions = tutorial?.config?.reset if (resetActions) { - hooks.onReset({ commands: resetActions?.commands, vscodeCommands: resetActions?.vscodeCommands }) + hooks.onReset( + { commands: resetActions?.commands, vscodeCommands: resetActions?.vscodeCommands }, + tutorial?.id as string, + ) } } diff --git a/src/actions/onTutorialConfigContinue.ts b/src/actions/onTutorialConfigContinue.ts index 397e96ad..f5e173b0 100644 --- a/src/actions/onTutorialConfigContinue.ts +++ b/src/actions/onTutorialConfigContinue.ts @@ -5,6 +5,7 @@ import Context from '../services/context/context' import tutorialConfig from './utils/tutorialConfig' import { COMMANDS, send } from '../commands' import logger from '../services/logger' +import { setupWebhook } from '../services/hooks/webhooks' const onTutorialConfigContinue = async (action: T.Action, context: Context): Promise => { logger('onTutorialConfigContinue', action) @@ -19,6 +20,11 @@ const onTutorialConfigContinue = async (action: T.Action, context: Context): Pro data: tutorialToContinue, alreadyConfigured: true, }) + + // configure webhook + if (tutorialToContinue.config?.webhook) { + setupWebhook(tutorialToContinue.config.webhook) + } } catch (e) { const error = { type: 'UnknownError', diff --git a/src/actions/onTutorialConfigNew.ts b/src/actions/onTutorialConfigNew.ts index 5c2774ac..171bee1a 100644 --- a/src/actions/onTutorialConfigNew.ts +++ b/src/actions/onTutorialConfigNew.ts @@ -8,6 +8,7 @@ import { version, compareVersions } from '../services/dependencies' import Context from '../services/context/context' import tutorialConfig from './utils/tutorialConfig' import { send } from '../commands' +import { setupWebhook } from '../services/hooks/webhooks' const onTutorialConfigNew = async (action: T.Action, context: Context): Promise => { try { @@ -108,6 +109,11 @@ const onTutorialConfigNew = async (action: T.Action, context: Context): Promise< return } + // configure webhook + if (data.config?.webhook) { + setupWebhook(data.config.webhook) + } + // report back to the webview that setup is complete send({ type: 'TUTORIAL_CONFIGURED' }) } catch (e) { diff --git a/src/commands.ts b/src/commands.ts index c7ba69a5..5eaaeab2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -65,7 +65,7 @@ export const createCommands = ({ extensionPath, workspaceState }: CreateCommandP if (!alreadyConfigured) { const setupActions = data.config.setup if (setupActions) { - hooks.onInit(setupActions) + hooks.onInit(setupActions, data.id) } } testRunner = createTestRunner(data, { diff --git a/src/environment.ts b/src/environment.ts index 8b7f5f3f..59077d61 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -43,3 +43,6 @@ export const DISABLE_RUN_ON_SAVE = (process.env.CODEROAD_DISABLE_RUN_ON_SAVE || // for multiple exemptions, separate each with a space "a1 b1" export const CONTENT_SECURITY_POLICY_EXEMPTIONS: string | null = process.env.CODEROAD_CONTENT_SECURITY_POLICY_EXEMPTIONS || null + +// optional token for authorization/authentication of webhook calls +export const WEBHOOK_TOKEN = process.env.CODEROAD_WEBHOOK_TOKEN || null diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts index ddcc7757..309d3ae3 100644 --- a/src/services/hooks/index.ts +++ b/src/services/hooks/index.ts @@ -7,14 +7,18 @@ import runCommands from './utils/runCommands' import runVSCodeCommands from './utils/runVSCodeCommands' import * as telemetry from '../telemetry' import { runTest } from '../../actions/onTest' -import logger from '../logger' import { VERSION } from '../../environment' +import * as webhooks from './webhooks' // run at the end of when a tutorial is configured -export const onInit = async (actions: TT.StepActions): Promise => { +export const onInit = async (actions: TT.StepActions, tutorialId: string): Promise => { await loadCommits(actions?.commits) await runCommands(actions?.commands) await runVSCodeCommands(actions?.vscodeCommands) + webhooks.onInit({ + tutorialId, + coderoadVersion: VERSION, + }) } // run when a level starts @@ -43,10 +47,13 @@ export const onSolutionEnter = async (actions: TT.StepActions): Promise => } // run when "reset" is triggered -export const onReset = async (actions: TT.StepActions): Promise => { +export const onReset = async (actions: TT.StepActions, tutorialId: string): Promise => { await resetWatchers() await runCommands(actions?.commands) await runVSCodeCommands(actions?.vscodeCommands) + webhooks.onReset({ + tutorialId, + }) } // run when an uncaught exception is thrown @@ -66,6 +73,11 @@ export const onStepComplete = async ({ }): Promise => { git.saveCommit(`Save progress: ${stepId}`) telemetry.onEvent('step_complete', { tutorialId, stepId, levelId, version: VERSION }) + webhooks.onStepComplete({ + tutorialId, + levelId, + stepId, + }) } // run when a level is complete (all tasks pass or no tasks) @@ -77,9 +89,16 @@ export const onLevelComplete = async ({ levelId: string }): Promise => { telemetry.onEvent('level_complete', { tutorialId, levelId, version: VERSION }) + webhooks.onLevelComplete({ + tutorialId, + levelId, + }) } // run when all levels are complete export const onTutorialComplete = async ({ tutorialId }: { tutorialId: string }): Promise => { telemetry.onEvent('tutorial_complete', { tutorialId, version: VERSION }) + webhooks.onTutorialComplete({ + tutorialId, + }) } diff --git a/src/services/hooks/webhooks.ts b/src/services/hooks/webhooks.ts new file mode 100644 index 00000000..a245d4cb --- /dev/null +++ b/src/services/hooks/webhooks.ts @@ -0,0 +1,101 @@ +import * as TT from 'typings/tutorial' +import fetch from 'node-fetch' +import logger from '../logger' +import { WEBHOOK_TOKEN } from '../../environment' + +const WEBHOOK_EVENTS = { + init: false, + reset: false, + step_complete: false, + level_complete: false, + tutorial_complete: false, +} + +// varaibles set on init +let WEBHOOK_URI: string | undefined + +export const setupWebhook = (webhookConfig: TT.WebhookConfig) => { + if (!webhookConfig.url) { + return + } + // set webhook uri + WEBHOOK_URI = webhookConfig.url + + // set webhook event triggers + const events = webhookConfig.events as TT.WebhookConfigEvents + for (const eventName of Object.keys(events || {})) { + WEBHOOK_EVENTS[eventName] = events[eventName] + } +} + +const callWebhookEndpoint = async (bodyObject: B): Promise => { + if (!WEBHOOK_URI) { + return + } + + const headers = { 'Content-Type': 'application/json' } + // if the webhook token is specified as env var, sends a token with the request + if (WEBHOOK_TOKEN) { + headers['CodeRoad-User-Token'] = WEBHOOK_TOKEN + } + + const body = JSON.stringify(bodyObject) + + try { + const sendEvent = await fetch(WEBHOOK_URI, { + method: 'POST', + headers, + body, + }) + if (!sendEvent.ok) { + throw new Error('Error sending event') + } + } catch (err: unknown) { + logger(`Failed to call webhook endpoint ${WEBHOOK_URI} with body ${body}`) + } +} + +type WebhookEventInit = { + tutorialId: string + coderoadVersion: string +} + +export const onInit = (event: WebhookEventInit): void => { + if (WEBHOOK_EVENTS.init) { + callWebhookEndpoint(event) + } +} + +type WebhookEventReset = { + tutorialId: string +} + +export const onReset = (event: WebhookEventReset): void => { + if (WEBHOOK_EVENTS.reset) { + callWebhookEndpoint(event) + } +} + +type WebhookEventStepComplete = { tutorialId: string; version: string; levelId: string; stepId: string } + +export const onStepComplete = (event: WebhookEventStepComplete): void => { + if (WEBHOOK_EVENTS.step_complete) { + callWebhookEndpoint(event) + } +} + +type WebhookEventLevelComplete = { tutorialId: string; version: string; levelId: string } + +export const onLevelComplete = (event: WebhookEventLevelComplete): void => { + if (WEBHOOK_EVENTS.level_complete) { + callWebhookEndpoint(event) + } +} + +type WebhookEevntTutorialComplete = { tutorialId: string; version: string } + +export const onTutorialComplete = (event: WebhookEevntTutorialComplete): void => { + if (WEBHOOK_EVENTS.tutorial_complete) { + callWebhookEndpoint(event) + } +} diff --git a/typings/tutorial.d.ts b/typings/tutorial.d.ts index 3f45e558..3a32599a 100644 --- a/typings/tutorial.d.ts +++ b/typings/tutorial.d.ts @@ -14,6 +14,7 @@ export type TutorialConfig = { dependencies?: TutorialDependency[] setup?: StepActions reset?: ConfigReset + webhook?: WebhookConfig } /** Logical groupings of tasks */ @@ -92,3 +93,15 @@ export interface TutorialAppVersions { } export type VSCodeCommand = string | [string, any] + +export interface WebhookConfigEvents { + init?: boolean + reset?: boolean + step_complete?: boolean + level_complete?: boolean + tutorial_complete?: boolean +} +export interface WebhookConfig { + url: string + events?: WebhookConfigEvents +}