Skip to content

Fix pre-commit hook Bash 3.2 support and partial-staging swallow #2229

Fix pre-commit hook Bash 3.2 support and partial-staging swallow

Fix pre-commit hook Bash 3.2 support and partial-staging swallow #2229

Workflow file for this run

name: pr-governance

Check warning on line 1 in .github/workflows/pr_governance.yml

View workflow run for this annotation

GitHub Actions / pr-governance

Workflow execution policy warning (evaluate mode)

On November 2, 2026, GitHub will restrict `pull_request_target` on public repositories by default. To continue allowing the event trigger, configure an Actions policy. Learn more: https://gh.io/securely-using-pull_request_target#default-policy-for-pull_request_target
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
- labeled
- unlabeled
permissions:
contents: read
pull-requests: read
issues: read
jobs:
policy:
name: policy
runs-on: ubuntu-latest
steps:
- name: Validate PR governance policy
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const owner = context.repo.owner;
const repo = context.repo.repo;
const body = pr.body || "";
const title = (pr.title || "").trim();
const errors = [];
const warnings = [];
const issueRefRegex =
/(?:^|[\s(])(?:(?<repo>[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+))?#(?<number>\d+)\b/g;
function uniqueById(items) {
const map = new Map();
for (const item of items) {
map.set(item.id ?? `${item.repository?.nameWithOwner || ""}#${item.number}`, item);
}
return [...map.values()];
}
function extractIssueReferencesFromBody(text) {
const refs = [];
for (const match of text.matchAll(issueRefRegex)) {
const repoName = match.groups?.repo || `${owner}/${repo}`;
const number = Number(match.groups?.number);
refs.push({
repository: { nameWithOwner: repoName },
number,
});
}
return refs;
}
async function filterOutPullRequestsFromBodyReferences(refs) {
const validated = [];
for (const ref of refs) {
try {
const [refOwner, refRepo] = ref.repository.nameWithOwner.split("/");
const { data } = await github.rest.issues.get({
owner: refOwner,
repo: refRepo,
issue_number: ref.number,
});
// GitHub returns a pull_request field when the number belongs to a PR.
if (!data.pull_request) {
validated.push({
id: data.node_id,
number: data.number,
repository: { nameWithOwner: ref.repository.nameWithOwner },
});
}
} catch (e) {
warnings.push(
`Could not validate whether ${ref.repository.nameWithOwner}#${ref.number} is an issue or a pull request.`
);
}
}
return validated;
}
async function getSameRepoManuallyLinkedIssues(prNodeId) {
// Best-effort same-repository manual-link detection.
// Scans issue timeline ConnectedEvent entries and looks for this PR as the source.
const found = new Map();
let issuesCursor = null;
let issuesHasNextPage = true;
let scannedPages = 0;
const maxIssuePages = 10; // safety cap
while (issuesHasNextPage && scannedPages < maxIssuePages) {
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
issues(
first: 100,
after: $cursor,
states: [OPEN, CLOSED],
orderBy: {field: UPDATED_AT, direction: DESC}
) {
nodes {
number
timelineItems(first: 20, itemTypes: [CONNECTED_EVENT]) {
nodes {
__typename
... on ConnectedEvent {
source {
__typename
... on PullRequest {
id
number
}
}
subject {
__typename
... on Issue {
id
number
}
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
const res = await github.graphql(query, {
owner,
repo,
cursor: issuesCursor,
});
const issuesConn = res.repository?.issues;
if (!issuesConn) break;
for (const issue of issuesConn.nodes || []) {
for (const item of issue.timelineItems?.nodes || []) {
if (
item?.__typename === "ConnectedEvent" &&
item?.source?.__typename === "PullRequest" &&
item?.source?.id === prNodeId &&
item?.subject?.__typename === "Issue"
) {
found.set(item.subject.id, {
id: item.subject.id,
number: item.subject.number,
repository: { nameWithOwner: `${owner}/${repo}` },
});
}
}
}
issuesHasNextPage = issuesConn.pageInfo?.hasNextPage || false;
issuesCursor = issuesConn.pageInfo?.endCursor || null;
scannedPages += 1;
}
if (issuesHasNextPage) {
warnings.push(
"Manual-link scan hit the page cap before exhausting all issues. Increase maxIssuePages if needed."
);
}
return [...found.values()];
}
// 1) Detect linked issues
// Policy passes if either:
// - the PR body references at least one issue
// - a same-repository manual sidebar link is detected
const rawBodyReferences = extractIssueReferencesFromBody(body);
const issuesMentionedInBody = await filterOutPullRequestsFromBodyReferences(rawBodyReferences);
const manualLinkedIssues = await getSameRepoManuallyLinkedIssues(pr.node_id);
const linkedIssues = uniqueById([
...issuesMentionedInBody,
...manualLinkedIssues,
]);
if (linkedIssues.length === 0) {
errors.push(
"PR must reference at least one issue in the description or be linked to one issue through the sidebar."
);
}
// 2) Review status
// Keep this as a warning. Use branch protection / rulesets for hard enforcement.
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
const approvedReviews = reviews.filter(r => r.state === "APPROVED");
if (approvedReviews.length < 1) {
warnings.push(
"No APPROVED review was detected. For hard enforcement, enable required reviews in branch protection or rulesets."
);
}
// 3) Helpful diagnostics
if (issuesMentionedInBody.length > 0) {
const crossRepoBodyRefs = issuesMentionedInBody.filter(
issue => issue.repository?.nameWithOwner !== `${owner}/${repo}`
);
if (crossRepoBodyRefs.length > 0) {
warnings.push(
"Cross-repository issue references were found in the PR description. Manual sidebar-link validation only covers the current repository."
);
}
}
if (manualLinkedIssues.length > 0) {
warnings.push(
"Manual sidebar links were detected only within the current repository. Cross-repository manual links are not fully validated by this workflow."
);
}
const summary = [];
summary.push("## PR Governance Report");
summary.push(`- PR #${pr.number}`);
summary.push(`- Title: ${title}`);
summary.push(`- Issues referenced in PR body: ${issuesMentionedInBody.length}`);
summary.push(`- Same-repository manual-linked issues detected: ${manualLinkedIssues.length}`);
summary.push(`- Total linked issues accepted by policy: ${linkedIssues.length}`);
summary.push(`- Files changed: ${pr.changed_files}`);
summary.push(`- Additions: ${pr.additions}`);
summary.push(`- Deletions: ${pr.deletions}`);
summary.push("");
if (warnings.length) {
summary.push("### Warnings");
for (const w of warnings) summary.push(`- ${w}`);
summary.push("");
}
if (errors.length) {
summary.push("### Errors");
for (const e of errors) summary.push(`- ${e}`);
await core.summary.addRaw(summary.join("\n")).write();
core.setFailed(errors.join(" | "));
return;
}
summary.push("### Result");
summary.push("- All required governance checks passed.");
await core.summary.addRaw(summary.join("\n")).write();