Skip to content

Commit a44e4a9

Browse files
authored
Merge pull request #45552 from github/repo-sync
Repo sync
2 parents bd40e7f + 038c6b3 commit a44e4a9

7 files changed

Lines changed: 899 additions & 28 deletions

File tree

.github/workflows/link-check-internal.yml

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ jobs:
212212
steps:
213213
- name: Checkout
214214
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
215+
- uses: ./.github/actions/node-npm-setup
216+
215217
- name: Download all artifacts
216218
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
217219
with:
@@ -221,23 +223,23 @@ jobs:
221223

222224
- name: Combine reports
223225
id: combine
226+
env:
227+
ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
228+
# A version with no broken links uploads no report, so the files on disk undercount
229+
# what was checked. Pass the matrix so the report can say "broken in all versions"
230+
# and mean it.
231+
MATRIX: ${{ needs.setup-matrix.outputs.matrix }}
224232
run: |
225-
# Check if any reports exist
226-
if ls reports/*.md 1> /dev/null 2>&1; then
233+
# Merge the per-version JSON rather than concatenating the rendered Markdown.
234+
# A link broken in every version is one problem, not one per version.
235+
if ls reports/*.json 1> /dev/null 2>&1; then
227236
echo "has_reports=true" >> $GITHUB_OUTPUT
228-
229-
# Combine all markdown reports
230-
echo "# Internal Links Report" > combined-report.md
231-
echo "" >> combined-report.md
232-
echo "Generated: $(date -u +'%Y-%m-%d %H:%M UTC')" >> combined-report.md
233-
echo "[Action run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> combined-report.md
234-
echo "" >> combined-report.md
235-
236-
for report in reports/*.md; do
237-
echo "---" >> combined-report.md
238-
cat "$report" >> combined-report.md
239-
echo "" >> combined-report.md
240-
done
237+
VERSIONS=$(echo "$MATRIX" | jq -r '[.include[] | "\(.version) \(.language)"] | join(",")')
238+
npm run combine-link-reports -- \
239+
--input reports \
240+
--output combined-report.md \
241+
--versions "$VERSIONS" \
242+
--action-url "$ACTION_RUN_URL"
241243
else
242244
echo "has_reports=false" >> $GITHUB_OUTPUT
243245
echo "No broken link reports generated - all links valid!"

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"liquid-tags": "tsx src/content-render/scripts/liquid-tags.ts",
9292
"check-links-pr": "tsx src/links/scripts/check-links-pr.ts",
9393
"check-links-internal": "tsx src/links/scripts/check-links-internal.ts",
94+
"combine-link-reports": "tsx src/links/scripts/combine-link-reports.ts",
9495
"check-links-external": "tsx src/links/scripts/check-links-external.ts",
9596
"rest-dev": "tsx src/rest/scripts/update-files.ts",
9697
"show-action-deps": "echo 'Action Dependencies:' && rg '^[\\s|-]*(uses:.*)$' .github -I -N --no-heading -r '$1$2' | sort | uniq | cut -c 7-",

src/links/lib/link-report.ts

Lines changed: 141 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,20 @@ export interface BrokenLink {
2222
* `update-internal-links` looks the href up exactly as written, so it can't fix these.
2323
*/
2424
requiresVersionContext?: boolean
25+
/**
26+
* Two checked versions resolved this href to genuinely different destinations, so no
27+
* single rewrite is correct for all of them. Merging keeps one target and drops the
28+
* rest, which would otherwise let the report name a destination that is only right for
29+
* one version.
30+
*/
31+
hasConflictingRedirectTargets?: boolean
2532
statusCode?: number
2633
errorMessage?: string
34+
/**
35+
* The versions this link is broken in. Only set on a merged report, where the same link
36+
* usually breaks in every version checked.
37+
*/
38+
versions?: string[]
2739
}
2840

2941
/**
@@ -55,6 +67,8 @@ export interface LinkReport {
5567
totalOccurrences: number
5668
timestamp: string
5769
actionUrl?: string
70+
/** Every version this report covers. Only set on a merged report. */
71+
versionsChecked?: string[]
5872
}
5973

6074
// ============================================================================
@@ -237,6 +251,15 @@ function isVersionOnlyRedirect(target: string, redirectTarget: string): boolean
237251
return withoutVersion === target
238252
}
239253

254+
/**
255+
* Two redirect targets that differ only by version prefix are the same rename seen from
256+
* two versions, not a disagreement. `/enterprise-server@3.21/new` and
257+
* `/enterprise-server@3.17/new` both mean "the page moved to /new".
258+
*/
259+
function sameDestination(a: string, b: string): boolean {
260+
return a.replace(VERSION_PREFIX_RE, '') === b.replace(VERSION_PREFIX_RE, '')
261+
}
262+
240263
/**
241264
* Create a suggestion message for a redirect
242265
*/
@@ -368,6 +391,84 @@ function createSummary(errorCount: number, warningCount: number, totalOccurrence
368391
return `Found ${parts.join(' and ')} across ${totalOccurrences} occurrence${plural}.`
369392
}
370393

394+
/**
395+
* Describe which versions a link breaks in, but only when that is news.
396+
*
397+
* Nearly every broken link breaks in every version, so printing the full list on every
398+
* group is noise that also blows past the issue body size limit. Say something only when a
399+
* link is version-specific.
400+
*/
401+
export function describeVersions(
402+
versions: string[] | undefined,
403+
versionsChecked: string[] | undefined,
404+
): string | undefined {
405+
if (!versions?.length || !versionsChecked?.length) return undefined
406+
if (versionsChecked.length === 1) return undefined
407+
if (versions.length >= versionsChecked.length) return undefined
408+
return versions.join(', ')
409+
}
410+
411+
/**
412+
* Merge one report per version into a single report.
413+
*
414+
* The workflow used to concatenate each version's rendered Markdown, so a link broken in
415+
* every version produced an identical section per version. Merging on the link itself means
416+
* one section per real problem, with the versions recorded on the occurrence.
417+
*/
418+
export function mergeInternalLinkReports(
419+
reports: { version: string; report: LinkReport }[],
420+
options: { actionUrl?: string; versionsChecked?: string[] } = {},
421+
): LinkReport {
422+
const merged = new Map<string, BrokenLink>()
423+
424+
for (const { version, report } of reports) {
425+
for (const group of report.groups) {
426+
for (const occurrence of group.occurrences) {
427+
const href = occurrence.href || group.target
428+
const key = `${href}\u0000${occurrence.file}`
429+
const existing = merged.get(key)
430+
if (existing) {
431+
existing.lines = [...new Set([...existing.lines, ...occurrence.lines])].sort(
432+
(a, b) => a - b,
433+
)
434+
existing.versions = [...new Set([...(existing.versions ?? []), version])]
435+
// A link that redirects in any version is still worth rewriting everywhere.
436+
existing.isRedirect = existing.isRedirect || occurrence.isRedirect
437+
existing.requiresVersionContext =
438+
existing.requiresVersionContext || occurrence.requiresVersionContext
439+
// Keeping the first target and dropping the rest is only safe while every
440+
// version agrees on where the page went. Today they always do, but if that ever
441+
// stops being true the report would confidently name a destination that is
442+
// right for one version and wrong for the others. Flag it instead.
443+
if (
444+
existing.redirectTarget &&
445+
occurrence.redirectTarget &&
446+
!sameDestination(existing.redirectTarget, occurrence.redirectTarget)
447+
) {
448+
existing.hasConflictingRedirectTargets = true
449+
}
450+
existing.redirectTarget = existing.redirectTarget ?? occurrence.redirectTarget
451+
} else {
452+
merged.set(key, { ...occurrence, href, versions: [version] })
453+
}
454+
}
455+
}
456+
}
457+
458+
// A version with no broken links writes no report, so the files on disk undercount what
459+
// was actually checked. Callers that know the full matrix pass it in, otherwise fall back
460+
// to what was found.
461+
const versionsChecked = options.versionsChecked?.length
462+
? options.versionsChecked
463+
: reports.map((r) => r.version)
464+
const report = generateInternalLinkReport([...merged.values()], options)
465+
const scope =
466+
versionsChecked.length > 1
467+
? `\n\nChecked ${versionsChecked.length} versions: ${versionsChecked.join(', ')}. A link listed without a version breaks in all of them.`
468+
: ''
469+
return { ...report, versionsChecked, summary: report.summary + scope }
470+
}
471+
371472
/**
372473
* Generate a report for internal links
373474
*/
@@ -472,6 +573,10 @@ export function classifyFixStrategy(group: GroupedBrokenLinks): FixStrategy {
472573
if (group.occurrences.some((occ) => occ.requiresVersionContext)) {
473574
return 'decide'
474575
}
576+
// Versions disagree about where the page went, so there is no single correct rewrite.
577+
if (group.occurrences.some((occ) => occ.hasConflictingRedirectTargets)) {
578+
return 'decide'
579+
}
475580
return 'codemod'
476581
}
477582
// The link carries a fragment, so the stale part is likely a renamed heading.
@@ -501,15 +606,30 @@ function codemodPaths(groups: GroupedBrokenLinks[]): string[] {
501606
return [...paths].sort()
502607
}
503608

609+
/** The union of versions across a group's occurrences. */
610+
function groupVersions(group: GroupedBrokenLinks): string[] {
611+
const versions = new Set<string>()
612+
for (const occ of group.occurrences) {
613+
for (const version of occ.versions ?? []) versions.add(version)
614+
}
615+
return [...versions]
616+
}
617+
504618
function occurrenceCount(groups: GroupedBrokenLinks[]): number {
505619
return groups.reduce((sum, g) => sum + g.occurrences.length, 0)
506620
}
507621

508-
function renderCodemodSection(groups: GroupedBrokenLinks[]): string {
622+
function renderCodemodSection(groups: GroupedBrokenLinks[], versionsChecked?: string[]): string {
623+
const versionFor = (group: GroupedBrokenLinks) =>
624+
describeVersions(groupVersions(group), versionsChecked)
625+
const showVersions = groups.some((group) => versionFor(group))
626+
509627
const rows = groups
510628
.map((group) => {
511629
const target = group.occurrences.find((occ) => occ.redirectTarget)?.redirectTarget ?? ''
512-
return `| \`${group.target}\` | \`${target}\` | ${group.occurrences.length} |`
630+
const cells = [`\`${group.target}\``, `\`${target}\``, `${group.occurrences.length}`]
631+
if (showVersions) cells.push(versionFor(group) ?? 'all')
632+
return `| ${cells.join(' | ')} |`
513633
})
514634
.join('\n')
515635

@@ -542,8 +662,8 @@ Review the diff, then open a pull request.
542662
<details>
543663
<summary>The ${groups.length} link${plural} this fixes</summary>
544664
545-
| From | To | Occurrences |
546-
|------|-----|-------------|
665+
| From | To | Occurrences |${showVersions ? ' Versions |' : ''}
666+
|------|-----|-------------|${showVersions ? '----------|' : ''}
547667
${rows}
548668
549669
</details>`
@@ -591,8 +711,15 @@ function renderManualSection(
591711
blurb: string,
592712
groups: GroupedBrokenLinks[],
593713
isExternal: boolean,
714+
versionsChecked?: string[],
594715
): string {
595-
const sections = groups.map((group) => TEMPLATES.group(group, isExternal)).join('\n\n')
716+
const sections = groups
717+
.map((group) => {
718+
const versions = describeVersions(groupVersions(group), versionsChecked)
719+
const note = versions ? `\n\n**Only in:** ${versions}` : ''
720+
return TEMPLATES.group(group, isExternal) + note
721+
})
722+
.join('\n\n')
596723
return `## ${heading} (${groups.length} link${groups.length === 1 ? '' : 's'}, ${occurrenceCount(groups)} occurrence${occurrenceCount(groups) === 1 ? '' : 's'})
597724
598725
${blurb}
@@ -604,7 +731,11 @@ ${sections}`
604731
* Render an internal report as four buckets ordered by how much work each one costs, from
605732
* one command down to nothing at all.
606733
*/
607-
function renderByFixStrategy(groups: GroupedBrokenLinks[], isExternal: boolean): string {
734+
function renderByFixStrategy(
735+
groups: GroupedBrokenLinks[],
736+
isExternal: boolean,
737+
versionsChecked?: string[],
738+
): string {
608739
const codemod = groups.filter((g) => classifyFixStrategy(g) === 'codemod')
609740
const versionless = groups.filter((g) => classifyFixStrategy(g) === 'versionless')
610741
const anchors = groups.filter((g) => classifyFixStrategy(g) === 'anchor')
@@ -631,14 +762,15 @@ ${summaryRows.join('\n')}
631762
Work top to bottom. Bucket 1 is usually most of the report and costs one command.`,
632763
]
633764

634-
if (codemod.length > 0) parts.push(renderCodemodSection(codemod))
765+
if (codemod.length > 0) parts.push(renderCodemodSection(codemod, versionsChecked))
635766
if (anchors.length > 0) {
636767
parts.push(
637768
renderManualSection(
638769
'2. Stale anchors',
639770
'The `#fragment` does not match a heading on the target page. Usually a heading was renamed: find it and repoint the link, or drop the fragment if the section is gone. Check that the page itself still exists first, since a missing page with a fragment also lands here.',
640771
anchors,
641772
isExternal,
773+
versionsChecked,
642774
),
643775
)
644776
}
@@ -649,6 +781,7 @@ Work top to bottom. Bucket 1 is usually most of the report and costs one command
649781
'The codemod looks each link up exactly as written, and for these that lookup finds nothing: either no redirect exists at all, or the redirect only exists under a version prefix the link does not carry. Choose a destination, or add a redirect from the path as written.',
650782
decide,
651783
isExternal,
784+
versionsChecked,
652785
),
653786
)
654787
}
@@ -725,7 +858,7 @@ export function reportToMarkdown(report: LinkReport, isExternal = false): string
725858
parts.push(
726859
isExternal
727860
? renderGroups(report.groups, isExternal)
728-
: renderByFixStrategy(report.groups, isExternal),
861+
: renderByFixStrategy(report.groups, isExternal, report.versionsChecked),
729862
)
730863
}
731864

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env tsx
2+
3+
/**
4+
* Combine every version's link report into one deduplicated Markdown report.
5+
*
6+
* The workflow used to `cat` each version's rendered Markdown together, so a link broken in
7+
* every version produced an identical section per version. That multiplied the report by the
8+
* size of the matrix and pushed it past the issue body limit, where it got truncated.
9+
*/
10+
11+
import fs from 'fs'
12+
import path from 'path'
13+
import { program } from 'commander'
14+
15+
import {
16+
mergeInternalLinkReports,
17+
reportToMarkdown,
18+
type LinkReport,
19+
} from '@/links/lib/link-report'
20+
21+
// `link-report-free-pro-team@latest-en.json` -> `free-pro-team@latest en`
22+
const REPORT_FILE = /^link-report-(.+)-([a-z]{2})\.json$/
23+
24+
interface VersionedReport {
25+
version: string
26+
report: LinkReport
27+
}
28+
29+
export function readReports(directory: string): VersionedReport[] {
30+
if (!fs.existsSync(directory)) return []
31+
32+
const reports: VersionedReport[] = []
33+
for (const file of fs.readdirSync(directory).sort()) {
34+
const match = REPORT_FILE.exec(file)
35+
if (!match) continue
36+
37+
const [, version, language] = match
38+
const raw = fs.readFileSync(path.join(directory, file), 'utf8')
39+
reports.push({ version: `${version} ${language}`, report: JSON.parse(raw) as LinkReport })
40+
}
41+
return reports
42+
}
43+
44+
async function main() {
45+
program
46+
.description('Combine per-version link reports into one deduplicated report')
47+
.option('-i, --input <directory>', 'Directory holding the report JSON files', 'reports')
48+
.option('-o, --output <file>', 'Where to write the combined Markdown', 'combined-report.md')
49+
.option('--action-url <url>', 'Link back to the workflow run')
50+
.option(
51+
'--versions <list>',
52+
'Comma-separated list of every version checked, including the ones that came back clean',
53+
)
54+
.parse()
55+
56+
const { input, output, actionUrl, versions } = program.opts()
57+
const versionsChecked = versions
58+
? String(versions)
59+
.split(',')
60+
.map((v: string) => v.trim())
61+
.filter(Boolean)
62+
: undefined
63+
const reports = readReports(input)
64+
65+
if (reports.length === 0) {
66+
console.log(`No report JSON found in ${input}.`)
67+
process.exit(1)
68+
}
69+
70+
const merged = mergeInternalLinkReports(reports, { actionUrl, versionsChecked })
71+
fs.writeFileSync(output, reportToMarkdown(merged))
72+
73+
const before = reports.reduce((sum, r) => sum + r.report.groups.length, 0)
74+
console.log(
75+
`Combined ${reports.length} report(s): ${before} sections before, ${merged.groups.length} after.`,
76+
)
77+
}
78+
79+
main()

0 commit comments

Comments
 (0)