Skip to content

Commit 4721122

Browse files
feat: add automation pieces (#629)
1 parent 5d90662 commit 4721122

File tree

10 files changed

+571
-0
lines changed

10 files changed

+571
-0
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
name: Update PR Description with Rigging
3+
on:
4+
pull_request:
5+
types: [opened]
6+
7+
jobs:
8+
update-description:
9+
name: Update PR Description with Rigging
10+
runs-on: ubuntu-latest
11+
permissions:
12+
pull-requests: write
13+
contents: read
14+
15+
steps:
16+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
17+
with:
18+
fetch-depth: 0 # full history for proper diffing
19+
20+
- name: Set up Python
21+
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
22+
with:
23+
python-version: "3.13"
24+
25+
- name: Install uv
26+
run: |
27+
python -m pip install --upgrade pip
28+
pip install uv
29+
30+
- name: Generate PR Description
31+
id: description
32+
env:
33+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
34+
run: |
35+
DESCRIPTION="$(uv run --no-project .hooks/generate_pr_description.py --base-ref "origin/${{ github.base_ref }}" --exclude "./*.lock")"
36+
{
37+
echo "description<<EOF"
38+
echo "${DESCRIPTION}"
39+
echo "EOF"
40+
} >> "$GITHUB_OUTPUT"
41+
42+
- name: Update PR Description
43+
uses: nefrob/pr-description@4dcc9f3ad5ec06b2a197c5f8f93db5e69d2fdca7 # v1.2.0
44+
with:
45+
token: ${{ secrets.GITHUB_TOKEN }}
46+
content: |
47+
48+
---
49+
50+
## Generated Summary:
51+
52+
${{ steps.description.outputs.description }}
53+
54+
This summary was generated with ❤️ by [rigging](https://rigging.dreadnode.io/)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env python
2+
import re
3+
import sys
4+
from pathlib import Path
5+
from typing import List, Tuple
6+
7+
8+
class GitHubActionChecker:
9+
def __init__(self):
10+
# Pattern for actions with SHA-1 hashes (pinned)
11+
self.pinned_pattern = re.compile(r"uses:\s+([^@\s]+)@([a-f0-9]{40})")
12+
13+
# Pattern for actions with version tags (unpinned)
14+
self.unpinned_pattern = re.compile(
15+
r"uses:\s+([^@\s]+)@(v\d+(?:\.\d+)*(?:-[a-zA-Z0-9]+(?:\.\d+)*)?)"
16+
)
17+
18+
# Pattern for all uses statements
19+
self.all_uses_pattern = re.compile(r"uses:\s+([^@\s]+)@([^\s\n]+)")
20+
21+
def format_terminal_link(self, file_path: str, line_number: int) -> str:
22+
"""Format a terminal link to a file and line number.
23+
24+
Args:
25+
file_path: Path to the file
26+
line_number: Line number in the file
27+
28+
Returns:
29+
str: Formatted string with file path and line number
30+
"""
31+
return f"{file_path}:{line_number}"
32+
33+
def get_line_numbers(self, content: str, pattern: re.Pattern) -> List[Tuple[str, int]]:
34+
"""Find matches with their line numbers."""
35+
matches = []
36+
for i, line in enumerate(content.splitlines(), 1):
37+
for match in pattern.finditer(line):
38+
matches.append((match.group(0), i))
39+
return matches
40+
41+
def check_file(self, file_path: str) -> bool:
42+
"""Check a single file for unpinned dependencies."""
43+
try:
44+
content = Path(file_path).read_text()
45+
except Exception as e:
46+
print(f"\033[91mError reading file {file_path}: {e}\033[0m")
47+
return False
48+
49+
# Get matches with line numbers
50+
pinned_matches = self.get_line_numbers(content, self.pinned_pattern)
51+
unpinned_matches = self.get_line_numbers(content, self.unpinned_pattern)
52+
all_matches = self.get_line_numbers(content, self.all_uses_pattern)
53+
54+
print(f"\n\033[1m[=] Checking file: {file_path}\033[0m")
55+
56+
# Print pinned dependencies
57+
if pinned_matches:
58+
print("\033[92m[+] Pinned:\033[0m")
59+
for match, line_num in pinned_matches:
60+
print(f" |- {match} \033[90m({file_path}:{line_num})\033[0m")
61+
62+
# Track all found actions for validation
63+
found_actions = set()
64+
for match, _ in pinned_matches + unpinned_matches:
65+
action_name = self.pinned_pattern.match(match) or self.unpinned_pattern.match(match)
66+
if action_name:
67+
found_actions.add(action_name.group(1))
68+
69+
has_errors = False
70+
71+
# Check for unpinned dependencies
72+
if unpinned_matches:
73+
has_errors = True
74+
print("\033[93m[!] Unpinned (using version tags):\033[0m")
75+
for match, line_num in unpinned_matches:
76+
print(f" |- {match} \033[90m({file_path}:{line_num})\033[0m")
77+
78+
# Check for completely unpinned dependencies (no SHA or version)
79+
unpinned_without_hash = [
80+
(match, line_num)
81+
for match, line_num in all_matches
82+
if not any(match in pinned[0] for pinned in pinned_matches)
83+
and not any(match in unpinned[0] for unpinned in unpinned_matches)
84+
]
85+
86+
if unpinned_without_hash:
87+
has_errors = True
88+
print("\033[91m[!] Completely unpinned (no SHA or version):\033[0m")
89+
for match, line_num in unpinned_without_hash:
90+
print(
91+
f" |- {match} \033[90m({self.format_terminal_link(file_path, line_num)})\033[0m"
92+
)
93+
94+
# Print summary
95+
total_actions = len(pinned_matches) + len(unpinned_matches) + len(unpinned_without_hash)
96+
if total_actions == 0:
97+
print("\033[93m[!] No GitHub Actions found in this file\033[0m")
98+
else:
99+
print("\n\033[1mSummary:\033[0m")
100+
print(f"Total actions: {total_actions}")
101+
print(f"Pinned: {len(pinned_matches)}")
102+
print(f"Unpinned with version: {len(unpinned_matches)}")
103+
print(f"Completely unpinned: {len(unpinned_without_hash)}")
104+
105+
return not has_errors
106+
107+
108+
def main():
109+
checker = GitHubActionChecker()
110+
files_to_check = sys.argv[1:]
111+
112+
if not files_to_check:
113+
print("\033[91mError: No files provided to check\033[0m")
114+
print("Usage: python script.py <file1> <file2> ...")
115+
sys.exit(1)
116+
117+
results = {file: checker.check_file(file) for file in files_to_check}
118+
119+
# Print final summary
120+
print("\n\033[1mFinal Results:\033[0m")
121+
for file, passed in results.items():
122+
status = "\033[92m✓ Passed\033[0m" if passed else "\033[91m✗ Failed\033[0m"
123+
print(f"{status} {file}")
124+
125+
if not all(results.values()):
126+
sys.exit(1)
127+
128+
129+
if __name__ == "__main__":
130+
main()

.hooks/generate_pr_description.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env python
2+
# /// script
3+
# requires-python = ">=3.10"
4+
# dependencies = [
5+
# "rigging",
6+
# "typer",
7+
# ]
8+
# ///
9+
import asyncio
10+
import os
11+
import typing as t
12+
13+
import rigging as rg
14+
import typer
15+
16+
TRUNCATION_WARNING = (
17+
"\n---\n**Note**: Due to the large size of this diff, some content has been truncated."
18+
)
19+
20+
21+
@rg.prompt
22+
def generate_pr_description(diff: str) -> t.Annotated[str, rg.Ctx("markdown")]: # type: ignore[empty-body]
23+
"""
24+
Analyze the provided git diff and create a PR description in markdown format.
25+
<guidance>
26+
- Keep the summary concise and informative.
27+
- Use bullet points to structure important statements.
28+
- Focus on key modifications and potential impact - if any.
29+
- Do not add in general advice or best-practice information.
30+
- Write like a developer who authored the changes.
31+
- Prefer flat bullet lists over nested.
32+
- Do not include any title structure.
33+
- If there are no changes, just provide "No relevant changes."
34+
- Order your bullet points by importance.
35+
</guidance>
36+
"""
37+
38+
39+
async def _run_git_command(args: list[str]) -> str:
40+
"""
41+
Safely run a git command with validated input.
42+
"""
43+
# Validate git exists in PATH
44+
git_path = "git" # Could use shutil.which("git") for more security
45+
if not any(
46+
os.path.isfile(os.path.join(path, "git")) for path in os.environ["PATH"].split(os.pathsep)
47+
):
48+
raise ValueError("Git executable not found in PATH")
49+
50+
# Validate input parameters
51+
if not all(isinstance(arg, str) for arg in args):
52+
raise ValueError("All command arguments must be strings")
53+
54+
# Use os.execv for more secure command execution
55+
try:
56+
# nosec B603 - Input is validated
57+
proc = await asyncio.create_subprocess_exec(
58+
git_path,
59+
*args,
60+
stdout=asyncio.subprocess.PIPE,
61+
stderr=asyncio.subprocess.PIPE,
62+
)
63+
stdout, stderr = await proc.communicate()
64+
65+
if proc.returncode != 0:
66+
raise RuntimeError(f"Git command failed: {stderr.decode()}")
67+
68+
return stdout.decode().strip()
69+
except Exception as e:
70+
raise RuntimeError(f"Failed to execute git command: {e}")
71+
72+
73+
async def get_diff(base_ref: str, source_ref: str, *, exclude: list[str] | None = None) -> str:
74+
"""
75+
Get the git diff between two branches.
76+
"""
77+
# Validate refs
78+
for ref in (base_ref, source_ref):
79+
if not isinstance(ref, str) or not ref.strip():
80+
raise ValueError("Invalid git reference")
81+
82+
# Get merge base
83+
merge_base = await _run_git_command(["merge-base", source_ref, base_ref])
84+
85+
# Prepare diff command
86+
diff_command = ["diff", "--no-color", merge_base, source_ref]
87+
if exclude:
88+
validated_excludes = []
89+
for path in exclude:
90+
# Validate path
91+
if not isinstance(path, str) or ".." in path:
92+
raise ValueError(f"Invalid exclude path: {path}")
93+
validated_excludes.append(f":(exclude){path}")
94+
diff_command.extend(["--", ".", *validated_excludes])
95+
96+
# Get diff
97+
return await _run_git_command(diff_command)
98+
99+
100+
def main(
101+
base_ref: str = "origin/main",
102+
source_ref: str = "HEAD",
103+
generator_id: str = "openai/gpt-4o-mini",
104+
max_diff_lines: int = 1000,
105+
exclude: list[str] | None = None,
106+
) -> None:
107+
"""
108+
Use rigging to generate a PR description from a git diff.
109+
"""
110+
diff = asyncio.run(get_diff(base_ref, source_ref, exclude=exclude))
111+
diff_lines = diff.split("\n")
112+
if len(diff_lines) > max_diff_lines:
113+
diff = "\n".join(diff_lines[:max_diff_lines]) + TRUNCATION_WARNING
114+
description = asyncio.run(generate_pr_description.bind(generator_id)(diff))
115+
print(description)
116+
117+
118+
if __name__ == "__main__":
119+
typer.run(main)

.hooks/linters/mdstyle.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
################################################################################
2+
# Style file for markdownlint.
3+
#
4+
# https://github.com/markdownlint/markdownlint/blob/master/docs/configuration.md
5+
#
6+
# This file is referenced by the project `.mdlrc`.
7+
################################################################################
8+
9+
#===============================================================================
10+
# Start with all built-in rules.
11+
# https://github.com/markdownlint/markdownlint/blob/master/docs/RULES.md
12+
all
13+
14+
#===============================================================================
15+
# Override default parameters for some built-in rules.
16+
# https://github.com/markdownlint/markdownlint/blob/master/docs/creating_styles.md#parameters
17+
18+
# Ignore line length for specific files
19+
rule 'MD013',
20+
ignore_code_blocks: true,
21+
files: ['CHANGELOG.md', 'RENOVATE_TESTING.md'],
22+
line_length: 99999 # Very high number to effectively disable for specified files
23+
24+
# Allow duplicate headers in changelog files
25+
rule 'MD024',
26+
allow_different_nesting: true,
27+
files: ['CHANGELOG.md']
28+
29+
#===============================================================================
30+
# Exclude the rules I disagree with.
31+
32+
# IMHO it's easier to read lists like:
33+
# * outmost indent
34+
# - one indent
35+
# - second indent
36+
# * Another major bullet
37+
exclude_rule 'MD004'
38+
39+
# Inconsistent indentation for list items is not a problem.
40+
exclude_rule 'MD005'
41+
42+
# Ordered lists are fine.
43+
exclude_rule 'MD029'
44+
45+
# The first line doesn't always need to be a top level header.
46+
exclude_rule 'MD041'
47+
48+
# I find it necessary to use '<br/>' to force line breaks.
49+
exclude_rule 'MD033' # Inline HTML
50+
51+
# Using bare URLs is fine.
52+
exclude_rule 'MD034'
53+
54+
# Allow emphasis to be used as headings (e.g., **Section Title**)
55+
exclude_rule 'MD036'

.hooks/linters/yamllint.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
extends: default
3+
4+
rules:
5+
line-length:
6+
max: 400
7+
level: warning
8+
truthy: false
9+
comments:
10+
min-spaces-from-content: 1
11+
braces: disable
12+
indentation: disable

0 commit comments

Comments
 (0)