Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e39aa23
feat: add change_gating_enabled column and enrollment API to repo sel…
isiddharthsingh Jun 12, 2026
e4892ae
feat: per-run tool denylist plumbed through agent state, background c…
isiddharthsingh Jun 12, 2026
6d6e224
feat: change-gating service layer (GitHub PR adapter, diff utils, ver…
isiddharthsingh Jun 12, 2026
9b84cd5
feat: PR change-gating webhook filters and investigate_pr Celery task
isiddharthsingh Jun 12, 2026
8fff13d
test: change-gating unit tests (adapter, diff utils, verdict, webhook…
isiddharthsingh Jun 12, 2026
5df452b
feat: Incident Prevention toggle on connected repos
isiddharthsingh Jun 12, 2026
7b3d7a4
chore: add CHANGE_GATING_DRY_RUN env var across compose files and .en…
isiddharthsingh Jun 12, 2026
94a0cdd
feat: transient 'Aurora is reviewing' progress comment on PRs
isiddharthsingh Jun 12, 2026
a465743
feat: incremental CodeRabbit-style inline review comments (post net-n…
isiddharthsingh Jun 12, 2026
c1a02fe
feat: incremental-diff PR reviews (review only new commits, status-ga…
isiddharthsingh Jun 12, 2026
9d2fc90
fix: address CodeRabbit review (dedupe-key release on skip, dup-log g…
isiddharthsingh Jun 12, 2026
c53f8ad
ci: install PyJWT in pre-checks so change_gating adapter/verdict test…
isiddharthsingh Jun 12, 2026
9919b9c
fix: address PR #506 review (quality-gate: secret-scan/ReDoS/reliabil…
isiddharthsingh Jun 12, 2026
c84212b
refactor: clear remaining sonar nits on PR #506 (logging.exception, d…
isiddharthsingh Jun 12, 2026
0a8125f
feat: scope change-gating review to infra/deploy/CI-CD risk with per-…
isiddharthsingh Jun 15, 2026
656b8b6
feat: split github oauth login vs token-honoring and auto-import inst…
isiddharthsingh Jun 15, 2026
4749f84
fix: use real github logo and drop oauth installation skeleton
isiddharthsingh Jun 15, 2026
61234c4
fix: make import_installation_repos body celery-agnostic so CI (stubb…
isiddharthsingh Jun 15, 2026
4bff8ea
refactor: extract per-file diff block helper (cognitive complexity) a…
isiddharthsingh Jun 15, 2026
ae37165
docs: add GitHub App setup guide to Docusaurus and inline permissions…
isiddharthsingh Jun 16, 2026
35ab3b0
fix: close adapter session in finally, guard pagination type, and esc…
isiddharthsingh Jun 17, 2026
fa1203b
refactor: remove tool denylist, add verdict-source logging, reduce ti…
isiddharthsingh Jun 17, 2026
c15fc73
feat: rewrite review prompt to leverage live infrastructure context a…
isiddharthsingh Jun 17, 2026
168501a
refactor: replace tool_denylist with is_pr_review context flag and re…
isiddharthsingh Jun 17, 2026
e2c0bd5
Update github-provider-integration.tsx
damianloch Jun 17, 2026
731d8ae
feat: add NEXT_PUBLIC_ENABLE_INCIDENT_PREVENTION feature flag replaci…
isiddharthsingh Jun 17, 2026
9691823
fix: remove unused os import from change_gating.py
isiddharthsingh Jun 17, 2026
21dd4ac
feat: add verdict decision test to reduce review sensitivity and fals…
isiddharthsingh Jun 17, 2026
95b5eb0
fix: skip NeMo input guardrail for PR change-gating reviews (content-…
isiddharthsingh Jun 18, 2026
ea76cbf
docs: rework GitHub App setup per review; add missing GitHub vars to …
isiddharthsingh Jun 18, 2026
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ GITHUB_APP_WEBHOOK_URL=
GITHUB_APP_SETUP_URL=
# Fallback used when the value is not stored in Vault.
GITHUB_APP_WEBHOOK_SECRET=
# Log PR change-gating reviews instead of posting to GitHub (calibration mode)
CHANGE_GATING_DRY_RUN=false
Comment thread
damianloch marked this conversation as resolved.
Outdated

# GitHub OAuth (only required when GITHUB_AUTH_MODE=oauth or =hybrid).
# Create at https://github.com/settings/developers > New OAuth App.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/linters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
python-version: "3.12"

- name: Install test dependencies
run: pip install pytest==9.0.3 Flask==3.1.3 psycopg2-binary==2.9.12 python-dotenv==1.2.2 pyyaml==6.0.1 pydantic==2.13.3 langchain-core==1.2.31
run: pip install pytest==9.0.3 Flask==3.1.3 psycopg2-binary==2.9.12 python-dotenv==1.2.2 pyyaml==6.0.1 pydantic==2.13.3 langchain-core==1.2.31 PyJWT==2.13.0

- name: Run all tests
working-directory: server
Expand Down
6 changes: 3 additions & 3 deletions client/src/components/connectors/ConnectorRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Github, Server } from "lucide-react";
import { Server } from "lucide-react";
import { isOvhEnabled, isSharePointEnabled, isJiraEnabled, isSpinnakerEnabled, isNotionEnabled, isCloudBeesEnabled } from "@/lib/feature-flags";
import type { ConnectorConfig } from "./types";

Expand Down Expand Up @@ -259,8 +259,8 @@ class ConnectorRegistry {
id: "github",
name: "GitHub",
description: "Integrate with GitHub to manage repositories, track issues, and automate workflows. Connect your GitHub account to enable seamless code collaboration.",
icon: Github,
iconColor: "text-gray-800 dark:text-gray-300",
iconPath: "/github-mark.svg",
iconClassName: "dark:invert",
iconBgColor: "bg-gray-200 dark:bg-gray-800",
category: "Development",
useCustomConnection: true,
Expand Down
84 changes: 80 additions & 4 deletions client/src/components/github-provider-integration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
Expand Down Expand Up @@ -76,6 +77,9 @@ export interface ConnectedRepo {
metadata_status: string;
repo_data: Repository | null;
created_at: string | null;
// PR change gating (incident prevention) — only settable on App-linked repos.
change_gating_enabled?: boolean;
installation_id?: number | null;
}

export interface GitHubAuthConfig {
Expand Down Expand Up @@ -188,6 +192,18 @@ export class GitHubIntegrationService {
if (!response.ok) throw new Error('Failed to update metadata');
}

static async setChangeGating(repoFullName: string, enabled: boolean): Promise<void> {
const response = await fetch(`/api/proxy/github/repo-selections/${encodeURIComponent(repoFullName)}/change-gating`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.error || 'Failed to update incident prevention setting');
}
}

static async generateRepoMetadata(repoFullName: string): Promise<void> {
const response = await fetch('/api/proxy/github/repo-metadata/generate', {
method: 'POST',
Expand Down Expand Up @@ -230,6 +246,7 @@ export default function GitHubProviderIntegration() {
const [savedRepos, setSavedRepos] = useState<ConnectedRepo[]>([]);
const [savedReposLoaded, setSavedReposLoaded] = useState(false);
const [editingMetadata, setEditingMetadata] = useState<Record<string, string>>({});
const [gatingUpdating, setGatingUpdating] = useState<Set<string>>(new Set());
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const popupCleanupsRef = useRef<Array<() => void>>([]);

Expand All @@ -244,6 +261,11 @@ export default function GitHubProviderIntegration() {
// GitHub App installations linked to this user
const [installations, setInstallations] = useState<GitHubInstallation[]>([]);
const [isLoadingInstallations, setIsLoadingInstallations] = useState(false);
// True once the first installations fetch has resolved. The loading skeleton
// is shown only before this flips — otherwise an OAuth-connected user (who
// has no App installations) sees the "Connected GitHub Installations"
// skeleton flash on every window focus/visibility refetch.
const [installationsLoaded, setInstallationsLoaded] = useState(false);
const [installationFilter, setInstallationFilter] = useState<string>('all');

// App installations that exist on GitHub but aren't linked to this Aurora
Expand Down Expand Up @@ -298,7 +320,7 @@ export default function GitHubProviderIntegration() {
setDiscoveredInstallations([]);
}
} catch { setInstallations([]); }
finally { setIsLoadingInstallations(false); }
finally { setIsLoadingInstallations(false); setInstallationsLoaded(true); }
}, [authConfig.app_enabled]);

const startMetadataPolling = useCallback((repos: ConnectedRepo[]) => {
Expand Down Expand Up @@ -526,6 +548,39 @@ export default function GitHubProviderIntegration() {
}
};

const handleChangeGatingToggle = async (repoFullName: string, enabled: boolean) => {
setGatingUpdating(prev => new Set(prev).add(repoFullName));
setSavedRepos(prev => prev.map(r =>
r.repo_full_name === repoFullName ? { ...r, change_gating_enabled: enabled } : r
));
try {
await GitHubIntegrationService.setChangeGating(repoFullName, enabled);
// Re-assert the confirmed value: a loadSavedRepos poll snapshotted
// before the PUT committed can land after the optimistic update and
// clobber it with the stale flag.
setSavedRepos(prev => prev.map(r =>
r.repo_full_name === repoFullName ? { ...r, change_gating_enabled: enabled } : r
));
globalThis.dispatchEvent(new CustomEvent('providerStateChanged'));
} catch (error: unknown) {
const err = error as Error;
setSavedRepos(prev => prev.map(r =>
r.repo_full_name === repoFullName ? { ...r, change_gating_enabled: !enabled } : r
));
toast({
title: "Error",
description: err.message || "Failed to update incident prevention setting",
variant: "destructive",
});
} finally {
setGatingUpdating(prev => {
const next = new Set(prev);
next.delete(repoFullName);
return next;
});
}
};

const handleRegenerate = async (repoFullName: string) => {
try {
await GitHubIntegrationService.generateRepoMetadata(repoFullName);
Expand Down Expand Up @@ -652,6 +707,9 @@ export default function GitHubProviderIntegration() {
setHasLoadedRepos(false);
setExpanded(false);
setInstallations([]);
// Reset so a genuine App reconnect in this session shows the loading
// skeleton again (it's only suppressed AFTER the first load completes).
setInstallationsLoaded(false);
setGithubConnectedOptimistically(false);
githubStatus.refresh();
window.dispatchEvent(new CustomEvent('providerStateChanged'));
Expand Down Expand Up @@ -947,11 +1005,13 @@ export default function GitHubProviderIntegration() {
{/* Expanded content */}
{expanded && githubStatus.isAuthenticated && (
<div className="ml-6 border-l-2 border-muted pl-6 mt-2 space-y-3">
{/* GitHub App installations (rendered only when at least one exists or first load is in flight) */}
{(installations.length > 0 || (isLoadingInstallations && installations.length === 0)) && (
{/* GitHub App installations (rendered only when at least one exists, or
while the FIRST load is still in flight — never re-flash the skeleton
on refetch for OAuth users who have no App installations). */}
{(installations.length > 0 || (isLoadingInstallations && !installationsLoaded)) && (
<div className="space-y-2" data-testid="installations-section">
<p className="text-sm font-medium text-muted-foreground">Connected GitHub Installations</p>
{isLoadingInstallations && installations.length === 0 ? (
{isLoadingInstallations && !installationsLoaded ? (
<div className="space-y-2" data-testid="installations-skeleton">
{[0, 1].map(i => (
<div key={i} className="p-3 rounded-md border border-border animate-pulse">
Expand Down Expand Up @@ -1135,6 +1195,22 @@ export default function GitHubProviderIntegration() {
{isReady && !isEditing && repo.metadata_summary && (
<p className="text-xs text-muted-foreground">{repo.metadata_summary.replace(/\*\*/g, '')}</p>
)}
{repo.installation_id != null && (
<div
className="flex items-center justify-between gap-2 pt-1"
title="Incident Prevention — Aurora reviews PRs for incident risk"
>
<span className="text-xs text-muted-foreground">Incident Prevention</span>
<Switch
checked={!!repo.change_gating_enabled}
disabled={gatingUpdating.has(repo.repo_full_name)}
onCheckedChange={(checked) => handleChangeGatingToggle(repo.repo_full_name, checked)}
className="scale-75 origin-right"
aria-label={`Incident Prevention for ${repo.repo_full_name}`}
data-testid={`repo-change-gating-${repo.repo_full_name}`}
/>
</div>
)}
</div>
);
})}
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.airtight.yml
Comment thread
isiddharthsingh marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ x-common-env: &common-env
NEXT_PUBLIC_ENABLE_SHAREPOINT: ${NEXT_PUBLIC_ENABLE_SHAREPOINT}
NEXT_PUBLIC_ENABLE_NOTION: ${NEXT_PUBLIC_ENABLE_NOTION}
NEXT_PUBLIC_ENABLE_SPINNAKER: ${NEXT_PUBLIC_ENABLE_SPINNAKER}
# Log PR change-gating reviews instead of posting to GitHub (calibration mode)
CHANGE_GATING_DRY_RUN: ${CHANGE_GATING_DRY_RUN:-false}
# Slack OAuth (needed by celery_worker for formatting responses)
SLACK_CLIENT_ID: ${SLACK_CLIENT_ID}
SLACK_CLIENT_SECRET: ${SLACK_CLIENT_SECRET}
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.prod-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ x-common-env: &common-env
GITHUB_APP_SETUP_URL: ${GITHUB_APP_SETUP_URL}
GH_OAUTH_CLIENT_ID: ${GH_OAUTH_CLIENT_ID}
GH_OAUTH_CLIENT_SECRET: ${GH_OAUTH_CLIENT_SECRET}
# Log PR change-gating reviews instead of posting to GitHub (calibration mode)
CHANGE_GATING_DRY_RUN: ${CHANGE_GATING_DRY_RUN:-false}

# AI Safety Guardrails
GUARDRAILS_ENABLED: ${GUARDRAILS_ENABLED:-true}
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ x-common-env: &common-env
GITHUB_APP_SETUP_URL: ${GITHUB_APP_SETUP_URL}
GH_OAUTH_CLIENT_ID: ${GH_OAUTH_CLIENT_ID}
GH_OAUTH_CLIENT_SECRET: ${GH_OAUTH_CLIENT_SECRET}
# Log PR change-gating reviews instead of posting to GitHub (calibration mode)
CHANGE_GATING_DRY_RUN: ${CHANGE_GATING_DRY_RUN:-false}

# AI Safety Guardrails
GUARDRAILS_ENABLED: ${GUARDRAILS_ENABLED:-true}
Expand Down
1 change: 1 addition & 0 deletions server/celery_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
'utils.aws.credential_refresh',
'routes.aws.cloudwatch_tasks',
'tasks.github_webhook_tasks',
'tasks.change_gating',
'routes.github.github_repo_metadata',
'utils.repo_metadata',
'services.actions.scheduler',
Expand Down
8 changes: 5 additions & 3 deletions server/chat/backend/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from chat.backend.agent.model_mapper import ModelMapper
from chat.backend.agent.providers import create_chat_model, get_registry
from chat.backend.agent.weaviate_client import WeaviateClient
from chat.backend.agent.utils.state import State
from chat.backend.agent.utils.state import State, filter_denied_tools
from chat.backend.agent.utils.tool_context_capture import ToolContextCapture
from langchain_core.tools import StructuredTool
from langchain_openai import ChatOpenAI
Expand Down Expand Up @@ -355,8 +355,10 @@ async def agentic_tool_flow(
tools = get_cloud_tools()
if tool_subset is not None:
tools = tool_subset


# Drop denylisted tools (returns a new list — get_cloud_tools() result is cached)
tools = filter_denied_tools(tools, state.tool_denylist)


prompt_text = ''
if state.messages and hasattr(state.messages[-1], 'content'):
# Handle both string and multimodal content
Expand Down
1 change: 1 addition & 0 deletions server/chat/backend/agent/orchestrator/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ def _build_sends(state: State) -> list:
"parent_user_id": user_id,
"parent_org_id": org_id,
"parent_session_id": parent_session_id,
"parent_tool_denylist": getattr(state, "tool_denylist", None),
"wave": wave,
}
sends.append(Send("sub_agent", payload))
Expand Down
4 changes: 4 additions & 0 deletions server/chat/backend/agent/orchestrator/sub_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,10 @@ async def _run(input_dict: dict) -> FindingRef:
is_background=True,
mode="ask",
model=sub_agent_model,
# A parent run's denylist must survive the sub-agent boundary,
# or denied write/exec tools would silently reappear here
# (agentic_tool_flow filters by sub_state.tool_denylist).
tool_denylist=input_dict.get("parent_tool_denylist"),
)

postgres_client = PostgreSQLClient()
Expand Down
4 changes: 2 additions & 2 deletions server/chat/backend/agent/tools/github_repos_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
NoGitHubAuthError,
get_any_auth_for_user,
)
from utils.auth.github_auth_mode import is_oauth_enabled
from utils.auth.github_auth_mode import is_oauth_token_honored
from utils.auth.token_management import get_token_data

logger = logging.getLogger(__name__)
Expand All @@ -24,7 +24,7 @@ class GetConnectedReposArgs(BaseModel):


def _user_has_oauth(user_id: str) -> bool:
if not is_oauth_enabled():
if not is_oauth_token_honored():
return False
creds = get_token_data(user_id, "github")
return bool(creds and creds.get("access_token"))
Expand Down
16 changes: 16 additions & 0 deletions server/chat/backend/agent/utils/state.py
Comment thread
damianloch marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@
from pydantic import BaseModel, ConfigDict


def filter_denied_tools(tools: List[Any], tool_denylist: Optional[List[str]]) -> List[Any]:
"""Return ``tools`` minus those whose ``.name`` is in ``tool_denylist``.

For a non-empty denylist, returns a NEW filtered list (the input may be
the cached ``get_cloud_tools()`` list, which must never be mutated). For
an empty/None denylist, returns the input list object unchanged (callers
must not mutate it). Single source of truth for ``State.tool_denylist``
semantics — used by ``agentic_tool_flow`` and unit-tested directly.
"""
if not tool_denylist:
return tools
denied = set(tool_denylist)
return [t for t in tools if getattr(t, "name", None) not in denied]


class State(BaseModel):
messages: List[AnyMessage] = []
question: str
Expand Down Expand Up @@ -41,6 +56,7 @@ class State(BaseModel):
)
guardrail_blocked: bool = False # Set by workflow when input rail blocks the message
permitted_tools: Optional[set] = None
tool_denylist: Optional[List[str]] = None # Tool names removed from the tool set for this run

# --- Multi-agent orchestrator fields (defaults preserve single-agent behavior) ---
triage_decision: Optional[Dict[str, Any]] = None
Expand Down
9 changes: 9 additions & 0 deletions server/chat/background/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ def run_background_chat(
send_notifications: bool = True,
mode: str = "ask",
rail_text: Optional[str] = None,
tool_denylist: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Run a chat session in the background without WebSocket.

Expand All @@ -446,6 +447,8 @@ def run_background_chat(
only the externally-controlled fields should be checked for prompt
injection; the internal instruction scaffolding should not. When
omitted, falls back to initial_message (legacy behavior).
tool_denylist: Optional list of tool names to remove from the agent's
tool set for this run (e.g. write/exec tools for PR change gating).

Returns:
Dict with session_id, status, and any error information
Expand Down Expand Up @@ -663,6 +666,7 @@ def run_background_chat(
incident_id=incident_id,
mode=mode,
rail_text=rail_text,
tool_denylist=tool_denylist,
))
except Exception as e:
logger.error(f"[BackgroundChat] Exception in asyncio.run(_execute_background_chat): {e}", exc_info=True)
Expand Down Expand Up @@ -1144,6 +1148,7 @@ async def _run_jira_action(
mode: str,
wf,
background_ws,
tool_denylist: Optional[List[str]] = None,
) -> None:
"""Run the Jira filing step after the RCA investigation completes.

Expand Down Expand Up @@ -1194,6 +1199,7 @@ async def _run_jira_action(
mode=mode,
is_background=True,
rca_context=rca_context,
tool_denylist=tool_denylist,
)
logger.info(f"[JiraAction] Starting Jira step for {session_id} (jira_mode={jira_mode})")

Expand Down Expand Up @@ -1239,6 +1245,7 @@ async def _execute_background_chat(
incident_id: Optional[str] = None,
mode: str = "ask",
rail_text: Optional[str] = None,
tool_denylist: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Execute the background chat workflow asynchronously.

Expand Down Expand Up @@ -1370,6 +1377,7 @@ async def _execute_background_chat(
is_postmortem_action=_is_postmortem_action,
rca_context=rca_context,
permitted_tools=_resolve_permitted_tools(user_id),
tool_denylist=tool_denylist,
)
logger.info(
f"[BackgroundChat] Created state with is_background=True, is_postmortem_action={_is_postmortem_action}, "
Expand Down Expand Up @@ -1420,6 +1428,7 @@ async def _execute_background_chat(
mode=mode,
wf=wf,
background_ws=background_ws,
tool_denylist=tool_denylist,
)

if incident_id:
Expand Down
4 changes: 2 additions & 2 deletions server/routes/connector_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ def _check_github(creds_or_user_id, app_runtime_ready: bool = True) -> Dict[str,
Accepts either a credentials dict (with ``_user_id``) for generic
PROVIDER_CHECKERS dispatch, or a plain user_id string for direct callers.
"""
from utils.auth.github_auth_mode import is_app_enabled, is_oauth_enabled
from utils.auth.github_auth_mode import is_app_enabled, is_oauth_token_honored

if isinstance(creds_or_user_id, dict):
user_id = creds_or_user_id.get("_user_id", "")
Expand Down Expand Up @@ -304,7 +304,7 @@ def _check_github(creds_or_user_id, app_runtime_ready: bool = True) -> Dict[str,
except Exception as exc:
logger.debug("[STATUS] github App check failed: %s", exc)

if is_oauth_enabled():
if is_oauth_token_honored():
try:
from utils.auth.token_management import get_token_data
creds = get_token_data(user_id, "github")
Expand Down
Loading
Loading