From 3339e6559bcf3a9643ae06af8dcefc259ab88820 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sat, 28 Mar 2026 20:50:30 +0100 Subject: [PATCH 1/5] feat: add GitHub Action to score PRs and suggest splits --- .github/workflows/split-score.yml | 22 ++++ README.md | 51 ++++++++++ action.yml | 99 ++++++++++++++++++ scripts/score_pr.py | 163 ++++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 .github/workflows/split-score.yml create mode 100644 action.yml create mode 100644 scripts/score_pr.py diff --git a/.github/workflows/split-score.yml b/.github/workflows/split-score.yml new file mode 100644 index 0000000..d929bcc --- /dev/null +++ b/.github/workflows/split-score.yml @@ -0,0 +1,22 @@ +name: PR Split Score + +on: + pull_request: + branches: [main] + +permissions: + pull-requests: write + +jobs: + score: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: vitali87/pr-split@main + with: + max-loc: "400" + partition-strategy: "graph" + threshold-groups: "2" diff --git a/README.md b/README.md index fb02957..296573b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ ## Latest News 🔥 +- GitHub Action — add pr-split to any repo as a CI check. Scores every PR and posts a split plan comment when it's too large. No API key needed. - Smart LOC Bounds — set `--min-loc` and `--max-loc` to control sub-PR size across all three backends (LLM, graph, CP-SAT). Undersized groups get merged, oversized groups get penalised. - LLM Refinement Loop — enable `--max-refinement-iterations` and pr-split will automatically feed LOC violations back to the LLM until every group fits within your configured bounds. - Auto-derived Minimum LOC — when refinement is on and no `--min-loc` is set, pr-split picks a sensible default (25% of `--max-loc`) so you get well-sized groups out of the box. @@ -185,6 +186,56 @@ Settings can be set via environment variables with the `PR_SPLIT_` prefix: | `PR_SPLIT_PARTITION_STRATEGY` | `llm` | Hunk-to-PR partition backend | | `PR_SPLIT_WEBHOOK_URL` | (none) | Webhook URL for merge notifications | +## GitHub Action + +Add pr-split as a CI check that scores every PR and posts a split plan when it's too large. Uses the `graph` backend by default — no API key needed. + +```yaml +# .github/workflows/split-score.yml +name: PR Split Score + +on: + pull_request: + branches: [main] + +permissions: + pull-requests: write + +jobs: + score: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: vitali87/pr-split@main + with: + max-loc: "400" + partition-strategy: "graph" + threshold-groups: "2" +``` + +### Action inputs + +| Input | Default | Description | +|-------|---------|-------------| +| `max-loc` | `400` | Maximum target diff lines per sub-PR | +| `min-loc` | (unset) | Minimum target diff lines per sub-PR | +| `partition-strategy` | `graph` | Backend for partitioning (`graph` or `cp_sat`) | +| `priority` | `orthogonal` | Grouping priority (`orthogonal` or `logical`) | +| `threshold-groups` | `2` | Minimum suggested groups before posting the split plan | +| `post-comment` | `true` | Whether to post a PR comment with the results | + +### Action outputs + +| Output | Description | +|--------|-------------| +| `total-loc` | Total lines of code in the PR diff | +| `total-groups` | Number of suggested groups | +| `objective` | Plan objective score (lower is better) | +| `should-split` | Whether the PR should be split (`true`/`false`) | + ## Planning backends `pr-split` now separates two optimization layers: diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..fd80234 --- /dev/null +++ b/action.yml @@ -0,0 +1,99 @@ +name: "pr-split score" +description: "Score a PR's complexity and suggest splits when it's too large" +branding: + icon: git-pull-request + color: blue + +inputs: + max-loc: + description: "Maximum target diff lines per sub-PR" + default: "400" + min-loc: + description: "Minimum target diff lines per sub-PR" + default: "" + partition-strategy: + description: "Backend for partitioning (graph or cp_sat)" + default: "graph" + priority: + description: "Grouping priority (orthogonal or logical)" + default: "orthogonal" + threshold-groups: + description: "Minimum suggested groups before showing the split plan" + default: "2" + python-version: + description: "Python version to use" + default: "3.12" + post-comment: + description: "Whether to post a PR comment with the results" + default: "true" + +outputs: + total-loc: + description: "Total lines of code in the PR diff" + value: ${{ steps.score.outputs.total_loc }} + total-groups: + description: "Number of suggested groups" + value: ${{ steps.score.outputs.total_groups }} + objective: + description: "Plan objective score (lower is better)" + value: ${{ steps.score.outputs.objective }} + should-split: + description: "Whether the PR should be split (true/false)" + value: ${{ steps.score.outputs.should_split }} + +runs: + using: composite + steps: + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + shell: bash + run: uv python install ${{ inputs.python-version }} + + - name: Install pr-split + shell: bash + run: uv tool install pr-split + + - name: Score PR + id: score + shell: bash + env: + MAX_LOC: ${{ inputs.max-loc }} + MIN_LOC: ${{ inputs.min-loc }} + PARTITION_STRATEGY: ${{ inputs.partition-strategy }} + PRIORITY: ${{ inputs.priority }} + THRESHOLD_GROUPS: ${{ inputs.threshold-groups }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: python "${{ github.action_path }}/scripts/score_pr.py" + + - name: Post comment + if: inputs.post-comment == 'true' && steps.score.outputs.should_split == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('/tmp/pr-split-comment.md', 'utf8'); + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + }); + const existing = comments.find(c => c.body.includes('')); + 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.payload.pull_request.number, + body, + }); + } diff --git a/scripts/score_pr.py b/scripts/score_pr.py new file mode 100644 index 0000000..c183371 --- /dev/null +++ b/scripts/score_pr.py @@ -0,0 +1,163 @@ +"""Score a PR and generate a markdown comment with the split plan.""" + +from __future__ import annotations + +import os +import subprocess +import sys + + +def _run(cmd: list[str]) -> str: + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"Command failed: {' '.join(cmd)}", file=sys.stderr) + print(result.stderr, file=sys.stderr) + sys.exit(1) + return result.stdout.strip() + + +def _set_output(name: str, value: str) -> None: + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"{name}={value}\n") + + +def main() -> None: + max_loc = os.environ.get("MAX_LOC", "400") + min_loc = os.environ.get("MIN_LOC", "") + strategy = os.environ.get("PARTITION_STRATEGY", "graph") + priority = os.environ.get("PRIORITY", "orthogonal") + threshold = int(os.environ.get("THRESHOLD_GROUPS", "2")) + base_branch = os.environ["BASE_BRANCH"] + head_branch = os.environ["HEAD_BRANCH"] + + # Compute diff stats + diff_numstat = _run(["git", "diff", "--numstat", f"{base_branch}...{head_branch}"]) + + total_added = 0 + total_removed = 0 + file_count = 0 + for line in diff_numstat.splitlines(): + parts = line.split("\t") + if len(parts) >= 3: + added = int(parts[0]) if parts[0] != "-" else 0 + removed = int(parts[1]) if parts[1] != "-" else 0 + total_added += added + total_removed += removed + file_count += 1 + + total_loc = total_added + total_removed + + _set_output("total_loc", str(total_loc)) + + # If the diff is tiny, skip scoring + if total_loc <= int(max_loc): + _set_output("total_groups", "1") + _set_output("objective", "0") + _set_output("should_split", "false") + print(f"PR has {total_loc} LOC — under the {max_loc} threshold, no split needed.") + return + + # Run pr-split in dry-run mode with the graph/cp_sat backend + cmd = [ + "pr-split", "split", head_branch, + "--base", base_branch, + "--partition-strategy", strategy, + "--priority", priority, + "--max-loc", max_loc, + "--dry-run", + ] + if min_loc: + cmd.extend(["--min-loc", min_loc]) + + # pr-split writes the plan to .pr-split/plan.json + result = subprocess.run(cmd, capture_output=True, text=True, input="done\n") + if result.returncode != 0: + print(f"pr-split failed:\n{result.stderr}", file=sys.stderr) + _set_output("total_groups", "1") + _set_output("objective", "0") + _set_output("should_split", "false") + return + + # Parse the saved plan + import json + + plan_path = ".pr-split/plan.json" + if not os.path.exists(plan_path): + print("No plan file generated", file=sys.stderr) + _set_output("total_groups", "1") + _set_output("objective", "0") + _set_output("should_split", "false") + return + + with open(plan_path) as f: + plan = json.load(f) + + groups = plan.get("groups", []) + total_groups = len(groups) + + # Compute a simple score + max_group_loc = max((g["estimated_loc"] for g in groups), default=0) + overflow = sum(max(0, g["estimated_loc"] - int(max_loc)) for g in groups) + file_groups: dict[str, set[str]] = {} + for g in groups: + for a in g.get("assignments", []): + file_groups.setdefault(a["file_path"], set()).add(g["id"]) + file_scatter = sum(max(0, len(gids) - 1) for gids in file_groups.values()) + + objective = overflow * 1000 + file_scatter * 50 + total_groups + should_split = total_groups >= threshold + + _set_output("total_groups", str(total_groups)) + _set_output("objective", str(objective)) + _set_output("should_split", str(should_split).lower()) + + print(f"PR: {total_loc} LOC across {file_count} files") + print(f"Split plan: {total_groups} groups, objective={objective}") + print(f"Should split: {should_split}") + + # Generate markdown comment + lines = [ + "", + "## pr-split analysis", + "", + "| Metric | Value |", + "|--------|-------|", + f"| Total LOC | {total_loc:,} |", + f"| Files changed | {file_count} |", + f"| Suggested groups | {total_groups} |", + f"| Largest group | {max_group_loc:,} LOC |", + f"| LOC overflow | {overflow:,} |", + f"| File scatter | {file_scatter} |", + "", + ] + + if should_split: + lines.append( + f"This PR has **{total_loc:,} LOC** and could be split into " + f"**{total_groups} smaller PRs**:" + ) + lines.append("") + lines.append("| Group | Title | Diff | Depends On | Files |") + lines.append("|-------|-------|------|------------|-------|") + for g in groups: + files = ", ".join( + f"`{a['file_path']}`" for a in g.get("assignments", []) + ) + deps = ", ".join(g.get("depends_on", [])) or "—" + diff_str = f"+{g.get('estimated_added', 0)}/-{g.get('estimated_removed', 0)}" + lines.append(f"| {g['id']} | {g['title']} | {diff_str} | {deps} | {files} |") + lines.append("") + lines.append( + "*Run `pr-split split` locally to create these sub-PRs, " + "or adjust `--max-loc` to change the target size.*" + ) + else: + lines.append("This PR is within acceptable size limits.") + + comment = "\n".join(lines) + with open("/tmp/pr-split-comment.md", "w") as f: + f.write(comment) + + +if __name__ == "__main__": + main() From e170daaabd9331561612e790e8fe4c1e8daf7d8e Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sat, 28 Mar 2026 20:54:04 +0100 Subject: [PATCH 2/5] fix: use local action reference for in-repo workflow --- .github/workflows/split-score.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/split-score.yml b/.github/workflows/split-score.yml index d929bcc..ceb22cf 100644 --- a/.github/workflows/split-score.yml +++ b/.github/workflows/split-score.yml @@ -15,7 +15,7 @@ jobs: with: fetch-depth: 0 - - uses: vitali87/pr-split@main + - uses: ./ with: max-loc: "400" partition-strategy: "graph" From 2fa3fbcf2ecd10bef6d365e0075208a370c3b5b3 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sat, 28 Mar 2026 20:56:07 +0100 Subject: [PATCH 3/5] fix: install pr-split from local checkout instead of PyPI --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index fd80234..ca6e4bb 100644 --- a/action.yml +++ b/action.yml @@ -53,7 +53,7 @@ runs: - name: Install pr-split shell: bash - run: uv tool install pr-split + run: uv tool install "${{ github.action_path }}" - name: Score PR id: score From 59970db63bdd93ce1faa4792ec7e4461ff264e72 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sat, 28 Mar 2026 20:57:41 +0100 Subject: [PATCH 4/5] fix: create local branch refs for GitHub Actions checkout compatibility --- scripts/score_pr.py | 65 ++++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/scripts/score_pr.py b/scripts/score_pr.py index c183371..d3505c4 100644 --- a/scripts/score_pr.py +++ b/scripts/score_pr.py @@ -2,18 +2,19 @@ from __future__ import annotations +import json import os import subprocess import sys -def _run(cmd: list[str]) -> str: +def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: + if check and result.returncode != 0: print(f"Command failed: {' '.join(cmd)}", file=sys.stderr) print(result.stderr, file=sys.stderr) sys.exit(1) - return result.stdout.strip() + return result def _set_output(name: str, value: str) -> None: @@ -21,6 +22,13 @@ def _set_output(name: str, value: str) -> None: f.write(f"{name}={value}\n") +def _skip(reason: str) -> None: + print(reason) + _set_output("total_groups", "1") + _set_output("objective", "0") + _set_output("should_split", "false") + + def main() -> None: max_loc = os.environ.get("MAX_LOC", "400") min_loc = os.environ.get("MIN_LOC", "") @@ -30,13 +38,21 @@ def main() -> None: base_branch = os.environ["BASE_BRANCH"] head_branch = os.environ["HEAD_BRANCH"] + # GitHub Actions pull_request checkout is a merge commit. + # Create local branch refs so pr-split can resolve them. + _run(["git", "fetch", "origin", base_branch, head_branch]) + _run(["git", "branch", "-f", base_branch, f"origin/{base_branch}"]) + _run(["git", "branch", "-f", head_branch, f"origin/{head_branch}"]) + # Compute diff stats - diff_numstat = _run(["git", "diff", "--numstat", f"{base_branch}...{head_branch}"]) + result = _run( + ["git", "diff", "--numstat", f"{base_branch}...{head_branch}"] + ) total_added = 0 total_removed = 0 file_count = 0 - for line in diff_numstat.splitlines(): + for line in result.stdout.strip().splitlines(): parts = line.split("\t") if len(parts) >= 3: added = int(parts[0]) if parts[0] != "-" else 0 @@ -46,18 +62,13 @@ def main() -> None: file_count += 1 total_loc = total_added + total_removed - _set_output("total_loc", str(total_loc)) - # If the diff is tiny, skip scoring if total_loc <= int(max_loc): - _set_output("total_groups", "1") - _set_output("objective", "0") - _set_output("should_split", "false") - print(f"PR has {total_loc} LOC — under the {max_loc} threshold, no split needed.") + _skip(f"PR has {total_loc} LOC — under the {max_loc} threshold, no split needed.") return - # Run pr-split in dry-run mode with the graph/cp_sat backend + # Run pr-split in dry-run mode cmd = [ "pr-split", "split", head_branch, "--base", base_branch, @@ -69,24 +80,21 @@ def main() -> None: if min_loc: cmd.extend(["--min-loc", min_loc]) - # pr-split writes the plan to .pr-split/plan.json - result = subprocess.run(cmd, capture_output=True, text=True, input="done\n") + result = _run(cmd, check=False) + # Feed "done" to the interactive editor via stdin + if result.returncode != 0: + # Retry with stdin to handle interactive editor prompt + result = subprocess.run( + cmd, capture_output=True, text=True, input="done\n" + ) if result.returncode != 0: print(f"pr-split failed:\n{result.stderr}", file=sys.stderr) - _set_output("total_groups", "1") - _set_output("objective", "0") - _set_output("should_split", "false") + _skip("pr-split failed to generate a plan.") return - # Parse the saved plan - import json - plan_path = ".pr-split/plan.json" if not os.path.exists(plan_path): - print("No plan file generated", file=sys.stderr) - _set_output("total_groups", "1") - _set_output("objective", "0") - _set_output("should_split", "false") + _skip("No plan file generated.") return with open(plan_path) as f: @@ -95,7 +103,6 @@ def main() -> None: groups = plan.get("groups", []) total_groups = len(groups) - # Compute a simple score max_group_loc = max((g["estimated_loc"] for g in groups), default=0) overflow = sum(max(0, g["estimated_loc"] - int(max_loc)) for g in groups) file_groups: dict[str, set[str]] = {} @@ -144,8 +151,12 @@ def main() -> None: f"`{a['file_path']}`" for a in g.get("assignments", []) ) deps = ", ".join(g.get("depends_on", [])) or "—" - diff_str = f"+{g.get('estimated_added', 0)}/-{g.get('estimated_removed', 0)}" - lines.append(f"| {g['id']} | {g['title']} | {diff_str} | {deps} | {files} |") + diff_str = ( + f"+{g.get('estimated_added', 0)}/-{g.get('estimated_removed', 0)}" + ) + lines.append( + f"| {g['id']} | {g['title']} | {diff_str} | {deps} | {files} |" + ) lines.append("") lines.append( "*Run `pr-split split` locally to create these sub-PRs, " From ee033988e5024345fd4008eda974e499f77a3e20 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sat, 28 Mar 2026 21:03:24 +0100 Subject: [PATCH 5/5] fix: prevent shell injection, support fork PRs, and use portable temp paths --- README.md | 1 + action.yml | 6 ++-- scripts/score_pr.py | 77 ++++++++++++++++++++++++++++----------------- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 296573b..567686e 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,7 @@ jobs: | `partition-strategy` | `graph` | Backend for partitioning (`graph` or `cp_sat`) | | `priority` | `orthogonal` | Grouping priority (`orthogonal` or `logical`) | | `threshold-groups` | `2` | Minimum suggested groups before posting the split plan | +| `python-version` | `3.12` | Python version to use | | `post-comment` | `true` | Whether to post a PR comment with the results | ### Action outputs diff --git a/action.yml b/action.yml index ca6e4bb..dc53a20 100644 --- a/action.yml +++ b/action.yml @@ -49,7 +49,9 @@ runs: - name: Set up Python shell: bash - run: uv python install ${{ inputs.python-version }} + env: + UV_PYTHON_VERSION: ${{ inputs.python-version }} + run: uv python install "$UV_PYTHON_VERSION" - name: Install pr-split shell: bash @@ -75,7 +77,7 @@ runs: with: script: | const fs = require('fs'); - const body = fs.readFileSync('/tmp/pr-split-comment.md', 'utf8'); + const body = fs.readFileSync('${{ steps.score.outputs.comment_path }}', 'utf8'); const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/scripts/score_pr.py b/scripts/score_pr.py index d3505c4..f5bf36b 100644 --- a/scripts/score_pr.py +++ b/scripts/score_pr.py @@ -6,6 +6,8 @@ import os import subprocess import sys +import tempfile +from pathlib import Path def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: @@ -29,25 +31,41 @@ def _skip(reason: str) -> None: _set_output("should_split", "false") +def _md_escape(s: str) -> str: + return s.replace("|", "\\|") + + +def _parse_int_env(name: str, default: int) -> int: + raw = os.environ.get(name, str(default)) + try: + return int(raw) + except ValueError: + print(f"Error: {name} must be an integer, got '{raw}'.", file=sys.stderr) + sys.exit(1) + + def main() -> None: - max_loc = os.environ.get("MAX_LOC", "400") - min_loc = os.environ.get("MIN_LOC", "") + max_loc = _parse_int_env("MAX_LOC", 400) + min_loc_raw = os.environ.get("MIN_LOC", "") strategy = os.environ.get("PARTITION_STRATEGY", "graph") priority = os.environ.get("PRIORITY", "orthogonal") - threshold = int(os.environ.get("THRESHOLD_GROUPS", "2")) + threshold = _parse_int_env("THRESHOLD_GROUPS", 2) + pr_number = os.environ.get("PR_NUMBER", "") base_branch = os.environ["BASE_BRANCH"] head_branch = os.environ["HEAD_BRANCH"] - # GitHub Actions pull_request checkout is a merge commit. - # Create local branch refs so pr-split can resolve them. - _run(["git", "fetch", "origin", base_branch, head_branch]) - _run(["git", "branch", "-f", base_branch, f"origin/{base_branch}"]) - _run(["git", "branch", "-f", head_branch, f"origin/{head_branch}"]) + # Fetch refs — use refs/pull/{n}/head for fork compatibility + _run(["git", "fetch", "origin", base_branch]) + if pr_number: + pr_ref = f"refs/pull/{pr_number}/head" + local_head = f"pr-split/head-{pr_number}" + _run(["git", "fetch", "origin", f"{pr_ref}:{local_head}"]) + else: + _run(["git", "fetch", "origin", head_branch]) + local_head = f"origin/{head_branch}" # Compute diff stats - result = _run( - ["git", "diff", "--numstat", f"{base_branch}...{head_branch}"] - ) + result = _run(["git", "diff", "--numstat", f"origin/{base_branch}...{local_head}"]) total_added = 0 total_removed = 0 @@ -64,29 +82,27 @@ def main() -> None: total_loc = total_added + total_removed _set_output("total_loc", str(total_loc)) - if total_loc <= int(max_loc): + if total_loc <= max_loc: _skip(f"PR has {total_loc} LOC — under the {max_loc} threshold, no split needed.") return + # Create local branch refs for pr-split + _run(["git", "branch", "-f", base_branch, f"origin/{base_branch}"]) + _run(["git", "branch", "-f", head_branch, local_head]) + # Run pr-split in dry-run mode cmd = [ "pr-split", "split", head_branch, "--base", base_branch, "--partition-strategy", strategy, "--priority", priority, - "--max-loc", max_loc, + "--max-loc", str(max_loc), "--dry-run", ] - if min_loc: - cmd.extend(["--min-loc", min_loc]) + if min_loc_raw: + cmd.extend(["--min-loc", min_loc_raw]) - result = _run(cmd, check=False) - # Feed "done" to the interactive editor via stdin - if result.returncode != 0: - # Retry with stdin to handle interactive editor prompt - result = subprocess.run( - cmd, capture_output=True, text=True, input="done\n" - ) + result = subprocess.run(cmd, capture_output=True, text=True, input="done\n") if result.returncode != 0: print(f"pr-split failed:\n{result.stderr}", file=sys.stderr) _skip("pr-split failed to generate a plan.") @@ -104,7 +120,7 @@ def main() -> None: total_groups = len(groups) max_group_loc = max((g["estimated_loc"] for g in groups), default=0) - overflow = sum(max(0, g["estimated_loc"] - int(max_loc)) for g in groups) + overflow = sum(max(0, g["estimated_loc"] - max_loc) for g in groups) file_groups: dict[str, set[str]] = {} for g in groups: for a in g.get("assignments", []): @@ -148,15 +164,16 @@ def main() -> None: lines.append("|-------|-------|------|------------|-------|") for g in groups: files = ", ".join( - f"`{a['file_path']}`" for a in g.get("assignments", []) + f"`{_md_escape(a['file_path'])}`" + for a in g.get("assignments", []) ) deps = ", ".join(g.get("depends_on", [])) or "—" diff_str = ( f"+{g.get('estimated_added', 0)}/-{g.get('estimated_removed', 0)}" ) - lines.append( - f"| {g['id']} | {g['title']} | {diff_str} | {deps} | {files} |" - ) + title = _md_escape(g["title"]) + gid = _md_escape(g["id"]) + lines.append(f"| {gid} | {title} | {diff_str} | {deps} | {files} |") lines.append("") lines.append( "*Run `pr-split split` locally to create these sub-PRs, " @@ -166,8 +183,10 @@ def main() -> None: lines.append("This PR is within acceptable size limits.") comment = "\n".join(lines) - with open("/tmp/pr-split-comment.md", "w") as f: - f.write(comment) + tmp_dir = os.environ.get("RUNNER_TEMP", tempfile.gettempdir()) + comment_path = Path(tmp_dir) / "pr-split-comment.md" + comment_path.write_text(comment) + _set_output("comment_path", str(comment_path)) if __name__ == "__main__":