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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

# Changelog

## [3.1.2] - 2026-01-14

### Fixed

- Changed error Markdown panel from `[!ERROR]` to `[!CAUTION]` as ERROR is not a
supported GitHub alert type
- Fixed duplicate `@mention` escaping in PR comments if already escaped
- Fixed file being relative to runner root instead of repository root in PR
comments
- Fixed PR comments to be minimized when the latest run has no skipped
annotations, ensuring old comments are cleared when they're no longer relevant

### Improved

- Truncate long file paths in PR comments for better readability

## [3.1.1] - 2025-12-30

### Fixed
Expand Down
142 changes: 139 additions & 3 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { jest } from '@jest/globals';
import { PendingAnnotation } from '../src/main';

// Type for mutable context in tests
type MutableContext = Omit<Context, 'payload' | 'repo'> & {
Expand Down Expand Up @@ -92,6 +93,8 @@ let mockOctokit: {
describe('action', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock GITHUB_WORKSPACE to match fixture paths
process.env.GITHUB_WORKSPACE = '/home/runner/work/repo-name/repo-name';
// Reset GitHub context
(github.context as MutableContext).payload = {};
(github.context as MutableContext).repo = {
Expand Down Expand Up @@ -156,7 +159,7 @@ describe('action', () => {
{
endColumn: undefined,
endLine: undefined,
file: '/home/runner/work/repo-name/repo-name/cypress/plugins/s3-email-client/s3-utils.ts',
file: 'cypress/plugins/s3-email-client/s3-utils.ts',
startColumn: 28,
startLine: 7,
title: '@typescript-eslint/dot-notation',
Expand All @@ -165,7 +168,7 @@ describe('action', () => {
expect(warningMock).toHaveBeenCalledWith('Missing JSDoc comment.', {
endColumn: undefined,
endLine: undefined,
file: '/home/runner/work/repo-name/repo-name/cypress/plugins/s3-email-client/s3-utils.ts',
file: 'cypress/plugins/s3-email-client/s3-utils.ts',
startColumn: 18,
startLine: 2,
title: 'jsdoc/require-jsdoc',
Expand Down Expand Up @@ -296,7 +299,7 @@ at Tests.Registration.main(Registration.java:202)`,
{
endColumn: undefined,
endLine: undefined,
file: '/home/runner/work/repo-name/repo-name/cypress/plugins/s3-email-client/s3-utils.ts',
file: 'cypress/plugins/s3-email-client/s3-utils.ts',
startColumn: 28,
startLine: 7,
title: '@typescript-eslint/dot-notation',
Expand Down Expand Up @@ -476,6 +479,52 @@ at Tests.Registration.main(Registration.java:202)`,
);
});

it('should minimize previous PR comments when no annotations are skipped', async () => {
// Mock GitHub context to be on a PR
(github.context as MutableContext).payload = {
pull_request: { number: 123 },
};
// Use a report with few errors that won't exceed the limit
testInputs.reports = ['junit|fixtures/junit-generic.xml'];
testInputs['max-annotations'] = '10';
// Mock listComments to return some previous bot comments
mockOctokit.rest.issues.listComments.mockResolvedValue({
data: [
{
id: 1,
node_id: 'comment1',
body: '## Skipped Annotations\n\nOld comment',
},
{
id: 2,
node_id: 'comment2',
body: 'Some other comment',
},
],
});
// Mock graphql for minimizing comments
mockOctokit.graphql.mockResolvedValue({});
await main.run();
expect(mockOctokit.rest.issues.listComments).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
issue_number: 123,
page: 1,
per_page: 100,
});
expect(mockOctokit.graphql).toHaveBeenCalledWith(
expect.stringContaining('MinimizeComment'),
{
input: {
subjectId: 'comment1',
classifier: 'OUTDATED',
},
},
);
// Should not create a new comment since no annotations were skipped
expect(mockOctokit.rest.issues.createComment).not.toHaveBeenCalled();
});

it('should handle pagination when fetching comments', async () => {
// Mock GitHub context to be on a PR
(github.context as MutableContext).payload = {
Expand Down Expand Up @@ -613,4 +662,91 @@ at Tests.Registration.main(Registration.java:202)`,
// Should not create any annotations since message is empty
expect(setOutputMock).toHaveBeenCalledWith('total', 0);
});

describe('truncateFilePath', () => {
it('should return short paths unchanged', () => {
expect(main.truncateFilePath('src/file.ts')).toBe('src/file.ts');
expect(main.truncateFilePath('a/b/c/d.ts')).toBe('a/b/c/d.ts');
});

it('should truncate long paths to last 4 parts', () => {
expect(main.truncateFilePath('a/b/c/d/e/f.ts')).toBe('.../c/d/e/f.ts');
expect(
main.truncateFilePath(
'home/runner/work/repo/repo/src/modules/products/dto/product.dto.ts',
),
).toBe('.../modules/products/dto/product.dto.ts');
});
});

describe('generateAnnotationSection', () => {
it('should return empty string for no annotations', () => {
expect(
main.generateAnnotationSection('ERROR', [], 'https://example.com'),
).toBe('');
});

it('should generate section with truncated file paths', () => {
const annotations: PendingAnnotation[] = [
{
level: 'error',
message: 'Test error',
properties: {
file: 'src/modules/products/dto/product.dto.ts',
startLine: 167,
},
},
];
const baseUrl = 'https://github.com/owner/repo/pull/123/files';
const result = main.generateAnnotationSection(
'CAUTION',
annotations,
baseUrl,
);
expect(result).toContain(
'[.../modules/products/dto/product.dto.ts#L167]',
);
expect(result).toContain(
'(https://github.com/owner/repo/pull/123/files/src/modules/products/dto/product.dto.ts#L167)',
);
});

it('should escape @mentions in messages', () => {
const annotations: PendingAnnotation[] = [
{
level: 'error',
message:
'Use InputSignals (e.g. via input()) for Component input properties rather than the legacy @Input() decorator',
properties: {},
},
];
const result = main.generateAnnotationSection(
'CAUTION',
annotations,
'https://example.com',
);
expect(result).toContain(
'Use InputSignals (e.g. via input()) for Component input properties rather than the legacy `@Input`() decorator',
);
});

it('should not add duplicate escaping for messages already containing code formatting', () => {
const annotations: PendingAnnotation[] = [
{
level: 'error',
message:
'Avoid using `@Output()` decorators. Use OutputSignals (e.g. via output()) instead.',
properties: {},
},
];
const result = main.generateAnnotationSection(
'CAUTION',
annotations,
'https://example.com',
);
expect(result).toContain(
'Avoid using `@Output()` decorators. Use OutputSignals (e.g. via output()) instead.',
);
});
});
});
38 changes: 31 additions & 7 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "report-annotate",
"description": "Annotate PR from report e.g. junit",
"version": "3.1.1",
"version": "3.1.2",
"author": "",
"type": "module",
"private": true,
Expand Down
49 changes: 39 additions & 10 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export interface Config {

type AnnotationLevel = 'notice' | 'warning' | 'error' | 'ignore';

interface PendingAnnotation {
export interface PendingAnnotation {
level: AnnotationLevel;
message: string;
properties: core.AnnotationProperties;
Expand Down Expand Up @@ -229,6 +229,20 @@ async function processAnnotations(
core.warning(
`Maximum number of annotations per type reached (${maxPerType}). ${totalSkipped} annotations were not shown.`,
);
}

// If on a PR, minimize any previous bot comments
if (github.context.payload.pull_request) {
const octokit = github.getOctokit(
core.getInput('token') || process.env.GITHUB_TOKEN!,
);
const { owner, repo } = github.context.repo;
const pullNumber = github.context.payload.pull_request.number;
await minimizePreviousBotComments(octokit, owner, repo, pullNumber);
}

// Create PR comment if annotations were skipped
if (totalSkipped > 0) {
await createSkippedAnnotationsComment(
skippedErrors,
skippedWarnings,
Expand Down Expand Up @@ -263,13 +277,10 @@ async function createSkippedAnnotationsComment(
const pullNumber = github.context.payload.pull_request.number;
const baseUrl = `https://github.com/${owner}/${repo}/pull/${pullNumber}/files`;

// Minimize previous bot comments before adding a new one
await minimizePreviousBotComments(octokit, owner, repo, pullNumber);

let commentBody = '## Skipped Annotations\n\n';
commentBody += `The maximum number of annotations per type (${maxPerType}) was reached. Here are the additional annotations that were not displayed:\n\n`;

commentBody += generateAnnotationSection('ERROR', skippedErrors, baseUrl);
commentBody += generateAnnotationSection('CAUTION', skippedErrors, baseUrl);
commentBody += generateAnnotationSection('WARNING', skippedWarnings, baseUrl);
commentBody += generateAnnotationSection('NOTE', skippedNotices, baseUrl);

Expand Down Expand Up @@ -359,8 +370,17 @@ async function minimizePreviousBotComments(
}
}

/** Truncate file path to show at most 4 directories. */
export function truncateFilePath(filePath: string): string {
const parts = filePath.split('/');
if (parts.length <= 4) {
return filePath;
}
return '...' + '/' + parts.slice(-4).join('/');
}

/** Generate a comment section for a specific annotation level. */
function generateAnnotationSection(
export function generateAnnotationSection(
levelName: string,
annotations: PendingAnnotation[],
baseUrl: string,
Expand All @@ -370,12 +390,13 @@ function generateAnnotationSection(
const noteType = `[!${levelName}]`;
let section = `> ${noteType}\n`;
for (const annotation of annotations) {
const message = annotation.message.replace(/@\w+/g, '`$&`');
const message = annotation.message.replace(/(?<!`)@\w+(?!`)/g, '`$&`');
let line = `> ${message}`;
if (annotation.properties.file && annotation.properties.startLine) {
const location = `${annotation.properties.file}#L${annotation.properties.startLine}`;
const link = `${baseUrl}/${location}`;
line = `> [${location}](${link}) ${message}`;
const displayLocation = `${truncateFilePath(annotation.properties.file)}#L${annotation.properties.startLine}`;
const linkLocation = `${annotation.properties.file}#L${annotation.properties.startLine}`;
const link = `${baseUrl}/${linkLocation}`;
line = `> [${displayLocation}](${link}) ${message}`;
}
section += `${line}\n`;
}
Expand Down Expand Up @@ -509,6 +530,14 @@ async function parseXmlReport(
: undefined,
} satisfies core.AnnotationProperties;

// Make file path relative to workspace
if (properties.file) {
const workspace = process.env.GITHUB_WORKSPACE;
if (workspace && properties.file.startsWith(workspace + '/')) {
properties.file = properties.file.slice(workspace.length + 1);
}
}

// Ensure annotations have a start line for proper display
if (!properties.startLine) properties.startLine = 1;

Expand Down
Loading