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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ https://skyhookapi.com/api/webhooks/firstPartOfWebhook/secondPartOfWebhook/provi
- [Unity Cloud](https://build-api.cloud.unity3d.com/docs/1.0.0/index.html#operation-webhooks-intro) - `/unity`
- [Uptime Robot](https://blog.uptimerobot.com/web-hook-alert-contacts-new-feature/) - `/uptimerobot`
- [Zendesk](https://developer.zendesk.com/api-reference/webhooks/webhooks-api/webhooks/) - `/zendesk`
<!-- provider-scaffold: supported-providers -->

## Contributing

Expand All @@ -69,6 +70,12 @@ If you wish to contribute, follow our [contributing guide](CONTRIBUTING.md).
If you want to create a new provider please follow the examples shown at our
small [documentation](docs/CreateNewProvider.md).

To generate a registered provider, canonical fixture, and focused test in one step:

```sh
npm run provider:new -- newprovider NewProvider "New Provider" https://docs.example.com/webhooks
```

## Testing Locally

To build:
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"useIgnoreFile": true
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts"]
"includes": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.mjs"]
},
"formatter": {
"enabled": true,
Expand Down
271 changes: 179 additions & 92 deletions docs/CreateNewProvider.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
"start": "node dist/index.js",
"dev": "node --watch --experimental-strip-types src/index.ts",
"test": "node --experimental-strip-types --test 'test/**/*-spec.ts'",
"lint": "biome check src test",
"lint:fix": "biome check --write src test",
"lint": "biome check src test scripts",
"lint:fix": "biome check --write src test scripts",
"provider:new": "node scripts/create-provider.mjs",
"deploy": "gcloud run deploy skyhook --source . --region us-central1 --allow-unauthenticated --max-instances 1 --port 8080",
"logs": "gcloud run services logs tail skyhook --region us-central1"
},
Expand Down
184 changes: 184 additions & 0 deletions scripts/create-provider.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'

const IMPORT_MARKER = '// provider-scaffold: imports'
const DEFINITION_MARKER = ' // provider-scaffold: definitions'
const README_MARKER = '<!-- provider-scaffold: supported-providers -->'
const PROVIDER_PATH_PATTERN = /^[a-z][a-z0-9-]*$/
const EXPORT_NAME_PATTERN = /^[A-Z][A-Za-z0-9]*$/

export async function createProvider({ root, path, exportName, displayName, documentationUrl }) {
validateArguments({ path, exportName, displayName, documentationUrl })

const projectRoot = resolve(root)
const providerFile = resolve(projectRoot, 'src/provider', `${exportName}.ts`)
const fixtureDirectory = resolve(projectRoot, 'examples', path)
const fixtureFile = resolve(fixtureDirectory, `${path}.json`)
const testDirectory = resolve(projectRoot, 'test', path)
const testFile = resolve(testDirectory, `${path}-spec.ts`)
const registryFile = resolve(projectRoot, 'src/provider/ProviderRegistry.ts')
const readmeFile = resolve(projectRoot, 'README.md')

await assertMissing(providerFile)
await assertMissing(fixtureDirectory)
await assertMissing(testDirectory)

const [registry, readme] = await Promise.all([readFile(registryFile, 'utf8'), readFile(readmeFile, 'utf8')])
assertMarker(registry, IMPORT_MARKER, registryFile)
assertMarker(registry, DEFINITION_MARKER, registryFile)
assertMarker(readme, README_MARKER, readmeFile)

const nextRegistry = insertProviderImport(registry, exportName).replace(
DEFINITION_MARKER,
` ${exportName},\n${DEFINITION_MARKER}`,
)
const nextReadme = readme.replace(
README_MARKER,
`- [${displayName}](${documentationUrl}) - \`/${path}\`\n${README_MARKER}`,
)

await Promise.all([mkdir(fixtureDirectory, { recursive: true }), mkdir(testDirectory, { recursive: true })])
await Promise.all([
writeFile(providerFile, providerTemplate({ path, exportName, displayName, documentationUrl }), 'utf8'),
writeFile(fixtureFile, fixtureTemplate(), 'utf8'),
writeFile(testFile, testTemplate({ path, exportName }), 'utf8'),
writeFile(registryFile, nextRegistry, 'utf8'),
writeFile(readmeFile, nextReadme, 'utf8'),
])

return { providerFile, fixtureFile, testFile }
}

function validateArguments({ path, exportName, displayName, documentationUrl }) {
if (!PROVIDER_PATH_PATTERN.test(path)) {
throw new Error(
'Provider path must start with a lowercase letter and contain only lowercase letters, digits, or hyphens.',
)
}
if (!EXPORT_NAME_PATTERN.test(exportName)) {
throw new Error('Provider export name must be a PascalCase JavaScript identifier.')
}
if (
typeof displayName !== 'string' ||
displayName.trim() !== displayName ||
displayName.length === 0 ||
displayName.length > 80 ||
hasControlCharacters(displayName)
) {
throw new Error('Provider display name must contain 1-80 printable characters without surrounding whitespace.')
}
if (typeof documentationUrl !== 'string' || /\s/.test(documentationUrl) || documentationUrl.includes('*/')) {
throw new Error('Provider documentation URL must be a valid HTTPS URL.')
}
let documentation
try {
documentation = new URL(documentationUrl)
} catch {
throw new Error('Provider documentation URL must be a valid HTTPS URL.')
}
if (documentation.protocol !== 'https:') {
throw new Error('Provider documentation URL must be a valid HTTPS URL.')
}
}

async function assertMissing(path) {
try {
await access(path)
throw new Error(`Refusing to overwrite existing path: ${path}`)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
}

function assertMarker(content, marker, file) {
if (!content.includes(marker)) throw new Error(`Scaffold marker is missing from ${file}: ${marker}`)
}

function insertProviderImport(registry, exportName) {
const newModule = `./${exportName}.ts`
const newImport = `import { ${exportName} } from '${newModule}'`
const lines = registry.split('\n')
const markerIndex = lines.indexOf(IMPORT_MARKER)
const insertAt = lines.findIndex((line, index) => {
if (index >= markerIndex || !line.startsWith('import ')) return false
const moduleName = line.match(/from '([^']+)'$/)?.[1]
return moduleName != null && moduleName.localeCompare(newModule) > 0
})
lines.splice(insertAt === -1 ? markerIndex : insertAt, 0, newImport)
return lines.join('\n')
}

function providerTemplate({ path, exportName, displayName, documentationUrl }) {
return `import { firstScalar } from '../util/WebhookValue.ts'
import { defineProvider } from './Provider.ts'

/**
* ${documentationUrl}
*/
export const ${exportName} = defineProvider({
path: '${path}',
name: '${escapeSingleQuoted(displayName)}',
example: { body: '${path}/${path}.json' },
map({ body }, output) {
const event = firstScalar(body.event, body.type) ?? '${escapeSingleQuoted(displayName)} notification'
output.addEmbed({ title: event })
},
})
`
}

function fixtureTemplate() {
return `${JSON.stringify({ event: 'Example event' }, null, 4)}\n`
}

function testTemplate({ path, exportName }) {
return `import { describe, it } from 'node:test'
import { ${exportName} } from '../../src/provider/${exportName}.ts'
import { Tester } from '../Tester.ts'

describe('/POST ${path}', () => {
it('example', () => Tester.test(${exportName}, '${path}.json'))
})
`
}

function escapeSingleQuoted(value) {
return value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")
}

function hasControlCharacters(value) {
return [...value].some((character) => {
const codePoint = character.codePointAt(0)
return codePoint <= 0x1f || codePoint === 0x7f
})
}

async function runCli() {
const [path, exportName, displayName, documentationUrl] = process.argv.slice(2)
if ([path, exportName, displayName, documentationUrl].some((value) => value == null)) {
throw new Error(
'Usage: npm run provider:new -- <path> <ExportName> "<Display Name>" <https://documentation-url>',
)
}
const result = await createProvider({
root: process.cwd(),
path,
exportName,
displayName,
documentationUrl,
})
console.log(`Created provider ${displayName}:`)
console.log(`- ${result.providerFile}`)
console.log(`- ${result.fixtureFile}`)
console.log(`- ${result.testFile}`)
console.log('Customize the generated mapper and fixture, then run npm test.')
}

const isMainModule = process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
if (isMainModule) {
runCli().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
}
113 changes: 47 additions & 66 deletions src/provider/AppCenter.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,54 @@
import type { Embed } from '../model/DiscordApi.ts'
import { TypeParseProvider } from '../provider/BaseProvider.ts'
import { defineEventProvider } from './Provider.ts'

/**
* https://learn.microsoft.com/en-us/appcenter/dashboard/webhooks/
*/
export class AppCenter extends TypeParseProvider {
private embed: Embed

constructor() {
super()
this.setEmbedColor(0xcb2e62)
this.embed = {}
}

public getName(): string {
return 'AppCenter'
}

public getType(): string | null {
if (this.body.build_status) {
return 'pipeline'
}

if (this.body.release_id) {
return 'distribute'
}

export const AppCenter = defineEventProvider({
path: 'appcenter',
name: 'AppCenter',
example: { body: 'appcenter/appcenter-pipeline.json' },
defaults: { embedColor: 0xcb2e62 },
event({ body }) {
if (body.build_status) return 'pipeline'
if (body.release_id) return 'distribute'
return null
}

public knownTypes(): string[] {
return ['pipeline', 'distribute']
}

private getEmojiStatus(status: string): string {
switch (status) {
case 'Canceled':
return '🚫'
case 'Failed':
return '❌'
case 'Succeeded':
return '✅'
case 'SucceededWithIssues':
return '⚠️'
default:
return ''
}
}

public async pipeline(): Promise<void> {
this.embed.title = 'Pipeline #' + this.body.build_id + ' on ' + this.body.app_name
this.embed.url = this.body.build_link
this.embed.description = `**Status**: ${this.body.build_status} ${this.getEmojiStatus(this.body.build_status)}`
this.addEmbed(this.embed)
}

public async distribute(): Promise<void> {
const information: string[] = [
`**Version**: ${this.body.short_version} (${this.body.version})`,
`**Platform**: ${this.body.platform}`,
]

if (this.body.release_notes) {
information.push(`**Release notes**: ${this.body.release_notes}`)
}

this.embed.title = 'Distribute #' + this.body.release_id + ' on ' + this.body.app_name
this.embed.url = this.body.install_link
this.embed.description = information.join('\n')
this.addEmbed(this.embed)
},
handlers: {
pipeline({ body }, output) {
output.addEmbed({
title: `Pipeline #${body.build_id} on ${body.app_name}`,
url: body.build_link,
description: `**Status**: ${body.build_status} ${emojiStatus(body.build_status)}`,
})
},
distribute({ body }, output) {
const information = [
`**Version**: ${body.short_version} (${body.version})`,
`**Platform**: ${body.platform}`,
]
if (body.release_notes) {
information.push(`**Release notes**: ${body.release_notes}`)
}
output.addEmbed({
title: `Distribute #${body.release_id} on ${body.app_name}`,
url: body.install_link,
description: information.join('\n'),
})
},
},
})

function emojiStatus(status: string): string {
switch (status) {
case 'Canceled':
return '🚫'
case 'Failed':
return '❌'
case 'Succeeded':
return '✅'
case 'SucceededWithIssues':
return '⚠️'
default:
return ''
}
}
Loading
Loading