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
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,68 @@ The following parameters can be used for additional control over when it is safe
- `override_filter_paths`: These are the file paths that, if out of date on a PR, will prevent merge no matter what files the PR is changing
- example: `override_filter_paths: package.json,package-lock.json`
- `override_filter_globs`: These are glob patterns for `override_filter_paths`
- `match_comment_paths`: When set to `'true'`, enables comment path matching. This reads paths from a specially formatted PR comment and checks if the branch is behind on any of those paths. This is useful when a PR's scope extends beyond the files it directly modifies (e.g., due to transitive dependencies in a monorepo triggering tests in other packages).

#### Comment Path Matching

When `match_comment_paths` is enabled, the helper looks for a PR comment containing the marker `<!-- check-merge-safety-paths -->` followed by a JSON array of paths in a fenced code block:

````markdown
<!-- check-merge-safety-paths -->

```json
["path/to/package1", "path/to/package2"]
```
````

````

This is useful for monorepos with selective testing, where changing one package (e.g., a shared data model) triggers tests for dependent packages. Without this feature, merging could introduce bugs if the dependent packages were updated on main after the PR's tests ran.

Example workflow integration:

```yaml
# After determining affected paths, post them as a comment
- name: Post affected paths
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- check-merge-safety-paths -->';
const paths = ${{ steps.get-affected-paths.outputs.paths }};
const body = `${marker}\n\`\`\`json\n${JSON.stringify(paths)}\n\`\`\``;

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const existing = comments.find(c => c.body.includes(marker));

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
}

# Then run check-merge-safety with comment path matching enabled
- uses: ExpediaGroup/github-helpers@v1
with:
helper: check-merge-safety
paths: |
packages/package-1
packages/package-2
match_comment_paths: 'true'
````

### [close-pr](.github/workflows/close-pr.yml)

Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ inputs:
description: 'The commit message to use'
required: false
default: 'Automated PR creation'
match_comment_paths:
description: 'Enable comment path matching for check-merge-safety. When enabled, reads paths from a specially formatted PR comment to determine if the branch needs rebasing.'
required: false
outputs:
output:
description: 'The output of the helper'
Expand Down
50 changes: 47 additions & 3 deletions dist/helpers/check-merge-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import"../main-9m3k9gt0.js";
import {
error,
info,
setFailed
setFailed,
warning
} from "../main-q70tmm6g.js";
import {
__toESM
Expand All @@ -38,6 +39,7 @@ var import_micromatch = __toESM(require_micromatch(), 1);
var import_bluebird = __toESM(require_bluebird(), 1);
var git = simpleGit();
var maxBranchNameLength = 50;
var COMMENT_PATHS_MARKER = "<!-- check-merge-safety-paths -->";

class CheckMergeSafety extends HelperInputs {
}
Expand Down Expand Up @@ -140,7 +142,7 @@ var getDiff = async (compareBase, compareHead, basehead) => {
}
return changedFileNames;
};
var getMergeSafetyStateAndMessage = async (pullRequest, { paths, ignore_globs, override_filter_paths, override_filter_globs }) => {
var getMergeSafetyStateAndMessage = async (pullRequest, { paths, ignore_globs, override_filter_paths, override_filter_globs, match_comment_paths }) => {
const {
base: {
repo: {
Expand All @@ -165,6 +167,24 @@ var getMergeSafetyStateAndMessage = async (pullRequest, { paths, ignore_globs, o
}
const truncatedRef = ref.length > maxBranchNameLength ? `${ref.substring(0, maxBranchNameLength)}...` : ref;
const truncatedBranchName = `${username}:${truncatedRef}`;
if (match_comment_paths === "true") {
const commentPaths = await getPathsFromComment(pullRequest.number);
if (commentPaths.length) {
info(`Found ${commentPaths.length} paths from PR comment`);
const outdatedCommentPaths = commentPaths.filter((commentPath) => fileNamesWhichBranchIsBehindOn.some((file) => file.startsWith(commentPath + "/") || file === commentPath));
if (outdatedCommentPaths.length) {
error(buildErrorMessage(outdatedCommentPaths, "comment paths", truncatedBranchName));
const displayPaths = outdatedCommentPaths.slice(0, 3).join(", ");
const suffix = outdatedCommentPaths.length > 3 ? "..." : "";
return {
state: "failure",
message: `Branch is behind on paths from comment: ${displayPaths}${suffix}. Please update with ${default_branch}.`
};
}
} else {
info("No paths found in PR comment, skipping comment path matching check");
}
}
const globalFilesOutdatedOnBranch = override_filter_globs ? import_micromatch.default(fileNamesWhichBranchIsBehindOn, override_filter_globs.split(/[\n,]/)) : override_filter_paths ? fileNamesWhichBranchIsBehindOn.filter((changedFile) => override_filter_paths.split(/[\n,]/).includes(changedFile)) : [];
if (globalFilesOutdatedOnBranch.length) {
error(buildErrorMessage(globalFilesOutdatedOnBranch, "global files", truncatedBranchName));
Expand Down Expand Up @@ -209,9 +229,33 @@ ${paths.map((path) => `* ${path}`).join(`
var diffErrorMessage = (basehead, message = "") => `Failed to generate diff for ${basehead}. Please verify SHAs are valid and try again.${message ? `
Error: ${message}` : ""}`;
var buildSuccessMessage = (branchName) => `Branch ${branchName} is safe to merge!`;
var getPathsFromComment = async (pullNumber) => {
const { data: comments } = await octokit.issues.listComments({
...context.repo,
issue_number: pullNumber
});
const pathsComment = comments.find((c) => c.body?.includes(COMMENT_PATHS_MARKER));
if (!pathsComment?.body) {
return [];
}
const jsonMatch = pathsComment.body.match(/```json\n([\s\S]*?)\n```/);
if (!jsonMatch?.[1]) {
return [];
}
try {
const parsed = JSON.parse(jsonMatch[1]);
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
return parsed;
}
return [];
} catch {
warning(`Failed to parse paths from PR #${pullNumber} comment`);
return [];
}
};
export {
checkMergeSafety,
CheckMergeSafety
};

//# debugId=852D8F69D0103B1E64756E2164756E21
//# debugId=8CCB3211353B88AF64756E2164756E21
6 changes: 3 additions & 3 deletions dist/helpers/check-merge-safety.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/main-8h70j5cy.js.map

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

60 changes: 58 additions & 2 deletions src/helpers/check-merge-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ import * as core from '@actions/core';
const git = simpleGit();

const maxBranchNameLength = 50;
const COMMENT_PATHS_MARKER = '<!-- check-merge-safety-paths -->';

export class CheckMergeSafety extends HelperInputs {
declare context?: string;
declare paths?: string;
declare ignore_globs?: string;
declare override_filter_paths?: string;
declare override_filter_globs?: string;
declare match_comment_paths?: string;
}

export const checkMergeSafety = async (inputs: CheckMergeSafety) => {
Expand Down Expand Up @@ -147,7 +150,7 @@ const getDiff = async (compareBase: DiffRefs, compareHead: DiffRefs, basehead: s

const getMergeSafetyStateAndMessage = async (
pullRequest: PullRequest,
{ paths, ignore_globs, override_filter_paths, override_filter_globs }: CheckMergeSafety
{ paths, ignore_globs, override_filter_paths, override_filter_globs, match_comment_paths }: CheckMergeSafety
) => {
const {
base: {
Expand Down Expand Up @@ -175,6 +178,31 @@ const getMergeSafetyStateAndMessage = async (

const truncatedRef = ref.length > maxBranchNameLength ? `${ref.substring(0, maxBranchNameLength)}...` : ref;
const truncatedBranchName = `${username}:${truncatedRef}`;

if (match_comment_paths === 'true') {
const commentPaths = await getPathsFromComment(pullRequest.number);

if (commentPaths.length) {
core.info(`Found ${commentPaths.length} paths from PR comment`);

const outdatedCommentPaths = commentPaths.filter(commentPath =>
fileNamesWhichBranchIsBehindOn.some(file => file.startsWith(commentPath + '/') || file === commentPath)
);

if (outdatedCommentPaths.length) {
core.error(buildErrorMessage(outdatedCommentPaths, 'comment paths', truncatedBranchName));
const displayPaths = outdatedCommentPaths.slice(0, 3).join(', ');
const suffix = outdatedCommentPaths.length > 3 ? '...' : '';
return {
state: 'failure',
message: `Branch is behind on paths from comment: ${displayPaths}${suffix}. Please update with ${default_branch}.`
} as const;
}
} else {
core.info('No paths found in PR comment, skipping comment path matching check');
}
}

const globalFilesOutdatedOnBranch = override_filter_globs
? micromatch(fileNamesWhichBranchIsBehindOn, override_filter_globs.split(/[\n,]/))
: override_filter_paths
Expand Down Expand Up @@ -223,7 +251,7 @@ const getMergeSafetyStateAndMessage = async (
} as const;
};

const buildErrorMessage = (paths: string[], pathType: 'projects' | 'global files', branchName: string) =>
const buildErrorMessage = (paths: string[], pathType: 'projects' | 'global files' | 'comment paths', branchName: string) =>
`
The following ${pathType} are outdated on branch ${branchName}

Expand All @@ -234,3 +262,31 @@ const diffErrorMessage = (basehead: string, message = '') =>
`Failed to generate diff for ${basehead}. Please verify SHAs are valid and try again.${message ? `\nError: ${message}` : ''}`;

const buildSuccessMessage = (branchName: string) => `Branch ${branchName} is safe to merge!`;

const getPathsFromComment = async (pullNumber: number): Promise<string[]> => {
const { data: comments } = await octokit.issues.listComments({
...githubContext.repo,
issue_number: pullNumber
});

const pathsComment = comments.find(c => c.body?.includes(COMMENT_PATHS_MARKER));
if (!pathsComment?.body) {
return [];
}

const jsonMatch = pathsComment.body.match(/```json\n([\s\S]*?)\n```/);
if (!jsonMatch?.[1]) {
return [];
}

try {
const parsed: unknown = JSON.parse(jsonMatch[1]);
if (Array.isArray(parsed) && parsed.every(item => typeof item === 'string')) {
return parsed;
}
return [];
} catch {
core.warning(`Failed to parse paths from PR #${pullNumber} comment`);
return [];
}
};
1 change: 1 addition & 0 deletions src/types/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,5 @@ export class HelperInputs {
declare packages?: string;
declare branch_name?: string;
declare commit_message?: string;
declare match_comment_paths?: string;
}
Loading
Loading