Skip to content

Commit babee40

Browse files
authored
feat: add AuditLogsTransformer for Article API (#58754)
1 parent 50f54eb commit babee40

File tree

17 files changed

+538
-11
lines changed

17 files changed

+538
-11
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# {{ page.title }}
2+
3+
{{ page.intro }}
4+
5+
{{ manualContent }}
6+
7+
## Audit log events
8+
9+
{% for categoryEntry in categorizedEvents %}
10+
{% assign categoryName = categoryEntry[0] %}
11+
{% assign events = categoryEntry[1] %}
12+
### {{ categoryName }}
13+
14+
{% if categoryNotes[categoryName] %}
15+
{{ categoryNotes[categoryName] }}
16+
17+
{% endif %}
18+
{% for event in events %}
19+
#### `{{ event.action }}`
20+
21+
{{ event.description }}
22+
23+
**Fields:** {% if event.fields %}{% for field in event.fields %}`{{ field }}`{% unless forloop.last %}, {% endunless %}{% endfor %}{% else %}No fields available{% endif %}
24+
25+
{% if event.docs_reference_links and event.docs_reference_links != 'N/A' %}
26+
**Reference:** {{ event.docs_reference_links }}
27+
{% endif %}
28+
29+
{% endfor %}
30+
{% endfor %}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { beforeAll, describe, expect, test } from 'vitest'
2+
3+
import { get } from '@/tests/helpers/e2etest'
4+
5+
const makeURL = (pathname: string): string => {
6+
const params = new URLSearchParams({ pathname })
7+
return `/api/article/body?${params}`
8+
}
9+
10+
describe('Audit Logs transformer', () => {
11+
beforeAll(() => {
12+
if (!process.env.ROOT) {
13+
console.warn(
14+
'WARNING: The Audit Logs transformer tests require the ROOT environment variable to be set to the fixture root',
15+
)
16+
}
17+
})
18+
19+
test('Security log events page renders with markdown structure', async () => {
20+
const res = await get(
21+
makeURL('/en/authentication/keeping-your-account-and-data-secure/security-log-events'),
22+
)
23+
expect(res.statusCode).toBe(200)
24+
expect(res.headers['content-type']).toContain('text/markdown')
25+
26+
// Check for the main heading
27+
expect(res.body).toContain('# Security log events')
28+
29+
// Check for intro
30+
expect(res.body).toContain(
31+
'Learn about security log events recorded for your personal account.',
32+
)
33+
34+
// Check for manual content section heading
35+
expect(res.body).toContain('## About security log events')
36+
37+
// Check for new main heading
38+
expect(res.body).toContain('## Audit log events')
39+
40+
// Check for category heading
41+
// The template renders "### Category"
42+
expect(res.body).toMatch(/### \w+/)
43+
})
44+
45+
test('Enterprise audit log events page renders with markdown structure', async () => {
46+
const res = await get(
47+
makeURL(
48+
'/en/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/audit-log-events-for-your-enterprise',
49+
),
50+
)
51+
expect(res.statusCode).toBe(200)
52+
expect(res.headers['content-type']).toContain('text/markdown')
53+
54+
expect(res.body).toContain('# Audit log events for your enterprise')
55+
})
56+
57+
test('Organization audit log events page renders with markdown structure', async () => {
58+
const res = await get(
59+
makeURL(
60+
'/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization',
61+
),
62+
)
63+
expect(res.statusCode).toBe(200)
64+
expect(res.headers['content-type']).toContain('text/markdown')
65+
66+
expect(res.body).toContain('# Audit log events for your organization')
67+
})
68+
69+
test('Events are formatted correctly', async () => {
70+
const res = await get(
71+
makeURL('/en/authentication/keeping-your-account-and-data-secure/security-log-events'),
72+
)
73+
expect(res.statusCode).toBe(200)
74+
75+
// Check for event action header
76+
// #### `action.name`
77+
expect(res.body).toMatch(/#### `[\w.]+`/)
78+
79+
// Check for fields section
80+
expect(res.body).toContain('**Fields:**')
81+
82+
// Check for reference section
83+
expect(res.body).toContain('**Reference:**')
84+
})
85+
86+
test('Manual content is preserved', async () => {
87+
const res = await get(
88+
makeURL('/en/authentication/keeping-your-account-and-data-secure/security-log-events'),
89+
)
90+
expect(res.statusCode).toBe(200)
91+
92+
// The source file has manual content before the marker
93+
expect(res.body).toContain('## About security log events')
94+
})
95+
})
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { Context, Page } from '@/types'
2+
import type { PageTransformer } from './types'
3+
import type { CategorizedEvents } from '@/audit-logs/types'
4+
import { renderContent } from '@/content-render/index'
5+
import matter from '@gr2m/gray-matter'
6+
import { readFileSync } from 'fs'
7+
import { join, dirname } from 'path'
8+
import { fileURLToPath } from 'url'
9+
10+
const __filename = fileURLToPath(import.meta.url)
11+
const __dirname = dirname(__filename)
12+
13+
/**
14+
* Transformer for Audit Logs pages
15+
* Converts audit log events and their data into markdown format using a Liquid template
16+
*/
17+
export class AuditLogsTransformer implements PageTransformer {
18+
canTransform(page: Page): boolean {
19+
return page.autogenerated === 'audit-logs'
20+
}
21+
22+
async transform(page: Page, pathname: string, context: Context): Promise<string> {
23+
// Import audit log lib dynamically to avoid circular dependencies
24+
const { getCategorizedAuditLogEvents, getCategoryNotes, resolveReferenceLinksToMarkdown } =
25+
await import('@/audit-logs/lib/index')
26+
27+
// Extract version from context
28+
const currentVersion = context.currentVersion!
29+
30+
let pageType = ''
31+
if (pathname.includes('/security-log-events')) {
32+
pageType = 'user'
33+
} else if (pathname.includes('/audit-log-events-for-your-enterprise')) {
34+
pageType = 'enterprise'
35+
} else if (pathname.includes('/audit-log-events-for-your-organization')) {
36+
pageType = 'organization'
37+
} else {
38+
throw new Error(`Unknown audit log page type for path: ${pathname}`)
39+
}
40+
41+
// Get the audit log events data
42+
const categorizedEvents = getCategorizedAuditLogEvents(pageType, currentVersion)
43+
const categoryNotes = getCategoryNotes()
44+
45+
// Prepare manual content
46+
let manualContent = ''
47+
if (page.markdown) {
48+
const markerIndex = page.markdown.indexOf(
49+
'<!-- Content after this section is automatically generated -->',
50+
)
51+
if (markerIndex > 0) {
52+
const { content } = matter(page.markdown)
53+
const manualContentMarkerIndex = content.indexOf(
54+
'<!-- Content after this section is automatically generated -->',
55+
)
56+
if (manualContentMarkerIndex > 0) {
57+
const rawManualContent = content.substring(0, manualContentMarkerIndex).trim()
58+
if (rawManualContent) {
59+
manualContent = await renderContent(rawManualContent, {
60+
...context,
61+
markdownRequested: true,
62+
})
63+
}
64+
}
65+
}
66+
}
67+
68+
// Prepare data for template
69+
const templateData = await this.prepareTemplateData(
70+
page,
71+
categorizedEvents,
72+
categoryNotes,
73+
context,
74+
manualContent,
75+
resolveReferenceLinksToMarkdown,
76+
)
77+
78+
// Load and render template
79+
const templatePath = join(__dirname, '../templates/audit-logs-page.template.md')
80+
const templateContent = readFileSync(templatePath, 'utf8')
81+
82+
// Render the template with Liquid
83+
const rendered = await renderContent(templateContent, {
84+
...context,
85+
...templateData,
86+
markdownRequested: true,
87+
})
88+
89+
return rendered
90+
}
91+
92+
/**
93+
* Prepare data for the Liquid template
94+
*/
95+
private async prepareTemplateData(
96+
page: Page,
97+
categorizedEvents: CategorizedEvents,
98+
categoryNotes: Record<string, string>,
99+
context: Context,
100+
manualContent: string,
101+
resolveReferenceLinksToMarkdown: (docsReferenceLinks: string, context: any) => Promise<string>,
102+
): Promise<Record<string, unknown>> {
103+
// Prepare page intro
104+
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
105+
106+
// Sort categories and events
107+
const sortedCategorizedEvents: CategorizedEvents = {}
108+
const sortedCategories = Object.keys(categorizedEvents).sort((a, b) => a.localeCompare(b))
109+
110+
for (const category of sortedCategories) {
111+
// Create a copy of the events array to avoid mutating the cache
112+
const events = [...categorizedEvents[category]].sort((a, b) =>
113+
a.action.localeCompare(b.action),
114+
)
115+
sortedCategorizedEvents[category] = await Promise.all(
116+
events.map(async (event) => {
117+
const newEvent = { ...event }
118+
if (newEvent.docs_reference_links && newEvent.docs_reference_links !== 'N/A') {
119+
newEvent.docs_reference_links = await resolveReferenceLinksToMarkdown(
120+
newEvent.docs_reference_links,
121+
context,
122+
)
123+
}
124+
return newEvent
125+
}),
126+
)
127+
}
128+
129+
return {
130+
page: {
131+
title: page.title,
132+
intro,
133+
},
134+
manualContent,
135+
categorizedEvents: sortedCategorizedEvents,
136+
categoryNotes,
137+
}
138+
}
139+
}
Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { TransformerRegistry } from './types'
22
import { RestTransformer } from './rest-transformer'
3+
import { AuditLogsTransformer } from './audit-logs-transformer'
34
import { GraphQLTransformer } from './graphql-transformer'
45

56
/**
@@ -8,15 +9,9 @@ import { GraphQLTransformer } from './graphql-transformer'
89
*/
910
export const transformerRegistry = new TransformerRegistry()
1011

11-
// Register REST transformer
1212
transformerRegistry.register(new RestTransformer())
13-
14-
// Register GraphQL transformer
13+
transformerRegistry.register(new AuditLogsTransformer())
1514
transformerRegistry.register(new GraphQLTransformer())
1615

17-
// Future transformers can be registered here:
18-
// transformerRegistry.register(new WebhooksTransformer())
19-
// transformerRegistry.register(new GitHubAppsTransformer())
20-
2116
export { TransformerRegistry } from './types'
2217
export type { PageTransformer } from './types'

src/audit-logs/lib/index.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,61 @@ export function getCategoryNotes(): CategoryNotes {
3131
return auditLogConfig.categoryNotes || {}
3232
}
3333

34-
type TitleResolutionContext = Context & {
34+
export type TitleResolutionContext = Context & {
3535
pages: Record<string, Page>
3636
redirects: Record<string, string>
3737
}
3838

39+
// Resolves docs_reference_links URLs to markdown links
40+
export async function resolveReferenceLinksToMarkdown(
41+
docsReferenceLinks: string,
42+
context: TitleResolutionContext,
43+
): Promise<string> {
44+
if (!docsReferenceLinks || docsReferenceLinks === 'N/A') {
45+
return ''
46+
}
47+
48+
// Handle multiple comma-separated or space-separated links
49+
const links = docsReferenceLinks
50+
.split(/[,\s]+/)
51+
.map((link) => link.trim())
52+
.filter((link) => link && link !== 'N/A')
53+
54+
const markdownLinks = []
55+
for (const link of links) {
56+
try {
57+
const page = findPage(link, context.pages, context.redirects)
58+
if (page) {
59+
// Create a minimal context for rendering the title
60+
const renderContext = {
61+
currentLanguage: 'en',
62+
currentVersion: 'free-pro-team@latest',
63+
pages: context.pages,
64+
redirects: context.redirects,
65+
} as unknown as Context
66+
const title = await page.renderProp('title', renderContext, { textOnly: true })
67+
markdownLinks.push(`[${title}](${link})`)
68+
} else {
69+
// If we can't resolve the link, use the original URL
70+
markdownLinks.push(link)
71+
}
72+
} catch (error) {
73+
// If resolution fails, use the original URL
74+
console.warn(
75+
`Failed to resolve title for link: ${link}`,
76+
error instanceof Error
77+
? error instanceof Error
78+
? error.message
79+
: String(error)
80+
: String(error),
81+
)
82+
markdownLinks.push(link)
83+
}
84+
}
85+
86+
return markdownLinks.join(', ')
87+
}
88+
3989
// Resolves docs_reference_links URLs to page titles
4090
async function resolveReferenceLinksToTitles(
4191
docsReferenceLinks: string,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
title: Enterprise administrator documentation
3+
shortTitle: Enterprise administrators
4+
intro: 'Documentation and guides for enterprise administrators.'
5+
6+
changelog:
7+
label: enterprise
8+
layout: product-landing
9+
versions:
10+
ghec: '*'
11+
ghes: '*'
12+
children:
13+
- /monitoring-activity-in-your-enterprise
14+
---
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
title: Monitoring activity in your enterprise
3+
intro: 'You can view user and system activity by leveraging audit logs.'
4+
redirect_from:
5+
- /enterprise/admin/installation/monitoring-activity-on-your-github-enterprise-server-instance
6+
versions:
7+
ghec: '*'
8+
ghes: '*'
9+
topics:
10+
- Enterprise
11+
children:
12+
- /reviewing-audit-logs-for-your-enterprise
13+
shortTitle: Monitor user activity
14+
---

0 commit comments

Comments
 (0)