Skip to content

Commit 9644e43

Browse files
authored
Support autogenerated webhooks pages in Article API (#58870)
1 parent 5a3c4d5 commit 9644e43

File tree

5 files changed

+345
-0
lines changed

5 files changed

+345
-0
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# {{ page.title }}
2+
3+
{% if page.intro %}
4+
{{ page.intro }}
5+
{% endif %}
6+
7+
{% if manualContent %}
8+
{{ manualContent }}
9+
{% endif %}
10+
11+
{% for webhook in webhooks %}
12+
## {{ webhook.name }}
13+
14+
**Available actions:** {% for actionType in webhook.actionTypes %}{% if forloop.last and forloop.length > 1 %}and {% endif %}`{{ actionType }}`{% unless forloop.last %}{% if forloop.length > 2 %}, {% else %} {% endif %}{% endunless %}{% endfor %}
15+
16+
{% if webhook.data.descriptionHtml %}
17+
{{ webhook.data.descriptionHtml }}
18+
{% endif %}
19+
20+
**Availability:** {% for availability in webhook.data.availability %}{% if forloop.last and forloop.length > 1 %}and {% endif %}`{{ availability }}`{% unless forloop.last %}{% if forloop.length > 2 %}, {% else %} {% endif %}{% endunless %}{% endfor %}
21+
22+
{% endfor %}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { beforeAll, describe, expect, test } from 'vitest'
2+
3+
import { get } from '@/tests/helpers/e2etest'
4+
5+
function makeURL(pathname: string, queryParams?: Record<string, string>) {
6+
const params = new URLSearchParams(queryParams || {})
7+
const queryString = params.toString()
8+
return `/api/article/body?pathname=${encodeURIComponent(pathname)}${queryString ? `&${queryString}` : ''}`
9+
}
10+
11+
describe('Webhooks transformer', () => {
12+
beforeAll(() => {
13+
if (!process.env.ROOT) {
14+
console.warn(
15+
'WARNING: The Webhooks transformer tests require the ROOT environment variable to be set to the fixture root',
16+
)
17+
}
18+
})
19+
20+
test('webhook-events-and-payloads page renders with markdown structure', async () => {
21+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
22+
expect(res.statusCode).toBe(200)
23+
expect(res.headers['content-type']).toContain('text/markdown')
24+
25+
// Should have title
26+
expect(res.body).toContain('# Webhook events and payloads')
27+
28+
// Should have intro
29+
expect(res.body).toContain('Learn about when each webhook event occurs')
30+
})
31+
32+
test('webhooks are formatted as sections with headers', async () => {
33+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
34+
expect(res.statusCode).toBe(200)
35+
36+
// Check for webhook event headers (## webhook_name)
37+
expect(res.body).toMatch(/^## \w+/m)
38+
})
39+
40+
test('webhooks show available actions', async () => {
41+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
42+
expect(res.statusCode).toBe(200)
43+
44+
// Should list action types for webhooks with multiple actions
45+
expect(res.body).toContain('**Action type:**')
46+
})
47+
48+
test('webhooks show availability information', async () => {
49+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
50+
expect(res.statusCode).toBe(200)
51+
52+
// Should show availability as a heading
53+
expect(res.body).toContain('### Availability')
54+
})
55+
56+
test('manual content is included', async () => {
57+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
58+
expect(res.statusCode).toBe(200)
59+
60+
// Check for some known manual content from the markdown file
61+
expect(res.body).toContain('About webhook events and payloads')
62+
})
63+
64+
test('intro is rendered', async () => {
65+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
66+
expect(res.statusCode).toBe(200)
67+
68+
const introMatch = res.body.match(/^Learn about when each webhook event/m)
69+
expect(introMatch).toBeTruthy()
70+
})
71+
72+
test('Liquid tags are rendered in content', async () => {
73+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
74+
expect(res.statusCode).toBe(200)
75+
76+
// Check that data variables are rendered (not left as Liquid syntax)
77+
expect(res.body).not.toContain('{% data')
78+
expect(res.body).not.toContain('{{')
79+
})
80+
81+
test('AUTOTITLE links are resolved', async () => {
82+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
83+
expect(res.statusCode).toBe(200)
84+
85+
// AUTOTITLE should be replaced with actual titles
86+
expect(res.body).not.toContain('[AUTOTITLE]')
87+
})
88+
89+
test('webhooks show payload parameters table', async () => {
90+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
91+
expect(res.statusCode).toBe(200)
92+
93+
// Should show payload object parameters section
94+
expect(res.body).toContain('### Webhook payload object')
95+
expect(res.body).toContain('#### Webhook payload object parameters')
96+
// Should have a markdown table with parameter columns
97+
expect(res.body).toContain('| Name | Type | Description |')
98+
})
99+
100+
test('webhooks show descriptions', async () => {
101+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
102+
expect(res.statusCode).toBe(200)
103+
104+
// Should include webhook descriptions (converted from HTML to plain text)
105+
// Using actual descriptions from real webhook data
106+
expect(res.body).toContain('A check run was completed')
107+
})
108+
109+
test('webhooks show body parameters in table', async () => {
110+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
111+
expect(res.statusCode).toBe(200)
112+
113+
// Should show parameter names and types in tables
114+
expect(res.body).toContain('`action`')
115+
expect(res.body).toContain('`string`')
116+
expect(res.body).toContain('`object`')
117+
// Should mark required parameters
118+
expect(res.body).toContain('**Required.**')
119+
})
120+
121+
test('webhooks show payload examples when available', async () => {
122+
const res = await get(makeURL('/en/webhooks/webhook-events-and-payloads'))
123+
expect(res.statusCode).toBe(200)
124+
125+
// NOTE: The current webhook source data does not include payloadExample fields,
126+
// so this section won't appear in the output. The transformer code (lines 115-120)
127+
// is ready to display payload examples if/when they are added to the source data.
128+
// For now, we just verify the transformer doesn't crash on missing examples.
129+
expect(res.statusCode).toBe(200)
130+
})
131+
132+
test('Non-webhooks pages are not transformed', async () => {
133+
const res = await get(makeURL('/en/get-started/start-your-journey/hello-world'))
134+
expect(res.statusCode).toBe(200)
135+
136+
// Regular pages should not be transformed by webhooks transformer
137+
// They should have their normal HTML-like structure
138+
expect(res.body).toContain('Hello World')
139+
})
140+
})

src/article-api/transformers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { SecretScanningTransformer } from './secret-scanning-transformer'
44
import { CodeQLCliTransformer } from './codeql-cli-transformer'
55
import { AuditLogsTransformer } from './audit-logs-transformer'
66
import { GraphQLTransformer } from './graphql-transformer'
7+
import { WebhooksTransformer } from './webhooks-transformer'
78

89
/**
910
* Global transformer registry
@@ -16,6 +17,7 @@ transformerRegistry.register(new SecretScanningTransformer())
1617
transformerRegistry.register(new CodeQLCliTransformer())
1718
transformerRegistry.register(new AuditLogsTransformer())
1819
transformerRegistry.register(new GraphQLTransformer())
20+
transformerRegistry.register(new WebhooksTransformer())
1921

2022
export { TransformerRegistry } from './types'
2123
export type { PageTransformer } from './types'
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type { Context, Page } from '@/types'
2+
import type { PageTransformer } from './types'
3+
import { renderContent } from '@/content-render/index'
4+
import { fastTextOnly } from '@/content-render/unified/text-only'
5+
import matter from '@gr2m/gray-matter'
6+
7+
/**
8+
* Transformer for Webhooks pages.
9+
* Converts webhook events and payloads into markdown format.
10+
*/
11+
export class WebhooksTransformer implements PageTransformer {
12+
canTransform(page: Page): boolean {
13+
return page.autogenerated === 'webhooks'
14+
}
15+
16+
async transform(page: Page, pathname: string, context: Context): Promise<string> {
17+
// Import getInitialPageWebhooks dynamically to avoid circular dependencies
18+
const { getInitialPageWebhooks } = await import('@/webhooks/lib/index')
19+
20+
// Extract version from context
21+
const currentVersion = context.currentVersion!
22+
23+
// Get the webhook data
24+
const webhooksData = await getInitialPageWebhooks(currentVersion)
25+
26+
// Prepare page intro
27+
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
28+
29+
// Build the page header manually to avoid Context type conflicts
30+
let headerMarkdown = `# ${page.title}\n\n`
31+
if (intro) {
32+
headerMarkdown += `${intro}\n\n`
33+
}
34+
35+
// Prepare manual content
36+
let manualContent = ''
37+
if (page.markdown) {
38+
const { content } = matter(page.markdown)
39+
const markerIndex = content.indexOf(
40+
'<!-- Content after this section is automatically generated -->',
41+
)
42+
43+
if (markerIndex > 0) {
44+
const manualMarkdown = content.substring(0, markerIndex).trim()
45+
if (manualMarkdown) {
46+
manualContent = await renderContent(manualMarkdown, {
47+
...context,
48+
markdownRequested: true,
49+
})
50+
}
51+
}
52+
}
53+
54+
// Build webhooks sections manually
55+
let webhooksMarkdown = ''
56+
for (const webhook of webhooksData) {
57+
webhooksMarkdown += `## ${webhook.name}\n\n`
58+
59+
// Summary if available
60+
if (webhook.data.summaryHtml) {
61+
const summaryText = fastTextOnly(webhook.data.summaryHtml)
62+
webhooksMarkdown += `${summaryText}\n\n`
63+
}
64+
65+
// Availability
66+
if (webhook.data.availability && webhook.data.availability.length > 0) {
67+
webhooksMarkdown += '### Availability\n\n'
68+
for (const avail of webhook.data.availability) {
69+
webhooksMarkdown += `- \`${avail}\`\n`
70+
}
71+
webhooksMarkdown += '\n'
72+
}
73+
74+
// Webhook payload object section
75+
webhooksMarkdown += '### Webhook payload object\n\n'
76+
77+
// Available actions
78+
if (webhook.actionTypes.length > 1) {
79+
webhooksMarkdown += '**Action type:** '
80+
const actions = webhook.actionTypes.map((a) => `\`${a}\``).join(', ')
81+
webhooksMarkdown += `${actions}\n\n`
82+
}
83+
84+
// Description if available
85+
if (webhook.data.descriptionHtml) {
86+
const descriptionText = fastTextOnly(webhook.data.descriptionHtml)
87+
webhooksMarkdown += `${descriptionText}\n\n`
88+
}
89+
90+
// Body parameters (payload structure)
91+
if (webhook.data.bodyParameters && webhook.data.bodyParameters.length > 0) {
92+
webhooksMarkdown += '#### Webhook payload object parameters\n\n'
93+
webhooksMarkdown += '| Name | Type | Description |\n'
94+
webhooksMarkdown += '|------|------|-------------|\n'
95+
96+
for (const param of webhook.data.bodyParameters) {
97+
const name = param.name ? `\`${param.name}\`` : ''
98+
const type = param.type ? `\`${param.type}\`` : ''
99+
// Convert HTML description to plain text
100+
let desc = param.description || ''
101+
if (desc) {
102+
desc = fastTextOnly(desc).replace(/\n/g, ' ').trim()
103+
}
104+
// Add required indicator
105+
if (param.isRequired) {
106+
desc = `**Required.** ${desc}`
107+
}
108+
109+
webhooksMarkdown += `| ${name} | ${type} | ${desc} |\n`
110+
}
111+
webhooksMarkdown += '\n'
112+
}
113+
114+
// Payload example if available
115+
if (webhook.data.payloadExample) {
116+
webhooksMarkdown += '### Webhook payload example\n\n'
117+
webhooksMarkdown += '```json\n'
118+
webhooksMarkdown += JSON.stringify(webhook.data.payloadExample, null, 2)
119+
webhooksMarkdown += '\n```\n\n'
120+
}
121+
}
122+
123+
return `${headerMarkdown + manualContent}\n\n${webhooksMarkdown}`
124+
}
125+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"check_run": {
3+
"completed": {
4+
"descriptionHtml": "<p>Check run is <strong>completed</strong>.</p>",
5+
"summaryHtml": "",
6+
"bodyParameters": [
7+
{
8+
"name": "action",
9+
"description": "The action performed. Value: <code>completed</code>",
10+
"isRequired": true,
11+
"type": "string"
12+
},
13+
{
14+
"name": "check_run",
15+
"description": "The check run object",
16+
"isRequired": true,
17+
"type": "object"
18+
}
19+
],
20+
"availability": ["Repository", "Organization", "GitHub App"],
21+
"action": "completed",
22+
"category": "check_run",
23+
"payloadExample": {
24+
"action": "completed",
25+
"check_run": {
26+
"id": 4,
27+
"status": "completed",
28+
"conclusion": "neutral"
29+
}
30+
}
31+
}
32+
},
33+
"issues": {
34+
"opened": {
35+
"descriptionHtml": "<p>Issue is <em>opened</em>.</p>",
36+
"summaryHtml": "",
37+
"bodyParameters": [
38+
{
39+
"name": "action",
40+
"description": "The action performed",
41+
"isRequired": true,
42+
"type": "string"
43+
},
44+
{
45+
"name": "issue",
46+
"description": "The issue object",
47+
"isRequired": false,
48+
"type": "object"
49+
}
50+
],
51+
"availability": ["Repository", "Organization"],
52+
"action": "opened",
53+
"category": "issues"
54+
}
55+
}
56+
}

0 commit comments

Comments
 (0)