Skip to content

Commit f98b10a

Browse files
authored
feat(utils): support local report links
1 parent 7b6d7da commit f98b10a

File tree

11 files changed

+362
-52
lines changed

11 files changed

+362
-52
lines changed

packages/core/src/lib/implementation/persist.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export async function persistReport(
4545
case 'md':
4646
return {
4747
format: 'md',
48-
content: generateMdReport(sortedScoredReport),
48+
content: generateMdReport(sortedScoredReport, { outputDir }),
4949
};
5050
}
5151
});

packages/models/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ export {
22
type TableCellValue,
33
tableCellValueSchema,
44
} from './lib/implementation/schemas';
5+
export {
6+
type SourceFileLocation,
7+
sourceFileLocationSchema,
8+
} from './lib/source';
59

610
export { type Audit, auditSchema } from './lib/audit';
711
export {

packages/models/src/lib/issue.ts

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,6 @@
11
import { z } from 'zod';
22
import { MAX_ISSUE_MESSAGE_LENGTH } from './implementation/limits';
3-
import { filePathSchema, positiveIntSchema } from './implementation/schemas';
4-
5-
const sourceFileLocationSchema = z.object(
6-
{
7-
file: filePathSchema.describe('Relative path to source file in Git repo'),
8-
position: z
9-
.object(
10-
{
11-
startLine: positiveIntSchema.describe('Start line'),
12-
startColumn: positiveIntSchema.describe('Start column').optional(),
13-
endLine: positiveIntSchema.describe('End line').optional(),
14-
endColumn: positiveIntSchema.describe('End column').optional(),
15-
},
16-
{ description: 'Location in file' },
17-
)
18-
.optional(),
19-
},
20-
{ description: 'Source file location' },
21-
);
3+
import { sourceFileLocationSchema } from './source';
224

235
export const issueSeveritySchema = z.enum(['info', 'warning', 'error'], {
246
description: 'Severity level',

packages/models/src/lib/source.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { z } from 'zod';
2+
import { filePathSchema, positiveIntSchema } from './implementation/schemas';
3+
4+
export const sourceFileLocationSchema = z.object(
5+
{
6+
file: filePathSchema.describe('Relative path to source file in Git repo'),
7+
position: z
8+
.object(
9+
{
10+
startLine: positiveIntSchema.describe('Start line'),
11+
startColumn: positiveIntSchema.describe('Start column').optional(),
12+
endLine: positiveIntSchema.describe('End line').optional(),
13+
endColumn: positiveIntSchema.describe('End column').optional(),
14+
},
15+
{ description: 'Location in file' },
16+
)
17+
.optional(),
18+
},
19+
{ description: 'Source file location' },
20+
);
21+
22+
export type SourceFileLocation = z.infer<typeof sourceFileLocationSchema>;

packages/utils/src/lib/reports/formatting.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@ import {
44
MarkdownDocument,
55
md,
66
} from 'build-md';
7-
import type { AuditReport, Table } from '@code-pushup/models';
7+
import { posix as pathPosix } from 'node:path';
8+
import type {
9+
AuditReport,
10+
SourceFileLocation,
11+
Table,
12+
} from '@code-pushup/models';
813
import { HIERARCHY } from '../text-formats';
914
import {
1015
columnsToStringArray,
1116
getColumnAlignments,
1217
rowToStringArray,
1318
} from '../text-formats/table';
19+
import { getEnvironmentType, getGitHubBaseUrl } from './ide-environment';
20+
import type { MdReportOptions } from './types';
1421

1522
export function tableSection(
1623
tableData: Table,
@@ -58,3 +65,75 @@ export function metaDescription(
5865
}
5966
return '';
6067
}
68+
69+
/**
70+
* Link to local source for IDE
71+
* @param source
72+
* @param reportLocation
73+
*
74+
* @example
75+
* linkToLocalSourceInIde({ file: 'src/index.ts' }, { outputDir: '.code-pushup' }) // [`src/index.ts`](../src/index.ts)
76+
*/
77+
export function linkToLocalSourceForIde(
78+
source: SourceFileLocation,
79+
options?: Pick<MdReportOptions, 'outputDir'>,
80+
): InlineText {
81+
const { file, position } = source;
82+
const { outputDir } = options ?? {};
83+
84+
// NOT linkable
85+
if (!outputDir) {
86+
return md.code(file);
87+
}
88+
89+
return md.link(formatFileLink(file, position, outputDir), md.code(file));
90+
}
91+
92+
export function formatSourceLine(
93+
position: SourceFileLocation['position'],
94+
): string {
95+
if (!position) {
96+
return '';
97+
}
98+
const { startLine, endLine } = position;
99+
return endLine && startLine !== endLine
100+
? `${startLine}-${endLine}`
101+
: `${startLine}`;
102+
}
103+
104+
export function formatGitHubLink(
105+
file: string,
106+
position: SourceFileLocation['position'],
107+
): string {
108+
const baseUrl = getGitHubBaseUrl();
109+
if (!position) {
110+
return `${baseUrl}/${file}`;
111+
}
112+
const { startLine, endLine, startColumn, endColumn } = position;
113+
const start = startColumn ? `L${startLine}C${startColumn}` : `L${startLine}`;
114+
const end = endLine
115+
? endColumn
116+
? `L${endLine}C${endColumn}`
117+
: `L${endLine}`
118+
: '';
119+
const lineRange = end && start !== end ? `${start}-${end}` : start;
120+
return `${baseUrl}/${file}#${lineRange}`;
121+
}
122+
123+
export function formatFileLink(
124+
file: string,
125+
position: SourceFileLocation['position'],
126+
outputDir: string,
127+
): string {
128+
const relativePath = pathPosix.relative(outputDir, file);
129+
const env = getEnvironmentType();
130+
131+
switch (env) {
132+
case 'vscode':
133+
return position ? `${relativePath}#L${position.startLine}` : relativePath;
134+
case 'github':
135+
return formatGitHubLink(file, position);
136+
default:
137+
return relativePath;
138+
}
139+
}

packages/utils/src/lib/reports/formatting.unit.test.ts

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { describe, expect, it } from 'vitest';
2-
import { metaDescription, tableSection } from './formatting';
2+
import { toUnixPath } from '../transform';
3+
import {
4+
formatFileLink,
5+
formatGitHubLink,
6+
formatSourceLine,
7+
linkToLocalSourceForIde,
8+
metaDescription,
9+
tableSection,
10+
} from './formatting';
311

412
describe('tableSection', () => {
513
it('should accept a title', () => {
@@ -112,3 +120,171 @@ describe('metaDescription', () => {
112120
);
113121
});
114122
});
123+
124+
describe('formatSourceLine', () => {
125+
it.each([
126+
[{ startLine: 2 }, '2'],
127+
[{ startLine: 2, endLine: undefined }, '2'],
128+
[{ startLine: 2, endLine: 2 }, '2'],
129+
[{ startLine: 2, endLine: 3 }, '2-3'],
130+
])('should format position %o as "%s"', (position, expected) => {
131+
expect(formatSourceLine(position)).toBe(expected);
132+
});
133+
134+
it('should return an empty string when the position is missing', () => {
135+
expect(formatSourceLine(undefined)).toBe('');
136+
});
137+
});
138+
139+
describe('linkToLocalSourceForIde', () => {
140+
it('should not format the file path as a link when outputDir is undefined (VS Code)', () => {
141+
vi.stubEnv('TERM_PROGRAM', 'vscode');
142+
vi.stubEnv('GITHUB_ACTIONS', 'false');
143+
144+
expect(
145+
linkToLocalSourceForIde({
146+
file: toUnixPath('packages/utils/src/index.ts'),
147+
}).toString(),
148+
).toBe('`packages/utils/src/index.ts`');
149+
});
150+
151+
it('should format the file path as a link when outputDir is defined (VS Code)', () => {
152+
vi.stubEnv('TERM_PROGRAM', 'vscode');
153+
vi.stubEnv('GITHUB_ACTIONS', 'false');
154+
const filePath = toUnixPath('packages/utils/src/index.ts');
155+
const outputDir = toUnixPath('.code-pushup');
156+
157+
expect(
158+
linkToLocalSourceForIde({ file: filePath }, { outputDir }).toString(),
159+
).toBe('[`packages/utils/src/index.ts`](../packages/utils/src/index.ts)');
160+
});
161+
162+
it('should return a link to a specific line when startLine is provided (VS Code)', () => {
163+
vi.stubEnv('TERM_PROGRAM', 'vscode');
164+
vi.stubEnv('GITHUB_ACTIONS', 'false');
165+
const filePath = toUnixPath('packages/utils/src/index.ts');
166+
const outputDir = toUnixPath('.code-pushup');
167+
168+
expect(
169+
linkToLocalSourceForIde(
170+
{ file: filePath, position: { startLine: 2 } },
171+
{ outputDir },
172+
).toString(),
173+
).toBe(
174+
'[`packages/utils/src/index.ts`](../packages/utils/src/index.ts#L2)',
175+
);
176+
});
177+
});
178+
179+
describe('formatGitHubLink', () => {
180+
beforeEach(() => {
181+
vi.stubEnv('TERM_PROGRAM', '');
182+
vi.stubEnv('GITHUB_ACTIONS', 'true');
183+
vi.stubEnv('GITHUB_SERVER_URL', 'https://github.com');
184+
vi.stubEnv('GITHUB_REPOSITORY', 'user/repo');
185+
vi.stubEnv('GITHUB_SHA', '1234567890abcdef');
186+
});
187+
188+
it.each([
189+
[
190+
{ startLine: 2 },
191+
'https://github.com/user/repo/blob/1234567890abcdef/src/index.ts#L2',
192+
],
193+
[
194+
{ startLine: 2, endLine: 5 },
195+
'https://github.com/user/repo/blob/1234567890abcdef/src/index.ts#L2-L5',
196+
],
197+
[
198+
{ startLine: 2, startColumn: 1 },
199+
'https://github.com/user/repo/blob/1234567890abcdef/src/index.ts#L2C1',
200+
],
201+
[
202+
{ startLine: 2, endLine: 2, startColumn: 1, endColumn: 5 },
203+
'https://github.com/user/repo/blob/1234567890abcdef/src/index.ts#L2C1-L2C5',
204+
],
205+
[
206+
{ startLine: 2, endLine: 5, startColumn: 1, endColumn: 6 },
207+
'https://github.com/user/repo/blob/1234567890abcdef/src/index.ts#L2C1-L5C6',
208+
],
209+
[
210+
{ startLine: 2, endLine: 2, startColumn: 1, endColumn: 1 },
211+
'https://github.com/user/repo/blob/1234567890abcdef/src/index.ts#L2C1',
212+
],
213+
])(
214+
'should generate a GitHub repository link for the file with position %o',
215+
(position, expected) => {
216+
expect(formatGitHubLink(toUnixPath('src/index.ts'), position)).toBe(
217+
expected,
218+
);
219+
},
220+
);
221+
222+
it('should generate a GitHub repository link for the file when the position is undefined', () => {
223+
expect(formatGitHubLink(toUnixPath('src/index.ts'), undefined)).toBe(
224+
'https://github.com/user/repo/blob/1234567890abcdef/src/index.ts',
225+
);
226+
});
227+
});
228+
229+
describe('formatFileLink', () => {
230+
it('should return a GitHub repository link when running in GitHub Actions', () => {
231+
vi.stubEnv('TERM_PROGRAM', '');
232+
vi.stubEnv('GITHUB_ACTIONS', 'true');
233+
vi.stubEnv('GITHUB_SERVER_URL', 'https://github.com');
234+
vi.stubEnv('GITHUB_REPOSITORY', 'user/repo');
235+
vi.stubEnv('GITHUB_SHA', '1234567890abcdef');
236+
237+
expect(
238+
formatFileLink(
239+
toUnixPath('src/index.ts'),
240+
{ startLine: 2 },
241+
toUnixPath('.code-pushup'),
242+
),
243+
).toBe(
244+
`https://github.com/user/repo/blob/1234567890abcdef/src/index.ts#L2`,
245+
);
246+
});
247+
248+
it.each([
249+
[{ startLine: 2 }, '../src/index.ts#L2'],
250+
[{ startLine: 2, endLine: 5 }, '../src/index.ts#L2'],
251+
[{ startLine: 2, startColumn: 1 }, '../src/index.ts#L2'],
252+
])(
253+
'should transform the file path by including position %o when running in VS Code',
254+
(position, expected) => {
255+
vi.stubEnv('TERM_PROGRAM', 'vscode');
256+
vi.stubEnv('GITHUB_ACTIONS', 'false');
257+
expect(
258+
formatFileLink(
259+
toUnixPath('src/index.ts'),
260+
position,
261+
toUnixPath('.code-pushup'),
262+
),
263+
).toBe(expected);
264+
},
265+
);
266+
267+
it('should return a relative file path when the position is undefined (VS Code)', () => {
268+
vi.stubEnv('TERM_PROGRAM', 'vscode');
269+
vi.stubEnv('GITHUB_ACTIONS', 'false');
270+
expect(
271+
formatFileLink(
272+
toUnixPath('src/index.ts'),
273+
undefined,
274+
toUnixPath('.code-pushup'),
275+
),
276+
).toBe('../src/index.ts');
277+
});
278+
279+
it('should return a relative file path when the environment is neither VS Code nor GitHub', () => {
280+
vi.stubEnv('TERM_PROGRAM', '');
281+
vi.stubEnv('GITHUB_ACTIONS', 'false');
282+
expect(
283+
formatFileLink(
284+
toUnixPath('src/index.ts'),
285+
{ startLine: 2, startColumn: 1 },
286+
toUnixPath('.code-pushup'),
287+
),
288+
).toBe('../src/index.ts');
289+
});
290+
});

0 commit comments

Comments
 (0)