Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions culprit_finder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ culprit-finder --repo <OWNER/REPO> --start <GOOD_SHA> --end <BAD_SHA> --workflow
- `--start`: The full or short SHA of the last known **good** commit.
- `--end`: The full or short SHA of the first known **bad** commit.
- `--workflow`: The filename of the GitHub Actions workflow to run (e.g., `ci.yml`, `tests.yaml`).
- `--clear-cache`: (Optional) Deletes the local state file before execution to start a fresh bisection.

### State Persistence and Resuming

Culprit Finder automatically saves its progress after each commit is tested. If the process is interrupted (e.g., via `CTRL+C`) or fails due to network issues, you can resume from where you left off.

1. **Automatic Save**: The state is stored locally in `~/.github_culprit_finder/`.
2. **Resume**: When you restart the tool with the same `--repo` and `--workflow`, it will prompt you to resume from the saved state.
3. **Caching**: Results for individual commits are cached. If the bisection hits a commit that was already tested in a previous session, it will use the cached "PASS" or "FAIL" result instead of triggering a new GitHub Action.


### Example

Expand Down
68 changes: 60 additions & 8 deletions culprit_finder/src/culprit_finder/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import sys

from culprit_finder import culprit_finder
from culprit_finder import culprit_finder_state
from culprit_finder import github

logging.basicConfig(
Expand Down Expand Up @@ -49,6 +50,11 @@ def main() -> None:
required=True,
help="Workflow filename (e.g., build_and_test.yml)",
)
parser.add_argument(
"--clear-cache",
action="store_true",
help="Deletes the local state file before execution",
)

args = parser.parse_args()

Expand All @@ -61,6 +67,42 @@ def main() -> None:
logging.error("Not authenticated with GitHub CLI or GH_TOKEN env var is not set.")
sys.exit(1)

state_persister = culprit_finder_state.StatePersister(
repo=args.repo, workflow=args.workflow
)

if args.clear_cache and state_persister.exists():
state_persister.delete()

if state_persister.exists():
print("\nA previous bisection state was found.")
resume = input("Do you want to resume from the saved state? (y/n): ").lower()
if resume not in ["y", "yes"]:
print("Starting a new bisection. Deleting the old state...")
state_persister.delete()
state: culprit_finder_state.CulpritFinderState = {
"repo": args.repo,
"workflow": args.workflow,
"original_start": args.start,
"original_end": args.end,
"current_good": "",
"current_bad": "",
"cache": {},
}
else:
state = state_persister.load()
print("Resuming from the saved state.")
else:
state: culprit_finder_state.CulpritFinderState = {
"repo": args.repo,
"workflow": args.workflow,
"original_start": args.start,
"original_end": args.end,
"current_good": "",
"current_bad": "",
"cache": {},
}

logging.info("Initializing culprit finder for %s", args.repo)
logging.info("Start commit: %s", args.start)
logging.info("End commit: %s", args.end)
Expand All @@ -80,15 +122,25 @@ def main() -> None:
workflow_file=args.workflow,
has_culprit_finder_workflow=has_culprit_finder_workflow,
github_client=gh_client,
state=state,
state_persister=state_persister,
)
culprit_commit = finder.run_bisection()
if culprit_commit:
commit_message = culprit_commit["message"].splitlines()[0]
print(
f"\nThe culprit commit is: {commit_message} (SHA: {culprit_commit['sha']})",
)
else:
print("No culprit commit found.")

try:
culprit_commit = finder.run_bisection()
if culprit_commit:
commit_message = culprit_commit["message"].splitlines()[0]
print(
f"\nThe culprit commit is: {commit_message} (SHA: {culprit_commit['sha']})",
)
else:
print("No culprit commit found.")

state_persister.delete()
except KeyboardInterrupt:
logging.info("Bisection interrupted by user (CTRL+C). Saving current state...")
state_persister.save(state)
logging.info("State saved.")


if __name__ == "__main__":
Expand Down
29 changes: 28 additions & 1 deletion culprit_finder/src/culprit_finder/culprit_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import logging
import uuid
from culprit_finder import github
from culprit_finder import culprit_finder_state


CULPRIT_FINDER_WORKFLOW_NAME = "culprit_finder.yml"
Expand All @@ -24,6 +25,8 @@ def __init__(
workflow_file: str,
has_culprit_finder_workflow: bool,
github_client: github.GithubClient,
state: culprit_finder_state.CulpritFinderState,
state_persister: culprit_finder_state.StatePersister,
):
"""
Initializes the CulpritFinder instance.
Expand All @@ -34,6 +37,9 @@ def __init__(
end_sha: The SHA of the first known bad commit.
workflow_file: The name of the workflow file to test (e.g., 'build.yml').
has_culprit_finder_workflow: Whether the repo being tested has a Culprit Finder workflow.
github_client: The GithubClient instance used to interact with GitHub.
state: The CulpritFinderState object containing the current bisection state.
state_persister: The StatePersister object used to save the bisection state.
"""
self._repo = repo
self._start_sha = start_sha
Expand All @@ -42,6 +48,8 @@ def __init__(
self._workflow_file = workflow_file
self._has_culprit_finder_workflow = has_culprit_finder_workflow
self._gh_client = github_client
self._state = state
self._state_persister = state_persister

def _wait_for_workflow_completion(
self,
Expand Down Expand Up @@ -175,8 +183,21 @@ def run_bisection(self) -> github.Commit | None:

while bad_idx - good_idx > 1:
mid_idx = (good_idx + bad_idx) // 2

commit_sha = commits[mid_idx]["sha"]

if commit_sha in self._state["cache"]:
logging.info("Using cached result for commit %s", commit_sha)
is_good = self._state["cache"][commit_sha] == "PASS"

if is_good:
good_idx = mid_idx
logging.info("Commit %s is good", commit_sha)
else:
bad_idx = mid_idx
logging.info("Commit %s is bad", commit_sha)

continue

branch_name = f"culprit-finder/test-{commit_sha}_{uuid.uuid4()}"

# Ensure the branch does not exist from a previous run
Expand All @@ -193,11 +214,17 @@ def run_bisection(self) -> github.Commit | None:

if is_good:
good_idx = mid_idx
self._state["current_good"] = commit_sha
self._state["cache"][commit_sha] = "PASS"
logging.info("Commit %s is good", commit_sha)
else:
bad_idx = mid_idx
self._state["current_bad"] = commit_sha
self._state["cache"][commit_sha] = "FAIL"
logging.info("Commit %s is bad", commit_sha)

self._state_persister.save(self._state)

if bad_idx == len(commits):
return None

Expand Down
119 changes: 119 additions & 0 deletions culprit_finder/src/culprit_finder/culprit_finder_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Manages the state of the culprit finder to persist it across runs."""

from __future__ import annotations
import json
from pathlib import Path
from typing import TypedDict, Literal


_STATE_ROOT_DIRNAME = ".github_culprit_finder"

COMMIT_STATUS = Literal["PASS", "FAIL"]


class CulpritFinderState(TypedDict):
repo: str
workflow: str
original_start: str
original_end: str
current_good: str
current_bad: str
cache: dict[str, COMMIT_STATUS]


def _sanitize_component(value: str) -> str:
"""
Sanitizes a string so it is safe to use as a filesystem path component.

This is used when turning user-provided values like repository names
(`owner/repo`) and workflow filenames into directory/file names.

The sanitization is intentionally conservative and focuses on preventing:
- path traversal (`..`)
- accidental directory separators on Windows (`\\`)
- characters that are problematic in filenames on common platforms (e.g. `:`)

Args:
value: Raw component string (e.g. repo owner, repo name, workflow file name).

Returns:
A sanitized string suitable for use as a single path component.
"""
return (
value.strip()
.replace("..", ".")
.replace("\\", "_")
.replace(":", "_")
.replace("|", "_")
)


class StatePersister:
"""Handles the persistence of the CulpritFinderState."""

def __init__(self, repo: str, workflow: str):
self._repo = repo
self._workflow = workflow

def _get_base_dir(self) -> Path:
"""Returns the base directory for the repo state."""
home = Path.home()
root = home / _STATE_ROOT_DIRNAME

repo_path = Path(*[
_sanitize_component(p) for p in self._repo.split("/") if p.strip()
])

return root / repo_path

def _get_file_path(self) -> Path:
"""Returns the path to the state file."""
safe_workflow = _sanitize_component(self._workflow) or "default"
return self._get_base_dir() / f"{safe_workflow}.json"

def _ensure_directory_exists(self) -> None:
"""Creates the necessary directories for storage."""
self._get_base_dir().mkdir(parents=True, exist_ok=True)

def exists(self) -> bool:
"""Checks if the state file exists.

Returns:
bool: True if the state file exists, False otherwise.
"""
return self._get_file_path().exists()

def save(self, state: CulpritFinderState) -> None:
"""Saves the state to disk.

Args:
state: The CulpritFinderState object to save.
"""
self._ensure_directory_exists()
state_path = self._get_file_path()
with state_path.open("w", encoding="utf-8") as f:
json.dump(state, f)

def load(self) -> CulpritFinderState:
"""Loads the state from disk.

Returns:
CulpritFinderState: The loaded CulpritFinderState object.
"""
state_path = self._get_file_path()
with state_path.open("r", encoding="utf-8") as f:
data = json.load(f)
return {
"repo": data["repo"],
"workflow": data["workflow"],
"original_start": data["original_start"],
"original_end": data["original_end"],
"current_good": data.get("current_good", ""),
"current_bad": data.get("current_bad", ""),
"cache": data.get("cache", {}),
}

def delete(self) -> None:
"""Deletes the state file."""
state_path = self._get_file_path()
state_path.unlink()
Loading