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
Original file line number Diff line number Diff line change
@@ -1,36 +1,22 @@
name: Publish WhyGraph Docker image
name: cd-deploy-whygraph

# Builds and pushes ghcr.io/mtrdesign/whygraph — the self-contained
# scanning-service image the `whygraph` host shim (scripts/install.sh)
# runs. Triggers:
# - any push to main that touches the image, the package source, or this workflow
# - manual workflow_dispatch (with optional pinned codegraph version + release tag)
# runs. Publishing is tied to releases: cutting a GitHub release (tagged
# vX.Y.Z) builds the image and tags it to match that version, so the image
# tag follows the release version 1:1.

on:
push:
branches: [main]
paths:
- 'docker/whygraph/**'
- 'src/whygraph/**'
- 'pyproject.toml'
- '.github/workflows/publish-whygraph-image.yml'
workflow_dispatch:
inputs:
codegraph_version:
description: 'npm version of @colbymchenry/codegraph to bake in (e.g. 1.2.3 or "latest")'
required: false
default: 'latest'
release_tag:
description: 'Optional human-readable release tag (e.g. v1.2.3) — published alongside :latest and :sha-XXXX'
required: false
default: ''
release:
types: [published]

permissions:
contents: read
packages: write

jobs:
build-and-push:
name: Build and push image
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -49,15 +35,20 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# The release tag drives the image tags. A release `v1.2.3` publishes
# :1.2.3, :1.2, :1 and :latest, so consumers can pin as loosely or as
# tightly as they like while the shim's default :latest tracks the
# newest release.
- name: Compute image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/mtrdesign/whygraph
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-
type=raw,value=${{ github.event.inputs.release_tag }},enable=${{ github.event.inputs.release_tag != '' }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest

- name: Build and push
uses: docker/build-push-action@v6
Expand All @@ -69,6 +60,6 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
CODEGRAPH_VERSION=${{ github.event.inputs.codegraph_version || 'latest' }}
CODEGRAPH_VERSION=latest
cache-from: type=gha
cache-to: type=gha,mode=max
50 changes: 50 additions & 0 deletions .github/workflows/ci-code-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: ci-code-checks

# Runs on every pull request: lints with ruff (lint + format check) and runs
# the pytest suite. Both jobs run in parallel and gate the PR.

on:
pull_request:

permissions:
contents: read

jobs:
lint:
name: Lint (ruff)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Install dependencies
run: uv sync --dev

- name: Ruff lint
run: uv run ruff check src/ tests/

- name: Ruff format check
run: uv run ruff format --check src/ tests/

test:
name: Unit tests (pytest)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Install dependencies
run: uv sync --dev

- name: Run pytest
run: uv run pytest
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,9 @@ packages = ["src/whygraph"]
[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
target-version = "py312"
line-length = 88

[dependency-groups]
dev = ["pytest>=8"]
dev = ["pytest>=8", "ruff>=0.8"]
8 changes: 2 additions & 6 deletions src/whygraph/analyze/llm_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,12 @@ def __init__(
self._describe_prompt = (
describe_prompt
if describe_prompt is not None
else resolve(
_PROMPT_COMPONENT, "describe", client.provider, client.model
)
else resolve(_PROMPT_COMPONENT, "describe", client.provider, client.model)
)
self._synthesis_prompt = (
synthesis_prompt
if synthesis_prompt is not None
else resolve(
_PROMPT_COMPONENT, "synthesis", client.provider, client.model
)
else resolve(_PROMPT_COMPONENT, "synthesis", client.provider, client.model)
)

def __repr__(self) -> str:
Expand Down
14 changes: 4 additions & 10 deletions src/whygraph/analyze/rationale_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,7 @@ def _format_evidence(evidence: Sequence[CommitEvidence]) -> str:
n_prs = sum(len(item.pull_requests) for item in evidence)
n_issues = sum(len(item.issues) for item in evidence)
blocks = [
f"Evidence: {len(evidence)} commit(s), {n_prs} PR(s), "
f"{n_issues} issue(s)."
f"Evidence: {len(evidence)} commit(s), {n_prs} PR(s), {n_issues} issue(s)."
]
for item in evidence:
lines = _format_commit(item.commit)
Expand Down Expand Up @@ -224,8 +223,7 @@ def _format_symbol_context(context: SymbolContext) -> str:
lines.append("")
lines.extend(
_format_relations(
f"Called by ({len(context.callers)} caller(s) — "
"blast radius of a change):",
f"Called by ({len(context.callers)} caller(s) — blast radius of a change):",
context.callers,
)
)
Expand Down Expand Up @@ -305,9 +303,7 @@ def _parse_rationale_json(text: str) -> dict:
if not isinstance(value, list) or not all(
isinstance(item, str) for item in value
):
raise RationaleError(
f"rationale key {key!r} must be a list of strings"
)
raise RationaleError(f"rationale key {key!r} must be a list of strings")
return parsed


Expand Down Expand Up @@ -355,9 +351,7 @@ def __init__(
self._rationale_prompt = (
rationale_prompt
if rationale_prompt is not None
else resolve(
_PROMPT_COMPONENT, "rationale", client.provider, client.model
)
else resolve(_PROMPT_COMPONENT, "rationale", client.provider, client.model)
)

def __repr__(self) -> str:
Expand Down
21 changes: 13 additions & 8 deletions src/whygraph/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,7 @@ def packaged_assets_for(target: AgentTarget) -> Traversable:
"""
if target.assets_subdir is None:
raise ValueError(
f"agent {target.name!r} has no bundled assets "
"(assets_subdir is None)"
f"agent {target.name!r} has no bundled assets (assets_subdir is None)"
)
return resources.files("whygraph") / "assets" / target.assets_subdir

Expand Down Expand Up @@ -167,17 +166,19 @@ def install_assets(
If ``target`` has no bundled assets configured.
"""
if not target.has_assets:
raise ValueError(
f"agent {target.name!r} has no bundled assets to install"
)
raise ValueError(f"agent {target.name!r} has no bundled assets to install")
src: Traversable | Path = (
source if source is not None else packaged_assets_for(target)
)
assert target.assets_dest is not None # for type checkers; has_assets guarantees this
assert (
target.assets_dest is not None
) # for type checkers; has_assets guarantees this
dest_root = project_root.joinpath(*target.assets_dest)
merge_set = frozenset(target.assets_merge_files)
result = InstallResult()
_copy_tree(src, dest_root, rel_prefix=(), merge_set=merge_set, force=force, result=result)
_copy_tree(
src, dest_root, rel_prefix=(), merge_set=merge_set, force=force, result=result
)
return result


Expand Down Expand Up @@ -282,7 +283,11 @@ def _merge_block(

# No markers — append the block after the existing content,
# ensuring exactly one blank line of separation.
separator = "" if existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n")
separator = (
""
if existing.endswith("\n\n")
else ("\n" if existing.endswith("\n") else "\n\n")
)
dest_path.write_text(existing + separator + block, encoding="utf-8")
result.overwritten.append(dest_path)

Expand Down
8 changes: 6 additions & 2 deletions src/whygraph/cli/commands/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ def uninstall_cmd() -> None:
removed_any = True

console.print(
"Auto-rescan hooks uninstalled." if removed_any else "No WhyGraph hooks were installed."
"Auto-rescan hooks uninstalled."
if removed_any
else "No WhyGraph hooks were installed."
)


Expand All @@ -163,7 +165,9 @@ def status_cmd() -> None:

console.print(f"Hooks dir: {hooks_dir}")
helper = project_root / HELPER_RELPATH
console.print(f"Helper: {'present' if helper.exists() else 'missing'} ({helper})")
console.print(
f"Helper: {'present' if helper.exists() else 'missing'} ({helper})"
)
for name in HOOK_NAMES:
hp = hooks_dir / name
if hp.exists() and SENTINEL in hp.read_text():
Expand Down
9 changes: 5 additions & 4 deletions src/whygraph/cli/commands/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,7 @@ def _refresh_codegraph(project_root: Path, *, image: str | None) -> None:
try:
refresh_codegraph_index(project_root, image=image)
except CodeGraphBootstrapError as exc:
console.print(
Text(f"CodeGraph refresh skipped — {exc}", style="yellow")
)
console.print(Text(f"CodeGraph refresh skipped — {exc}", style="yellow"))


def _select_github_client(
Expand Down Expand Up @@ -306,7 +304,10 @@ def _render_scan_panel(
]
if github_client is None:
rows.append(
("GitHub", Text(_github_skip_reason(config, remote_enabled), style="yellow"))
(
"GitHub",
Text(_github_skip_reason(config, remote_enabled), style="yellow"),
)
)
else:
rows.append(
Expand Down
4 changes: 1 addition & 3 deletions src/whygraph/cli/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,7 @@ def run_preflight(project_root: Path) -> None:
if c.status == "missing" and c.hint:
console.print(f" install: {c.hint}")

hard_missing = [
c.name for c in checks if c.status == "missing" and not c.soft
]
hard_missing = [c.name for c in checks if c.status == "missing" and not c.soft]
if hard_missing:
names = ", ".join(hard_missing)
raise PreflightError(
Expand Down
8 changes: 2 additions & 6 deletions src/whygraph/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,9 @@ def __post_init__(self) -> None:
try:
LogLevel[self.level.upper()]
except KeyError as exc:
raise ConfigError(
f"invalid logging.level: {self.level!r}"
) from exc
raise ConfigError(f"invalid logging.level: {self.level!r}") from exc
if self.max_bytes < 1:
raise ConfigError(
f"logging.max_bytes must be >= 1, got {self.max_bytes}"
)
raise ConfigError(f"logging.max_bytes must be >= 1, got {self.max_bytes}")
if self.backup_count < 0:
raise ConfigError(
f"logging.backup_count must be >= 0, got {self.backup_count}"
Expand Down
Loading
Loading