Skip to content

Commit 7427858

Browse files
committed
add github actions
1 parent 1c7347c commit 7427858

File tree

2 files changed

+359
-0
lines changed

2 files changed

+359
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: CodeState PR Report
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened, ready_for_review]
6+
7+
jobs:
8+
pr-report:
9+
name: Generate PR Code Report
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: read
13+
pull-requests: write
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v4
17+
with:
18+
fetch-depth: 0
19+
20+
- name: Set up Python
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: '3.x'
24+
25+
- name: Generate PR report
26+
env:
27+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
28+
HEAD_SHA: ${{ github.sha }}
29+
run: |
30+
python tools/codestate_pr_report.py
31+
32+
- name: Upload HTML artifact
33+
uses: actions/upload-artifact@v4
34+
with:
35+
name: codestate-pr-report
36+
path: |
37+
codestate_pr_report.html
38+
codestate_pr_report.json
39+
if-no-files-found: warn
40+
41+
- name: Create or update PR comment
42+
uses: marocchino/sticky-pull-request-comment@v2
43+
with:
44+
header: codestate-pr-report
45+
path: codestate_pr_report.md

tools/codestate_pr_report.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate a PR report for changed files: language distribution, LOC stats, and a simple complexity heatmap.
4+
This script is standalone (stdlib only) to avoid runtime deps. It scans only the changed files between two git SHAs.
5+
6+
Outputs:
7+
- codestate_pr_report.md : Markdown content suitable for PR comments
8+
- codestate_pr_report.html : An HTML artifact with a detailed table
9+
10+
Env (recommended):
11+
- BASE_SHA: base commit SHA (e.g., github.event.pull_request.base.sha)
12+
- HEAD_SHA: head commit SHA (e.g., github.sha)
13+
"""
14+
from __future__ import annotations
15+
16+
import os
17+
import re
18+
import sys
19+
import json
20+
import html
21+
import shlex
22+
import subprocess
23+
from pathlib import Path
24+
from typing import Dict, List, Tuple
25+
26+
27+
SUPPORTED_EXTS = {
28+
'.py', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs',
29+
'.java', '.c', '.h', '.cpp', '.hpp', '.cc', '.cs', '.go', '.rb', '.php', '.rs', '.kt', '.swift',
30+
'.m', '.mm', '.scala', '.sh', '.bash', '.zsh', '.ps1', '.psm1', '.pl', '.pm', '.r', '.jl', '.lua',
31+
'.ex', '.exs', '.hs', '.erl', '.clj', '.groovy', '.dart', '.sql', '.css', '.scss', '.sass', '.less',
32+
'.html', '.vue', '.svelte', '.hbs', '.ejs', '.jinja', '.jinja2', '.njk'
33+
}
34+
35+
36+
def run(cmd: str) -> Tuple[int, str, str]:
37+
p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
38+
out, err = p.communicate()
39+
return p.returncode, out, err
40+
41+
42+
def list_changed_files(base: str, head: str) -> List[str]:
43+
# Include Added, Copied, Modified, Renamed, Type changed, Unmerged, Unknown, Broken
44+
cmd = f"git diff --name-only --diff-filter=ACMRTUXB {shlex.quote(base)}..{shlex.quote(head)}"
45+
code, out, err = run(cmd)
46+
if code != 0:
47+
print(f"::warning::Failed to list changed files: {err.strip()}\nFalling back to 'git diff --name-only' on HEAD.")
48+
code, out, _ = run("git diff --name-only")
49+
files = [line.strip() for line in out.splitlines() if line.strip()]
50+
# Filter only supported extensions and existing files
51+
result = []
52+
for f in files:
53+
p = Path(f)
54+
if p.suffix.lower() in SUPPORTED_EXTS and p.exists() and p.is_file():
55+
result.append(str(p))
56+
return result
57+
58+
59+
def estimate_comment_lines(ext: str, lines: List[str]) -> int:
60+
ext = ext.lower()
61+
comment = 0
62+
in_block = False
63+
block_start = {'/*', "'''", '"""'}
64+
block_end = {'*/', "'''", '"""'}
65+
for raw in lines:
66+
s = raw.strip()
67+
if not s:
68+
continue
69+
if in_block:
70+
comment += 1
71+
# Heuristic: close on end token appearance
72+
if any(tok in s for tok in block_end):
73+
in_block = False
74+
continue
75+
# Single-line styles
76+
if ext in {'.py'}:
77+
if s.startswith('#'):
78+
comment += 1
79+
continue
80+
# Start of docstring block
81+
if s.startswith("'''") or s.startswith('"""'):
82+
comment += 1
83+
if not (s.endswith("'''") or s.endswith('"""')):
84+
in_block = True
85+
continue
86+
else:
87+
if s.startswith('//'):
88+
comment += 1
89+
continue
90+
if s.startswith('/*') or s.startswith('<!--'):
91+
comment += 1
92+
in_block = True
93+
continue
94+
return comment
95+
96+
97+
FUNC_PATTERNS = [
98+
re.compile(r"\bdef\s+\w+\s*\(", re.I),
99+
re.compile(r"\bfunction\b|=>\s*\(", re.I),
100+
re.compile(r"\b(class|interface)\s+\w+\b", re.I),
101+
]
102+
103+
COMPLEXITY_TOKENS = (
104+
' if ', ' for ', ' while ', ' case ', ' when ', ' elif ', ' switch ', ' catch ', ' try ',
105+
'&&', '||', '?:', '?', ' await ', ' async ', ' yield ', ' except ', ' with ', ' and ', ' or '
106+
)
107+
108+
109+
def count_functions(text: str) -> int:
110+
total = 0
111+
for pat in FUNC_PATTERNS:
112+
total += len(pat.findall(text))
113+
return total
114+
115+
116+
def estimate_complexity(lines: List[str]) -> float:
117+
score = 0
118+
for raw in lines:
119+
s = f" {raw.strip()} ".lower()
120+
score += sum(1 for tok in COMPLEXITY_TOKENS if tok in s)
121+
return float(score)
122+
123+
124+
def read_lines(path: Path) -> List[str]:
125+
try:
126+
return path.read_text(encoding='utf-8', errors='ignore').splitlines()
127+
except Exception:
128+
try:
129+
return path.read_text(encoding='latin-1', errors='ignore').splitlines()
130+
except Exception:
131+
return []
132+
133+
134+
def analyze_files(files: List[str]) -> Tuple[Dict[str, dict], List[dict]]:
135+
by_ext: Dict[str, dict] = {}
136+
per_file: List[dict] = []
137+
for f in files:
138+
p = Path(f)
139+
ext = p.suffix.lower() or '<none>'
140+
lines = read_lines(p)
141+
total_lines = len(lines)
142+
comments = estimate_comment_lines(ext, lines)
143+
text = "\n".join(lines)
144+
func_count = count_functions(text)
145+
complexity = estimate_complexity(lines)
146+
pf = {
147+
'file': f,
148+
'ext': ext,
149+
'total_lines': total_lines,
150+
'comment_lines': comments,
151+
'function_count': func_count,
152+
'complexity': complexity,
153+
}
154+
per_file.append(pf)
155+
agg = by_ext.setdefault(ext, {
156+
'ext': ext, 'file_count': 0, 'total_lines': 0,
157+
'comment_lines': 0, 'function_count': 0
158+
})
159+
agg['file_count'] += 1
160+
agg['total_lines'] += total_lines
161+
agg['comment_lines'] += comments
162+
agg['function_count'] += func_count
163+
return by_ext, per_file
164+
165+
166+
def make_bar(value: int, max_value: int, width: int = 24) -> str:
167+
if max_value <= 0:
168+
return ''
169+
n = int(round((value / max_value) * width))
170+
return '█' * max(1, n) if value > 0 else ''
171+
172+
173+
def render_markdown(by_ext: Dict[str, dict], per_file: List[dict]) -> str:
174+
title = "## CodeState PR Report — Changed Files"
175+
if not per_file:
176+
return f"{title}\n\nNo supported code changes detected."
177+
178+
# Extension summary table
179+
exts = sorted(by_ext.values(), key=lambda x: (-x['total_lines'], x['ext']))
180+
header = "| ext | files | lines | comments | functions |\n|---|---:|---:|---:|---:|"
181+
rows = [
182+
f"| {e['ext']} | {e['file_count']} | {e['total_lines']} | {e['comment_lines']} | {e['function_count']} |"
183+
for e in exts
184+
]
185+
186+
# Language distribution bars (by lines)
187+
max_lines = max((e['total_lines'] for e in exts), default=0)
188+
bars = [f"`{e['ext']}` {make_bar(e['total_lines'], max_lines)} {e['total_lines']}" for e in exts]
189+
190+
# Complexity heatmap (top 10)
191+
top_complex = sorted(per_file, key=lambda x: (-x['complexity'], -x['total_lines']))[:10]
192+
max_c = int(max((f['complexity'] for f in top_complex), default=0))
193+
heat = [
194+
f"`{Path(f['file']).name}` {make_bar(int(f['complexity']), max_c)} {int(f['complexity'])}"
195+
for f in top_complex
196+
]
197+
198+
md = []
199+
md.append(title)
200+
md.append("")
201+
md.append("### Summary by extension")
202+
md.append(header)
203+
md.extend(rows)
204+
md.append("")
205+
md.append("### Language distribution (by lines)")
206+
md.extend([f"- {b}" for b in bars])
207+
md.append("")
208+
md.append("### Complexity heatmap (top 10 files)")
209+
if heat:
210+
md.extend([f"- {h}" for h in heat])
211+
else:
212+
md.append("- No files to display")
213+
md.append("")
214+
md.append(
215+
"> Generated by CodeState PR reporter. An HTML report has been uploaded as a workflow artifact."
216+
)
217+
return "\n".join(md)
218+
219+
220+
def render_html(by_ext: Dict[str, dict], per_file: List[dict]) -> str:
221+
html_rows = []
222+
for f in sorted(per_file, key=lambda x: (-x['total_lines'], x['file'])):
223+
html_rows.append(
224+
f"<tr><td>{html.escape(f['file'])}</td><td>{html.escape(f['ext'])}</td>"
225+
f"<td style='text-align:right'>{f['total_lines']}</td>"
226+
f"<td style='text-align:right'>{f['comment_lines']}</td>"
227+
f"<td style='text-align:right'>{f['function_count']}</td>"
228+
f"<td style='text-align:right'>{int(f['complexity'])}</td></tr>"
229+
)
230+
ext_rows = []
231+
for e in sorted(by_ext.values(), key=lambda x: (-x['total_lines'], x['ext'])):
232+
ext_rows.append(
233+
f"<tr><td>{html.escape(e['ext'])}</td>"
234+
f"<td style='text-align:right'>{e['file_count']}</td>"
235+
f"<td style='text-align:right'>{e['total_lines']}</td>"
236+
f"<td style='text-align:right'>{e['comment_lines']}</td>"
237+
f"<td style='text-align:right'>{e['function_count']}</td></tr>"
238+
)
239+
return f"""
240+
<!doctype html>
241+
<html lang="en">
242+
<head>
243+
<meta charset="utf-8" />
244+
<meta name="viewport" content="width=device-width, initial-scale=1" />
245+
<title>CodeState PR Report</title>
246+
<style>
247+
body {{ font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; padding: 16px; }}
248+
table {{ border-collapse: collapse; width: 100%; margin-bottom: 24px; }}
249+
th, td {{ border: 1px solid #e5e7eb; padding: 8px; }}
250+
th {{ background: #f3f4f6; text-align: left; }}
251+
caption {{ text-align: left; font-weight: 600; margin: 8px 0; }}
252+
.muted {{ color: #6b7280; }}
253+
</style>
254+
</head>
255+
<body>
256+
<h2>CodeState PR Report — Changed Files</h2>
257+
<p class="muted">This artifact lists per-file and per-extension metrics for the current pull request.</p>
258+
<table>
259+
<caption>Summary by extension</caption>
260+
<thead>
261+
<tr><th>ext</th><th>files</th><th>lines</th><th>comments</th><th>functions</th></tr>
262+
</thead>
263+
<tbody>
264+
{''.join(ext_rows)}
265+
</tbody>
266+
</table>
267+
<table>
268+
<caption>Per-file details</caption>
269+
<thead>
270+
<tr><th>file</th><th>ext</th><th>lines</th><th>comments</th><th>functions</th><th>complexity</th></tr>
271+
</thead>
272+
<tbody>
273+
{''.join(html_rows)}
274+
</tbody>
275+
</table>
276+
<p class="muted">Generated by CodeState PR reporter.</p>
277+
</body>
278+
</html>
279+
"""
280+
281+
282+
def main() -> int:
283+
base = os.getenv('BASE_SHA') or os.getenv('GITHUB_BASE_SHA') or ''
284+
head = os.getenv('HEAD_SHA') or os.getenv('GITHUB_HEAD_SHA') or ''
285+
if not base or not head:
286+
# Try to infer base from origin or default branch
287+
# Fallback to HEAD~1..HEAD
288+
code, out, _ = run("git rev-parse HEAD")
289+
head = out.strip() if out.strip() else head
290+
code, out, _ = run("git rev-parse HEAD~1")
291+
base = out.strip() if out.strip() else base
292+
293+
files = list_changed_files(base, head)
294+
by_ext, per_file = analyze_files(files)
295+
296+
md = render_markdown(by_ext, per_file)
297+
html_content = render_html(by_ext, per_file)
298+
299+
Path('codestate_pr_report.md').write_text(md, encoding='utf-8')
300+
Path('codestate_pr_report.html').write_text(html_content, encoding='utf-8')
301+
302+
# Optional: also write a compact JSON for future automation
303+
payload = {
304+
'base': base, 'head': head, 'file_count': len(per_file),
305+
'by_ext': list(by_ext.values()), 'per_file': per_file,
306+
}
307+
Path('codestate_pr_report.json').write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
308+
309+
print("Report files generated: codestate_pr_report.md, codestate_pr_report.html, codestate_pr_report.json")
310+
return 0
311+
312+
313+
if __name__ == '__main__':
314+
raise SystemExit(main())

0 commit comments

Comments
 (0)