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
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1258,14 +1258,18 @@ AI-suggested fixes for PR titles that don't follow the Conventional Commits form
ai-features:
ai-provider: "claude"
ai-model: "claude-opus-4-6[1m]"
conventional-title: "true" # "true": suggest in check run | "false": disabled | "fix": auto-update PR title
conventional-title:
enabled: true
mode: suggest # suggest | fix
timeout-minutes: 10 # default: 10
```

| Mode | Behavior |
|------|----------|
| `"true"` | Shows AI-suggested title in check run output when validation fails |
| `"false"` | Disabled (default) |
| `"fix"` | Automatically updates the PR title with the AI suggestion |
| Setting | Values | Description |
|---------|--------|-------------|
| `enabled` | `true` / `false` | Enable or disable AI conventional title suggestions |
| `mode` | `suggest` (default) | Shows AI-suggested title in check run output when validation fails |
| | `fix` | Automatically updates the PR title with the AI suggestion |
| `timeout-minutes` | integer (default: `10`) | Timeout in minutes for the AI CLI process |

### Cherry-Pick Conflict Resolution

Expand Down
5 changes: 4 additions & 1 deletion examples/.github-webhook-server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ pr-size-thresholds:
ai-features:
ai-provider: "claude" # claude | gemini | cursor
ai-model: "claude-opus-4-6[1m]"
conventional-title: "true" # "true": suggest in checkrun | "false": disabled | "fix": auto-update PR title
conventional-title:
enabled: true
mode: suggest # suggest: show in checkrun | fix: auto-update PR title
timeout-minutes: 10
resolve-cherry-pick-conflicts-with-ai:
enabled: true
timeout-minutes: 10 # Timeout in minutes for AI CLI (default: 10)
Expand Down
5 changes: 4 additions & 1 deletion examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,10 @@ test-oracle:
ai-features:
ai-provider: "claude" # claude | gemini | cursor
ai-model: "claude-opus-4-6[1m]"
conventional-title: "true" # "true": suggest in checkrun | "false": disabled | "fix": auto-update PR title
conventional-title:
enabled: true
mode: suggest # suggest: show in checkrun | fix: auto-update PR title
timeout-minutes: 10
resolve-cherry-pick-conflicts-with-ai:
enabled: true
timeout-minutes: 10 # Timeout in minutes for AI CLI (default: 10)
Expand Down
35 changes: 25 additions & 10 deletions webhook_server/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,32 @@ $defs:
type: string
description: AI model identifier (e.g., claude-opus-4-6[1m], sonnet, gemini-2.5-pro)
conventional-title:
type: string
enum:
- "true"
- "false"
- "fix"
type: object
description: |
AI-powered conventional title suggestions.
- "true": Show AI-suggested title in check run output when validation fails
- "false": Disabled (default)
- "fix": Auto-fix PR title with AI suggestion when validation fails
default: "false"
AI-powered conventional title configuration.
When enabled, suggests or auto-fixes PR titles that don't follow
Conventional Commits format.
properties:
enabled:
type: boolean
description: Enable AI conventional title suggestions
mode:
type: string
enum:
- suggest
- fix
description: |
- suggest: Show AI-suggested title in check run output when validation fails
- fix: Auto-fix PR title with AI suggestion when validation fails
default: suggest
timeout-minutes:
type: integer
minimum: 1
description: Timeout in minutes for the AI CLI process (default 10)
default: 10
required:
- enabled
additionalProperties: false
Comment thread
myakove marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
resolve-cherry-pick-conflicts-with-ai:
type: object
description: |
Expand Down
87 changes: 53 additions & 34 deletions webhook_server/libs/handlers/runner_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ async def run_conventional_title_check(self, pull_request: PullRequest) -> None:
"""
# AI-suggested title (if ai-features configured)
ai_suggestion = await self._get_ai_title_suggestion(
pull_request=pull_request,
title=title,
allowed_names=allowed_names,
is_wildcard=is_wildcard,
Expand All @@ -544,12 +545,16 @@ async def run_conventional_title_check(self, pull_request: PullRequest) -> None:
self.logger.info(f"{self.log_prefix} AI fixing PR title from '{title}' to '{ai_suggestion}'")
try:
await asyncio.to_thread(pull_request.edit, title=ai_suggestion)
if output["text"] is not None:
output["text"] += (
f"\n\n---\n\n### AI Auto-Fix\n\n"
f"Title updated to: `{ai_suggestion}`\n"
f"Check will re-run automatically."
)
output["title"] = "Conventional Title"
output["summary"] = "PR title auto-fixed by AI"
output["text"] = (
f"**AI Auto-Fix Applied**\n\n"
f"Title updated from: `{title}`\n"
f"Title updated to: `{ai_suggestion}`\n"
)
return await self.check_run_handler.set_check_success(
name=CONVENTIONAL_TITLE_STR, output=output
)
except Exception:
self.logger.exception(f"{self.log_prefix} Failed to auto-fix PR title")
if output["text"] is not None:
Expand All @@ -569,7 +574,7 @@ async def run_conventional_title_check(self, pull_request: PullRequest) -> None:
f"Suggestion was invalid or unchanged."
)

elif ai_suggestion and ai_mode == "true" and output["text"] is not None:
elif ai_suggestion and ai_mode == "suggest" and output["text"] is not None:
output["text"] += f"\n\n---\n\n### AI-Suggested Title\n\n> {ai_suggestion}\n"

await self.check_run_handler.set_check_failure(name=CONVENTIONAL_TITLE_STR, output=output)
Expand All @@ -578,20 +583,25 @@ def _get_ai_conventional_title_mode(self) -> str | None:
"""Get the conventional-title AI mode from config.

Returns:
"true" for suggestion mode, "fix" for auto-fix mode, or None if disabled.
"suggest" for suggestion mode, "fix" for auto-fix mode, or None if disabled.
"""
ai_config = self.github_webhook.ai_features
if not ai_config:
return None

ct_value = ai_config.get("conventional-title", "false")
if ct_value == "true":
return "true"
if ct_value == "fix":
return "fix"
return None
ct_config = ai_config.get("conventional-title")
if not isinstance(ct_config, dict) or not ct_config.get("enabled"):
return None

mode = ct_config.get("mode", "suggest")
if mode not in ("suggest", "fix"):
self.logger.warning(f"{self.log_prefix} Invalid conventional-title mode '{mode}', defaulting to 'suggest'")
return "suggest"
return mode

async def _get_ai_title_suggestion(self, title: str, allowed_names: list[str], *, is_wildcard: bool) -> str | None:
async def _get_ai_title_suggestion(
self, pull_request: PullRequest, title: str, allowed_names: list[str], *, is_wildcard: bool
) -> str | None:
"""Get an AI-suggested conventional title when validation fails.

Returns the suggestion string or None if AI features are not configured or on error.
Expand All @@ -606,21 +616,24 @@ async def _get_ai_title_suggestion(self, title: str, allowed_names: list[str], *

ai_provider, ai_model = ai_result

ai_config = self.github_webhook.ai_features
ct_config = ai_config.get("conventional-title", {}) if ai_config else {}
timeout_minutes = ct_config.get("timeout-minutes", 10) if isinstance(ct_config, dict) else 10

if is_wildcard:
types_info = "Any type name is accepted (wildcard mode)."
else:
types_info = f"Allowed types: {', '.join(allowed_names)}"

# The repository clone is already checked out to the PR branch
# (done by _clone_repository in github_api.py), so the AI CLI has
# full access to the PR's commits and changes from the cwd.
prompt = (
"You are in a git repository checked out to a PR branch.\n"
"Look at the recent commits and changes to understand what this PR does.\n"
f"Current PR title: {title}\n"
f"{types_info}\n"
f"Format: <type>[optional scope]: <description>\n"
"Reply with ONLY the suggested title, nothing else."
f"Required format: <type>[optional scope]: <description>\n"
f"Output ONLY the corrected title on a single line.\n"
f"Do NOT include any explanation, reasoning, markdown, or quotes.\n"
f"Example output: feat: add user authentication"
)

cli_flags: list[str] = []
Expand All @@ -632,22 +645,28 @@ async def _get_ai_title_suggestion(self, title: str, allowed_names: list[str], *
cli_flags = ["--force"]

try:
success, result = await call_ai_cli(
prompt=prompt,
ai_provider=ai_provider,
ai_model=ai_model,
cwd=self.github_webhook.clone_repo_dir,
cli_flags=cli_flags,
)
async with self._checkout_worktree(pull_request=pull_request) as (wt_success, worktree_path, _, _):
if not wt_success:
self.logger.warning(f"{self.log_prefix} Failed to create worktree for AI title suggestion")
return None

success, result = await call_ai_cli(
prompt=prompt,
ai_provider=ai_provider,
ai_model=ai_model,
cwd=worktree_path,
cli_flags=cli_flags,
timeout_minutes=timeout_minutes,
)

if success:
# Clean up the response - take first line, strip backticks/quotes
suggestion = result.strip().splitlines()[0].strip().strip("`").strip('"').strip("'")
self.logger.info(f"{self.log_prefix} AI suggested title: {suggestion}")
return suggestion
if success:
# Clean up the response - take first line, strip backticks/quotes
suggestion = result.strip().splitlines()[0].strip().strip("`").strip('"').strip("'")
self.logger.info(f"{self.log_prefix} AI suggested title: {suggestion}")
return suggestion

self.logger.warning(f"{self.log_prefix} AI title suggestion failed: {result}")
return None
self.logger.warning(f"{self.log_prefix} AI title suggestion failed: {result}")
return None

except Exception:
self.logger.exception(f"{self.log_prefix} AI title suggestion failed unexpectedly")
Expand Down
Loading