whycode-cli 0.2.3__tar.gz → 0.2.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {whycode_cli-0.2.3/src/whycode_cli.egg-info → whycode_cli-0.2.5}/PKG-INFO +1 -1
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/pyproject.toml +1 -1
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/__init__.py +1 -1
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/cli.py +77 -45
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/git_facts.py +50 -2
- whycode_cli-0.2.5/src/whycode/templates/github-workflow.yml +64 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5/src/whycode_cli.egg-info}/PKG-INFO +1 -1
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/tests/test_cli.py +33 -0
- whycode_cli-0.2.3/src/whycode/templates/github-workflow.yml +0 -40
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/LICENSE +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/README.md +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/setup.cfg +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/__main__.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/ignore.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/mcp_server.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/risk_card.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/scorer.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/signals.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/suppressions.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/templates/__init__.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode/templates/pre-commit +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode_cli.egg-info/SOURCES.txt +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode_cli.egg-info/dependency_links.txt +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode_cli.egg-info/entry_points.txt +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode_cli.egg-info/requires.txt +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/src/whycode_cli.egg-info/top_level.txt +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/tests/test_git_facts.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/tests/test_ignore.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/tests/test_scorer.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/tests/test_signals.py +0 -0
- {whycode_cli-0.2.3 → whycode_cli-0.2.5}/tests/test_suppressions.py +0 -0
|
@@ -71,12 +71,29 @@ def _path_is_known_to_git(repo_root: Path, rel: str) -> bool:
|
|
|
71
71
|
if gf.is_tracked(repo_root, rel):
|
|
72
72
|
return True
|
|
73
73
|
try:
|
|
74
|
-
out = gf.
|
|
74
|
+
out = gf.run_git(repo_root, "log", "--oneline", "-1", "--all", "--", rel)
|
|
75
75
|
except gf.GitError:
|
|
76
76
|
return False
|
|
77
77
|
return bool(out.strip())
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def _require_tracked(path_arg: str) -> tuple[Path, str]:
|
|
81
|
+
"""Resolve ``path_arg`` to ``(repo_root, rel)`` or exit with a friendly warning.
|
|
82
|
+
|
|
83
|
+
Used by every command that takes a path argument and needs git history
|
|
84
|
+
to be useful (``why``, ``timeline``, ``honest``). Combines the two earlier
|
|
85
|
+
helpers so callers don't repeat the warn-and-exit boilerplate.
|
|
86
|
+
"""
|
|
87
|
+
repo_root, rel = _resolve_repo_and_path(path_arg)
|
|
88
|
+
if not _path_is_known_to_git(repo_root, rel):
|
|
89
|
+
err.print(
|
|
90
|
+
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
91
|
+
f"and has no history in this repo. Nothing to learn from."
|
|
92
|
+
)
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
return repo_root, rel
|
|
95
|
+
|
|
96
|
+
|
|
80
97
|
# --- shared: band threshold parsing ----------------------------------------
|
|
81
98
|
|
|
82
99
|
_BAND_THRESHOLDS_BY_KEY: dict[str, int] = {
|
|
@@ -142,17 +159,11 @@ def why(
|
|
|
142
159
|
),
|
|
143
160
|
) -> None:
|
|
144
161
|
"""Print the Risk Card for ``path``."""
|
|
145
|
-
repo_root, rel =
|
|
146
|
-
if not _path_is_known_to_git(repo_root, rel):
|
|
147
|
-
err.print(
|
|
148
|
-
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
149
|
-
f"and has no history in this repo. Nothing to learn from."
|
|
150
|
-
)
|
|
151
|
-
raise typer.Exit(1)
|
|
162
|
+
repo_root, rel = _require_tracked(path)
|
|
152
163
|
resolved_ref: str | None = None
|
|
153
164
|
if at is not None:
|
|
154
165
|
try:
|
|
155
|
-
resolved_ref = gf.
|
|
166
|
+
resolved_ref = gf.run_git(
|
|
156
167
|
repo_root, "rev-parse", "--verify", f"{at}^{{commit}}"
|
|
157
168
|
).strip()
|
|
158
169
|
except gf.GitError:
|
|
@@ -202,7 +213,7 @@ def _resolve_base_ref(repo_root: Path, requested: str | None) -> str:
|
|
|
202
213
|
candidates = ("origin/main", "origin/master", "main", "master", "HEAD~1")
|
|
203
214
|
for ref in candidates:
|
|
204
215
|
try:
|
|
205
|
-
gf.
|
|
216
|
+
gf.run_git(repo_root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
|
|
206
217
|
return ref
|
|
207
218
|
except gf.GitError:
|
|
208
219
|
continue
|
|
@@ -227,6 +238,14 @@ def diff(
|
|
|
227
238
|
json_out: bool = typer.Option(
|
|
228
239
|
False, "--json", help="Emit machine-readable JSON instead of a table."
|
|
229
240
|
),
|
|
241
|
+
markdown: bool = typer.Option(
|
|
242
|
+
False,
|
|
243
|
+
"--markdown",
|
|
244
|
+
help=(
|
|
245
|
+
"Emit GitHub-flavoured markdown suitable for posting as a PR comment. "
|
|
246
|
+
"Pipe into a workflow step that calls `gh pr comment`."
|
|
247
|
+
),
|
|
248
|
+
),
|
|
230
249
|
fail_on: str | None = typer.Option(
|
|
231
250
|
None,
|
|
232
251
|
"--fail-on",
|
|
@@ -241,13 +260,13 @@ def diff(
|
|
|
241
260
|
try:
|
|
242
261
|
repo_root = gf.discover_repo_root(repo.resolve())
|
|
243
262
|
if staged:
|
|
244
|
-
raw = gf.
|
|
263
|
+
raw = gf.run_git(
|
|
245
264
|
repo_root, "diff", "--cached", "--name-only", "--diff-filter=ACMR"
|
|
246
265
|
)
|
|
247
266
|
actual_base = "(staged changes)"
|
|
248
267
|
else:
|
|
249
268
|
actual_base = _resolve_base_ref(repo_root, base)
|
|
250
|
-
raw = gf.
|
|
269
|
+
raw = gf.run_git(repo_root, "diff", "--name-only", f"{actual_base}...HEAD")
|
|
251
270
|
except gf.GitError as exc:
|
|
252
271
|
err.print(f"[red]error:[/red] {exc}")
|
|
253
272
|
raise typer.Exit(2) from exc
|
|
@@ -294,6 +313,37 @@ def diff(
|
|
|
294
313
|
|
|
295
314
|
flagged = [c for c in cards if _is_actionable(c)]
|
|
296
315
|
quiet_n = len(cards) - len(flagged)
|
|
316
|
+
scope_md = "files staged for commit" if staged else f"files changed vs `{actual_base}`"
|
|
317
|
+
if markdown:
|
|
318
|
+
# Stable marker so a follow-up workflow step can find-and-update the
|
|
319
|
+
# same comment on subsequent pushes instead of stacking new ones.
|
|
320
|
+
print("<!-- whycode-comment -->")
|
|
321
|
+
print("## WhyCode risk briefing")
|
|
322
|
+
print()
|
|
323
|
+
print(f"**{len(files)} {scope_md}**")
|
|
324
|
+
print()
|
|
325
|
+
if not flagged:
|
|
326
|
+
print("Nothing flagged. Read the diff anyway.")
|
|
327
|
+
else:
|
|
328
|
+
print("| Score | Band | File | Top signal |")
|
|
329
|
+
print("| ----: | ---- | ---- | ---------- |")
|
|
330
|
+
for c in flagged:
|
|
331
|
+
top_signal = c.signals[0].headline.replace("|", "\\|")
|
|
332
|
+
print(
|
|
333
|
+
f"| {c.score.value} | {c.score.band.value} | "
|
|
334
|
+
f"`{c.path}` | {top_signal} |"
|
|
335
|
+
)
|
|
336
|
+
if quiet_n:
|
|
337
|
+
print()
|
|
338
|
+
print(f"_+ {quiet_n} file(s) changed with no flags._")
|
|
339
|
+
print()
|
|
340
|
+
print(
|
|
341
|
+
"_Run `whycode why <path>` for the full Risk Card on any of the above._"
|
|
342
|
+
)
|
|
343
|
+
if threshold is not None and any(c.score.value >= threshold for c in cards):
|
|
344
|
+
raise typer.Exit(1)
|
|
345
|
+
return
|
|
346
|
+
|
|
297
347
|
scope = "staged for commit" if staged else f"changed vs {actual_base}"
|
|
298
348
|
console.print(f"[bold]{len(files)} file(s) {scope}[/bold]")
|
|
299
349
|
if not flagged:
|
|
@@ -483,13 +533,7 @@ def timeline(
|
|
|
483
533
|
),
|
|
484
534
|
) -> None:
|
|
485
535
|
"""Show how this file's risk score evolved over its history."""
|
|
486
|
-
repo_root, rel =
|
|
487
|
-
if not _path_is_known_to_git(repo_root, rel):
|
|
488
|
-
err.print(
|
|
489
|
-
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
490
|
-
f"and has no history in this repo."
|
|
491
|
-
)
|
|
492
|
-
raise typer.Exit(1)
|
|
536
|
+
repo_root, rel = _require_tracked(path)
|
|
493
537
|
|
|
494
538
|
commits = gf.commits_for_path(repo_root, rel)
|
|
495
539
|
if not commits:
|
|
@@ -588,7 +632,7 @@ def scan(
|
|
|
588
632
|
err.print(f"[red]error:[/red] {exc}")
|
|
589
633
|
raise typer.Exit(2) from exc
|
|
590
634
|
|
|
591
|
-
raw = gf.
|
|
635
|
+
raw = gf.run_git(repo_root, "ls-files")
|
|
592
636
|
all_paths = [line for line in raw.splitlines() if line.strip()]
|
|
593
637
|
patterns = () if no_ignore else ign.effective_patterns(repo_root)
|
|
594
638
|
paths = [p for p in all_paths if not ign.is_ignored(p, patterns)][:sample]
|
|
@@ -648,13 +692,7 @@ def honest(
|
|
|
648
692
|
Use when the Risk Card's first-sentence truncation is hiding important
|
|
649
693
|
context — e.g., a commit whose constraint is stated across two lines.
|
|
650
694
|
"""
|
|
651
|
-
repo_root, rel =
|
|
652
|
-
if not _path_is_known_to_git(repo_root, rel):
|
|
653
|
-
err.print(
|
|
654
|
-
f"[yellow]warning:[/yellow] [bold]{rel}[/bold] is not tracked by git "
|
|
655
|
-
f"and has no history in this repo."
|
|
656
|
-
)
|
|
657
|
-
raise typer.Exit(1)
|
|
695
|
+
repo_root, rel = _require_tracked(path)
|
|
658
696
|
facts = gf.gather(repo_root, rel)
|
|
659
697
|
if not facts.invariant_quotes:
|
|
660
698
|
if json_out:
|
|
@@ -716,24 +754,18 @@ def show(
|
|
|
716
754
|
"""Risk-flavored summary for a single commit: classification + per-file risk."""
|
|
717
755
|
try:
|
|
718
756
|
repo_root = gf.discover_repo_root(repo.resolve())
|
|
719
|
-
full_sha = gf._run_git(repo_root, "rev-parse", "--verify", f"{sha}^{{commit}}").strip()
|
|
720
757
|
except gf.GitError as exc:
|
|
721
758
|
err.print(f"[red]error:[/red] {exc}")
|
|
722
759
|
raise typer.Exit(2) from exc
|
|
723
760
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
commits = gf._parse_log_records(raw)
|
|
728
|
-
if not commits:
|
|
729
|
-
err.print(f"[red]error:[/red] could not read commit {full_sha}")
|
|
761
|
+
commit = gf.read_commit(repo_root, sha)
|
|
762
|
+
if commit is None:
|
|
763
|
+
err.print(f"[red]error:[/red] could not read commit {sha!r}")
|
|
730
764
|
raise typer.Exit(2)
|
|
731
|
-
|
|
765
|
+
full_sha = commit.sha
|
|
732
766
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
or gf._BREAKING_CC_RE.search(commit.subject)
|
|
736
|
-
)
|
|
767
|
+
classification = gf.classify_commit(commit)
|
|
768
|
+
is_incident = classification.incident_flavoured
|
|
737
769
|
invariants = gf.extract_invariant_quotes([commit])
|
|
738
770
|
file_changes = gf.files_changed_in(repo_root, full_sha)
|
|
739
771
|
|
|
@@ -768,14 +800,14 @@ def show(
|
|
|
768
800
|
)
|
|
769
801
|
console.print(f" {commit.subject}")
|
|
770
802
|
console.print()
|
|
771
|
-
|
|
803
|
+
badges: list[str] = []
|
|
772
804
|
if is_incident:
|
|
773
|
-
|
|
805
|
+
badges.append("[bold red]incident-flavored[/bold red]")
|
|
774
806
|
if invariants:
|
|
775
|
-
|
|
776
|
-
if not
|
|
777
|
-
|
|
778
|
-
console.print(" " + " ".join(
|
|
807
|
+
badges.append(f"[yellow]states {len(invariants)} invariant(s)[/yellow]")
|
|
808
|
+
if not badges:
|
|
809
|
+
badges.append("[dim]no special classification[/dim]")
|
|
810
|
+
console.print(" " + " ".join(badges))
|
|
779
811
|
console.print(f" [dim]{len(file_changes)} files changed[/dim]")
|
|
780
812
|
|
|
781
813
|
if not cards:
|
|
@@ -129,8 +129,13 @@ class GitError(RuntimeError):
|
|
|
129
129
|
"""Raised when a git invocation fails or produces unexpected output."""
|
|
130
130
|
|
|
131
131
|
|
|
132
|
-
def
|
|
133
|
-
"""Invoke git
|
|
132
|
+
def run_git(repo_root: Path, *args: str) -> str:
|
|
133
|
+
"""Invoke ``git -C <repo_root> <args>`` and return stdout.
|
|
134
|
+
|
|
135
|
+
Public API: callers (CLI, MCP server) use this to run git commands
|
|
136
|
+
that aren't already wrapped in a higher-level helper here. Raises
|
|
137
|
+
:class:`GitError` on non-zero exit or when ``git`` itself is missing.
|
|
138
|
+
"""
|
|
134
139
|
cmd = ["git", "-C", str(repo_root), *args]
|
|
135
140
|
try:
|
|
136
141
|
proc = subprocess.run(
|
|
@@ -150,6 +155,10 @@ def _run_git(repo_root: Path, *args: str) -> str:
|
|
|
150
155
|
return proc.stdout
|
|
151
156
|
|
|
152
157
|
|
|
158
|
+
# Back-compat alias. Prefer ``run_git`` in new code.
|
|
159
|
+
_run_git = run_git
|
|
160
|
+
|
|
161
|
+
|
|
153
162
|
def discover_repo_root(start: Path) -> Path:
|
|
154
163
|
"""Find the enclosing git repo root for ``start``."""
|
|
155
164
|
out = _run_git(start, "rev-parse", "--show-toplevel").strip()
|
|
@@ -240,6 +249,25 @@ def all_commits(repo_root: Path, *, max_count: int | None = None) -> list[Commit
|
|
|
240
249
|
return _parse_log_records(raw)
|
|
241
250
|
|
|
242
251
|
|
|
252
|
+
def read_commit(repo_root: Path, ref: str) -> Commit | None:
|
|
253
|
+
"""Resolve ``ref`` (SHA, tag, branch, ``HEAD~3`` …) to a single ``Commit``.
|
|
254
|
+
|
|
255
|
+
Returns ``None`` when the ref doesn't exist or doesn't resolve to a
|
|
256
|
+
commit. Used by ``whycode show <sha>`` and similar single-commit views.
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
full_sha = run_git(
|
|
260
|
+
repo_root, "rev-parse", "--verify", f"{ref}^{{commit}}"
|
|
261
|
+
).strip()
|
|
262
|
+
except GitError:
|
|
263
|
+
return None
|
|
264
|
+
raw = run_git(
|
|
265
|
+
repo_root, "log", "-1", "--no-merges", f"--pretty=format:{_log_format()}", full_sha
|
|
266
|
+
)
|
|
267
|
+
parsed = _parse_log_records(raw)
|
|
268
|
+
return parsed[0] if parsed else None
|
|
269
|
+
|
|
270
|
+
|
|
243
271
|
def files_changed_in(repo_root: Path, sha: str) -> list[FileChange]:
|
|
244
272
|
"""Return the list of files (with diffstat) changed in ``sha``."""
|
|
245
273
|
raw = _run_git(
|
|
@@ -359,6 +387,26 @@ def find_incidents(commits: Sequence[Commit]) -> list[Commit]:
|
|
|
359
387
|
return out
|
|
360
388
|
|
|
361
389
|
|
|
390
|
+
@dataclass(frozen=True)
|
|
391
|
+
class CommitClassification:
|
|
392
|
+
"""Light-weight summary of what kind of work a single commit represents."""
|
|
393
|
+
|
|
394
|
+
incident_flavoured: bool
|
|
395
|
+
invariant_count: int
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def classify_commit(commit: Commit) -> CommitClassification:
|
|
399
|
+
"""Classify a single commit by reusing the same rules ``find_incidents`` and
|
|
400
|
+
``extract_invariant_quotes`` apply to a list. Public API for ``whycode show``
|
|
401
|
+
and any other surface that wants a single-commit verdict without
|
|
402
|
+
re-implementing the regex ladder.
|
|
403
|
+
"""
|
|
404
|
+
return CommitClassification(
|
|
405
|
+
incident_flavoured=bool(find_incidents([commit])),
|
|
406
|
+
invariant_count=len(extract_invariant_quotes([commit])),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
362
410
|
# Straight, backtick, and the four common Unicode "smart" quote code points.
|
|
363
411
|
# We build the string from chr() calls because ruff's RUF001 ambiguous-char
|
|
364
412
|
# check rejects the literal Unicode quotes inline.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Risk-rank pull requests with WhyCode.
|
|
2
|
+
#
|
|
3
|
+
# On every PR this workflow:
|
|
4
|
+
# 1. Computes the Risk Card for each changed file (vs the PR base)
|
|
5
|
+
# 2. Prints a risk-ranked table to the job log
|
|
6
|
+
# 3. Posts (or updates) a single PR comment with the same table
|
|
7
|
+
#
|
|
8
|
+
# Advisory by default — humans decide. To turn it into a hard gate that
|
|
9
|
+
# blocks merging, append `--fail-on <band>` to the diff line:
|
|
10
|
+
# handle block at HANDLE WITH CARE (score >= 75)
|
|
11
|
+
# history block at READ HISTORY FIRST (score >= 50)
|
|
12
|
+
# look stricter — block at >= 25
|
|
13
|
+
name: WhyCode
|
|
14
|
+
|
|
15
|
+
on:
|
|
16
|
+
pull_request:
|
|
17
|
+
types: [opened, synchronize, reopened]
|
|
18
|
+
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
pull-requests: write # required to post / update the PR comment
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
whycode:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- name: Check out PR
|
|
28
|
+
uses: actions/checkout@v4
|
|
29
|
+
with:
|
|
30
|
+
fetch-depth: 0 # WhyCode needs full history
|
|
31
|
+
|
|
32
|
+
- name: Set up Python
|
|
33
|
+
uses: actions/setup-python@v5
|
|
34
|
+
with:
|
|
35
|
+
python-version: "3.11"
|
|
36
|
+
|
|
37
|
+
- name: Install WhyCode
|
|
38
|
+
run: pip install whycode-cli
|
|
39
|
+
|
|
40
|
+
- name: Risk-rank files in this PR (job log)
|
|
41
|
+
run: whycode diff --base origin/${{ github.base_ref }}
|
|
42
|
+
|
|
43
|
+
- name: Build PR comment body
|
|
44
|
+
run: whycode diff --base origin/${{ github.base_ref }} --markdown > whycode-comment.md
|
|
45
|
+
|
|
46
|
+
- name: Post or update the PR comment
|
|
47
|
+
env:
|
|
48
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
49
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
50
|
+
REPO: ${{ github.repository }}
|
|
51
|
+
run: |
|
|
52
|
+
set -euo pipefail
|
|
53
|
+
BODY=$(cat whycode-comment.md)
|
|
54
|
+
# Find any existing comment we previously posted (identified by the
|
|
55
|
+
# hidden HTML marker WhyCode emits at the top of the message).
|
|
56
|
+
EXISTING=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
|
|
57
|
+
--jq 'map(select(.body | contains("<!-- whycode-comment -->"))) | .[0].id // empty')
|
|
58
|
+
if [ -n "$EXISTING" ]; then
|
|
59
|
+
gh api -X PATCH "repos/${REPO}/issues/comments/${EXISTING}" \
|
|
60
|
+
-f body="$BODY" > /dev/null
|
|
61
|
+
else
|
|
62
|
+
gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" \
|
|
63
|
+
-f body="$BODY" > /dev/null
|
|
64
|
+
fi
|
|
@@ -508,6 +508,39 @@ def test_scan_no_ignore_brings_them_back(repo, days_ago) -> None: # type: ignor
|
|
|
508
508
|
assert "CHANGELOG" in permissive_run.output or "src/app.py" in permissive_run.output
|
|
509
509
|
|
|
510
510
|
|
|
511
|
+
def test_diff_markdown_output(repo, days_ago) -> None: # type: ignore[no-untyped-def]
|
|
512
|
+
repo.commit("init", {"refund.py": "0"}, when=days_ago(120))
|
|
513
|
+
sha = repo.commit("feat: refund flow", {"refund.py": "1"}, when=days_ago(60))
|
|
514
|
+
repo.revert(sha, when=days_ago(50))
|
|
515
|
+
repo.commit(
|
|
516
|
+
"hotfix: regression",
|
|
517
|
+
{"refund.py": "2"},
|
|
518
|
+
body="incident #INC-42",
|
|
519
|
+
when=days_ago(10),
|
|
520
|
+
)
|
|
521
|
+
result = _invoke(repo.root, "diff", "--base", "HEAD~3", "--markdown")
|
|
522
|
+
assert result.exit_code == 0
|
|
523
|
+
out = result.output
|
|
524
|
+
# Hidden marker so the workflow can find-and-update its prior comment.
|
|
525
|
+
assert "<!-- whycode-comment -->" in out
|
|
526
|
+
# Markdown table syntax.
|
|
527
|
+
assert "| Score | Band |" in out
|
|
528
|
+
assert "| ----: |" in out
|
|
529
|
+
# File path appears as inline code.
|
|
530
|
+
assert "`refund.py`" in out
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def test_diff_markdown_quiet_repo(repo, days_ago) -> None: # type: ignore[no-untyped-def]
|
|
534
|
+
repo.commit("init", {"a.py": "1"}, when=days_ago(40))
|
|
535
|
+
repo.commit("docs: tweak", {"a.py": "2"}, when=days_ago(20))
|
|
536
|
+
result = _invoke(repo.root, "diff", "--base", "HEAD~1", "--markdown")
|
|
537
|
+
assert result.exit_code == 0
|
|
538
|
+
out = result.output
|
|
539
|
+
assert "<!-- whycode-comment -->" in out
|
|
540
|
+
# No flagged files → friendly note instead of an empty table.
|
|
541
|
+
assert "Nothing flagged" in out
|
|
542
|
+
|
|
543
|
+
|
|
511
544
|
def test_scan_respects_user_whycodeignore(repo, days_ago) -> None: # type: ignore[no-untyped-def]
|
|
512
545
|
(repo.root / ".whycodeignore").write_text("internal/legacy.py\n")
|
|
513
546
|
sha = repo.commit(
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# Risk-rank pull requests with WhyCode.
|
|
2
|
-
#
|
|
3
|
-
# On every PR this workflow computes the Risk Card for each changed file
|
|
4
|
-
# (vs the PR base) and prints a risk-ranked table to the job log.
|
|
5
|
-
# Advisory by default — humans decide.
|
|
6
|
-
#
|
|
7
|
-
# To turn it into a hard gate that blocks merging, append `--fail-on <band>`
|
|
8
|
-
# to the `whycode diff` line below:
|
|
9
|
-
# handle block at HANDLE WITH CARE (score >= 75)
|
|
10
|
-
# history block at READ HISTORY FIRST (score >= 50)
|
|
11
|
-
# look stricter — block at >= 25
|
|
12
|
-
name: WhyCode
|
|
13
|
-
|
|
14
|
-
on:
|
|
15
|
-
pull_request:
|
|
16
|
-
types: [opened, synchronize, reopened]
|
|
17
|
-
|
|
18
|
-
permissions:
|
|
19
|
-
contents: read
|
|
20
|
-
pull-requests: read
|
|
21
|
-
|
|
22
|
-
jobs:
|
|
23
|
-
whycode:
|
|
24
|
-
runs-on: ubuntu-latest
|
|
25
|
-
steps:
|
|
26
|
-
- name: Check out PR
|
|
27
|
-
uses: actions/checkout@v4
|
|
28
|
-
with:
|
|
29
|
-
fetch-depth: 0 # WhyCode needs full history
|
|
30
|
-
|
|
31
|
-
- name: Set up Python
|
|
32
|
-
uses: actions/setup-python@v5
|
|
33
|
-
with:
|
|
34
|
-
python-version: "3.11"
|
|
35
|
-
|
|
36
|
-
- name: Install WhyCode
|
|
37
|
-
run: pip install whycode-cli
|
|
38
|
-
|
|
39
|
-
- name: Risk-rank files in this PR
|
|
40
|
-
run: whycode diff --base origin/${{ github.base_ref }}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|