fix: make default repository configurable (#1429)

* fix: make default repository configurable

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: preserve dashboard repo-less runs

* fix: distinguish explicit repo-less dashboard runs

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-05 13:48:47 -07:00 • committed by GitHub
parent c28b1641f8
commit 449cb5d1a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 347 additions and 13 deletions

View file

@ -8,6 +8,7 @@ configuration in one place. Per-repo style prompts live in
from __future__ import annotations
import logging
import os
from datetime import UTC, datetime
from typing import Any, Literal
@ -41,6 +42,7 @@ class TeamSettingsUpdate(BaseModel):
default_agent_reasoning_effort: str | None = None
default_agent_subagent_model: str | None = None
default_agent_subagent_reasoning_effort: str | None = None
default_repo: str | None = None
default_reviewer_model: str | None = None
default_reviewer_reasoning_effort: str | None = None
default_reviewer_subagent_model: str | None = None
@ -82,6 +84,21 @@ def _client():
return get_client()
def _env_default_repo() -> str | None:
owner = os.environ.get("DEFAULT_REPO_OWNER", "").strip()
name = os.environ.get("DEFAULT_REPO_NAME", "").strip()
return f"{owner}/{name}" if owner and name else None
def _parse_repo(value: object) -> dict[str, str] | None:
if not isinstance(value, str):
return None
owner, sep, name = value.strip().partition("/")
if not sep or not owner.strip() or not name.strip():
return None
return {"owner": owner.strip(), "name": name.strip()}
def _default_settings() -> dict[str, Any]:
fallback_model, fallback_effort = default_model_pair()
return {
@ -95,6 +112,7 @@ def _default_settings() -> dict[str, Any]:
"default_agent_reasoning_effort": fallback_effort,
"default_agent_subagent_model": fallback_model,
"default_agent_subagent_reasoning_effort": fallback_effort,
"default_repo": _env_default_repo(),
"default_reviewer_model": fallback_model,
"default_reviewer_reasoning_effort": fallback_effort,
"default_reviewer_subagent_model": fallback_model,
@ -138,6 +156,7 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
"default_agent_reasoning_effort": update.default_agent_reasoning_effort,
"default_agent_subagent_model": update.default_agent_subagent_model,
"default_agent_subagent_reasoning_effort": update.default_agent_subagent_reasoning_effort,
"default_repo": update.default_repo,
"default_reviewer_model": update.default_reviewer_model,
"default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort,
"default_reviewer_subagent_model": update.default_reviewer_subagent_model,
@ -148,6 +167,11 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
return value
async def get_team_default_repo() -> dict[str, str] | None:
settings = await get_team_settings()
return _parse_repo(settings.get("default_repo"))
async def get_team_default_model(
role: Literal["agent", "reviewer"],
) -> tuple[str, str]:

View file

@ -48,6 +48,7 @@ async def _resolve_run_email(login: str, profile: dict[str, Any]) -> str | None:
class ThreadCreateBody(BaseModel):
prompt: str = Field(min_length=1, max_length=20_000)
repo: str | None = None
repo_explicitly_none: bool = False
model_id: str | None = None
effort: str | None = None
@ -269,13 +270,7 @@ async def get_dashboard_thread(
def _resolve_repo_config(repo: str | None) -> dict[str, str]:
"""Resolve the run's repo from the request, or ``{}`` when none is given.
A repo is optional: the agent identifies and clones the target repo from the
task itself. The dashboard pre-fills the user's default repo on the client,
so the request value is authoritative here — an empty value means an
intentionally repo-less run, not "fall back to the saved default".
"""
"""Resolve the run's repo from the request, or ``{}`` when none is given."""
return _parse_repo(repo) or {}
@ -284,6 +279,7 @@ async def _start_agent_run(
*,
login: str,
repo_config: dict[str, str],
repo_explicitly_none: bool = False,
prompt: str,
title: str | None = None,
model_id: str | None = None,
@ -309,6 +305,8 @@ async def _start_agent_run(
if has_repo:
metadata["repo_owner"] = repo_config["owner"]
metadata["repo_name"] = repo_config["name"]
elif repo_explicitly_none:
metadata["repo_explicitly_none"] = True
client = langgraph_client()
await client.threads.create(thread_id=thread_id, metadata=metadata, if_exists="do_nothing")
@ -323,6 +321,8 @@ async def _start_agent_run(
}
if has_repo:
configurable["repo"] = repo_config
elif repo_explicitly_none:
configurable["repo_explicitly_none"] = True
if chosen_model and chosen_effort:
configurable["agent_model_id"] = chosen_model
configurable["agent_effort"] = chosen_effort
@ -354,6 +354,7 @@ async def create_dashboard_thread(login: str, body: ThreadCreateBody) -> dict[st
thread_id,
login=login,
repo_config=repo_config,
repo_explicitly_none=body.repo_explicitly_none,
prompt=body.prompt.strip(),
model_id=body.model_id,
effort=body.effort,
@ -404,6 +405,8 @@ async def send_dashboard_message(
}
if owner and name:
configurable["repo"] = {"owner": owner, "name": name}
elif metadata.get("repo_explicitly_none") is True:
configurable["repo_explicitly_none"] = True
if chosen_model and chosen_effort:
configurable["agent_model_id"] = chosen_model
configurable["agent_effort"] = chosen_effort

View file

@ -429,8 +429,15 @@ def construct_system_prompt(
linear_issue_number: str = "",
triggering_user_identity: CollaboratorIdentity | None = None,
create_prs: bool = False,
default_repo: dict[str, str] | None = None,
) -> str:
default_prompt_section = _load_default_prompt()
if default_repo and default_repo.get("owner") and default_repo.get("name"):
repo_line = (
"When a repository is not explicitly mentioned, use "
f"`{default_repo['owner']}/{default_repo['name']}`."
)
default_prompt_section += f"\n\n{repo_line}"
# Shell-escape: display names/emails are user-controlled (e.g. O'Connor) and
# are embedded in a `git config` command the agent copies verbatim.
if triggering_user_identity is not None:

View file

@ -39,7 +39,7 @@ from .dashboard.agent_overrides import (
)
from .dashboard.agent_usage import record_agent_thread_usage
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
from .dashboard.team_settings import get_team_default_model_pair
from .dashboard.team_settings import get_team_default_model_pair, get_team_default_repo
from .integrations.langsmith import _configure_github_proxy
from .middleware import (
ModelFallbackMiddleware,
@ -100,6 +100,24 @@ from .utils.sandbox_state import (
)
async def _resolve_prompt_default_repo(configurable: dict[str, Any]) -> dict[str, str] | None:
repo_config = configurable.get("repo")
if isinstance(repo_config, dict):
owner = repo_config.get("owner")
name = repo_config.get("name")
if isinstance(owner, str) and isinstance(name, str):
return {"owner": owner, "name": name}
if configurable.get("repo_explicitly_none") is True:
return None
try:
return await get_team_default_repo()
except Exception:
logger.debug("Failed to load team default repo for prompt", exc_info=True)
return None
async def _start_langsmith_sandbox_if_needed(sandbox_backend: SandboxBackendProtocol) -> None:
"""Start a LangSmith sandbox before operations that require it to be running."""
if os.getenv("SANDBOX_TYPE", "langsmith") != "langsmith":
@ -541,6 +559,8 @@ async def get_agent(config: RunnableConfig) -> Pregel:
except Exception:
logger.debug("Failed to record agent usage for thread %s", thread_id, exc_info=True)
prompt_default_repo = await _resolve_prompt_default_repo(configurable)
logger.info("Returning agent with sandbox for thread %s", thread_id)
main_model = make_model(model_id, **model_kwargs)
subagent_model = make_model(subagent_model_id, **subagent_model_kwargs)
@ -552,6 +572,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
linear_issue_number=linear_issue_number,
triggering_user_identity=triggering_user_identity,
create_prs=always_create_prs,
default_repo=prompt_default_repo,
),
tools=[
http_request,

View file

@ -27,7 +27,7 @@ from .dashboard.agent_overrides import (
from .dashboard.enabled_repos import is_review_repo_enabled
from .dashboard.oauth import build_settings_url
from .dashboard.profiles import get_profile, get_valid_access_token, has_access_token_record
from .dashboard.team_settings import get_team_settings
from .dashboard.team_settings import get_team_default_repo, get_team_settings
from .dashboard.user_mappings import (
email_for_login,
login_for_email,
@ -137,7 +137,7 @@ SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
SLACK_BOT_USER_ID = os.environ.get("SLACK_BOT_USER_ID", "")
SLACK_BOT_USERNAME = os.environ.get("SLACK_BOT_USERNAME", "")
DEFAULT_REPO_OWNER = os.environ.get("DEFAULT_REPO_OWNER", "langchain-ai")
DEFAULT_REPO_NAME = os.environ.get("DEFAULT_REPO_NAME", "langchainplus")
DEFAULT_REPO_NAME = os.environ.get("DEFAULT_REPO_NAME", "")
SLACK_REPO_OWNER = os.environ.get("SLACK_REPO_OWNER", "") or DEFAULT_REPO_OWNER
SLACK_REPO_NAME = os.environ.get("SLACK_REPO_NAME", "") or DEFAULT_REPO_NAME
@ -183,7 +183,7 @@ def get_repo_config_from_team_mapping(
team_identifier: str, project_name: str = ""
) -> dict[str, str]:
"""Look up repository configuration from LINEAR_TEAM_TO_REPO mapping."""
fallback = {"owner": DEFAULT_REPO_OWNER, "name": DEFAULT_REPO_NAME}
fallback = {"owner": DEFAULT_REPO_OWNER, "name": DEFAULT_REPO_NAME} if DEFAULT_REPO_NAME else {}
if not team_identifier or team_identifier not in LINEAR_TEAM_TO_REPO:
return fallback
@ -608,8 +608,14 @@ async def get_slack_repo_config(
logger.exception("Failed to apply dashboard default_repo for Slack user")
if not repo_config:
repo_config = await get_team_default_repo()
if not repo_config and default_owner and default_name:
repo_config = {"owner": default_owner, "name": default_name}
if not repo_config:
raise HTTPException(400, "no default repository configured")
return repo_config
@ -1305,6 +1311,12 @@ async def linear_webhook( # noqa: PLR0911, PLR0912, PLR0915
},
)
if not repo_config:
repo_config = await get_team_default_repo()
if not repo_config:
return {"status": "ignored", "reason": "No default repository configured"}
if not _is_repo_allowed(repo_config):
logger.warning(
"Rejecting Linear webhook: repo '%s/%s' not in allowlist",

View file

@ -1,3 +1,3 @@
# Default Prompt
When a repository is not explicitly mentioned, use `langchain-ai/langchainplus`. Always assume the organization is `langchain-ai`.
When a repository is not explicitly mentioned, use the repository provided in the run metadata or dashboard settings. Do not assume a hardcoded repository name.

View file

@ -1,5 +1,10 @@
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from agent.dashboard import thread_api
@ -8,7 +13,6 @@ def test_resolve_repo_config_parses_request_repo() -> None:
def test_resolve_repo_config_returns_empty_when_no_repo_given() -> None:
# None / blank / malformed all mean an intentionally repo-less run — never an error.
assert thread_api._resolve_repo_config(None) == {}
assert thread_api._resolve_repo_config("") == {}
assert thread_api._resolve_repo_config("not-a-repo") == {}
@ -36,3 +40,116 @@ def test_thread_summary_keeps_repo_when_present() -> None:
)
assert summary["repo"] == "repo"
assert summary["repoFullName"] == "octo/repo"
class _FakeThreadsClient:
async def create(
self, *, thread_id: str, metadata: dict[str, Any], if_exists: str
) -> dict[str, Any]:
return {"thread_id": thread_id, "metadata": metadata, "if_exists": if_exists}
async def update(self, *, thread_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
return {"thread_id": thread_id, "metadata": metadata}
async def get(self, thread_id: str) -> dict[str, Any]:
return {"thread_id": thread_id, "metadata": {}}
class _FakeRunsClient:
def __init__(self) -> None:
self.configurable: dict[str, Any] | None = None
async def create(
self,
thread_id: str,
assistant_id: str,
*,
input: dict[str, Any],
config: dict[str, Any],
if_not_exists: str = "reject",
stream_mode: list[str] | None = None,
stream_resumable: bool = False,
) -> dict[str, str]:
self.configurable = config["configurable"]
return {"run_id": "run-id"}
class _FakeLangGraphClient:
def __init__(self) -> None:
self.threads = _FakeThreadsClient()
self.runs = _FakeRunsClient()
@pytest.fixture
def dashboard_run_client(monkeypatch: pytest.MonkeyPatch) -> _FakeLangGraphClient:
client = _FakeLangGraphClient()
async def fake_get_profile(login: str) -> dict[str, Any]:
return {}
async def fake_ensure_token(login: str) -> None:
return None
async def fake_resolve_email(login: str, profile: dict[str, Any]) -> str:
return "octo@example.com"
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
return client
def test_start_agent_run_omits_repo_less_marker_when_repo_unset(
dashboard_run_client: _FakeLangGraphClient,
) -> None:
asyncio.run(
thread_api._start_agent_run(
"thread-id",
login="octo",
repo_config={},
prompt="do work",
)
)
configurable = dashboard_run_client.runs.configurable
assert configurable is not None
assert "repo_explicitly_none" not in configurable
assert "repo" not in configurable
def test_start_agent_run_marks_repo_less_config_when_explicit(
dashboard_run_client: _FakeLangGraphClient,
) -> None:
asyncio.run(
thread_api._start_agent_run(
"thread-id",
login="octo",
repo_config={},
repo_explicitly_none=True,
prompt="do work",
)
)
configurable = dashboard_run_client.runs.configurable
assert configurable is not None
assert configurable["repo_explicitly_none"] is True
assert "repo" not in configurable
def test_start_agent_run_omits_repo_less_marker_when_repo_configured(
dashboard_run_client: _FakeLangGraphClient,
) -> None:
asyncio.run(
thread_api._start_agent_run(
"thread-id",
login="octo",
repo_config={"owner": "octo", "name": "repo"},
prompt="do work",
)
)
configurable = dashboard_run_client.runs.configurable
assert configurable is not None
assert configurable["repo"] == {"owner": "octo", "name": "repo"}
assert "repo_explicitly_none" not in configurable

View file

@ -120,3 +120,58 @@ async def test_dashboard_followup_on_busy_thread_queues_dashboard_handoff(
assert client.threads.updates[0]["source"] == "dashboard"
assert queued_messages == [{"text": "continue in web", "source": "dashboard"}]
@pytest.mark.asyncio
async def test_dashboard_followup_preserves_explicit_repo_less_thread(
monkeypatch: pytest.MonkeyPatch,
) -> None:
metadata = {
"source": "dashboard",
"github_login": "octocat",
"repo_explicitly_none": True,
}
client = _FakeClient(metadata)
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
monkeypatch.setattr(thread_api, "is_thread_active", _inactive_thread)
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
await thread_api.send_dashboard_message(
"thread-1",
"octocat",
thread_api.ThreadMessageBody(content="continue in web"),
)
run_config = client.runs.created[0]["kwargs"]["config"]["configurable"]
assert run_config["repo_explicitly_none"] is True
assert "repo" not in run_config
@pytest.mark.asyncio
async def test_dashboard_followup_without_repo_metadata_allows_team_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
metadata = {
"source": "dashboard",
"github_login": "octocat",
}
client = _FakeClient(metadata)
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
monkeypatch.setattr(thread_api, "is_thread_active", _inactive_thread)
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", _noop_token_check)
monkeypatch.setattr(thread_api, "get_profile", _empty_profile)
monkeypatch.setattr(thread_api, "_resolve_run_email", _run_email)
await thread_api.send_dashboard_message(
"thread-1",
"octocat",
thread_api.ThreadMessageBody(content="continue in web"),
)
run_config = client.runs.created[0]["kwargs"]["config"]["configurable"]
assert "repo_explicitly_none" not in run_config
assert "repo" not in run_config

View file

@ -0,0 +1,48 @@
from __future__ import annotations
import asyncio
import pytest
from agent import server
def test_resolve_prompt_default_repo_uses_explicit_repo(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_get_team_default_repo() -> dict[str, str] | None:
raise AssertionError("team default should not be loaded")
monkeypatch.setattr(server, "get_team_default_repo", fake_get_team_default_repo)
repo = asyncio.run(
server._resolve_prompt_default_repo({"repo": {"owner": "octo", "name": "repo"}})
)
assert repo == {"owner": "octo", "name": "repo"}
def test_resolve_prompt_default_repo_skips_team_default_for_repo_less_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_get_team_default_repo() -> dict[str, str] | None:
raise AssertionError("team default should not be loaded")
monkeypatch.setattr(server, "get_team_default_repo", fake_get_team_default_repo)
repo = asyncio.run(server._resolve_prompt_default_repo({"repo_explicitly_none": True}))
assert repo is None
def test_resolve_prompt_default_repo_falls_back_to_team_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_get_team_default_repo() -> dict[str, str] | None:
return {"owner": "team", "name": "repo"}
monkeypatch.setattr(server, "get_team_default_repo", fake_get_team_default_repo)
repo = asyncio.run(server._resolve_prompt_default_repo({}))
assert repo == {"owner": "team", "name": "repo"}

View file

@ -395,6 +395,24 @@ def test_get_slack_repo_config_applies_profile_default_repo(
assert repo == {"owner": "profile-owner", "name": "profile-repo"}
def test_get_slack_repo_config_applies_team_default_repo(
monkeypatch: pytest.MonkeyPatch,
) -> None:
threads_client = _FakeThreadsClient(thread={"metadata": {}})
async def fake_get_team_default_repo() -> dict[str, str] | None:
return {"owner": "team-owner", "name": "team-repo"}
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
monkeypatch.setattr(webapp, "get_team_default_repo", fake_get_team_default_repo)
monkeypatch.setattr(webapp, "SLACK_REPO_NAME", "")
monkeypatch.setattr(webapp, "DEFAULT_REPO_NAME", "")
repo = asyncio.run(webapp.get_slack_repo_config("C123", "1.234"))
assert repo == {"owner": "team-owner", "name": "team-repo"}
def _setup_slack_mention_fakes(
monkeypatch: pytest.MonkeyPatch, captured: dict[str, object]
) -> None:

View file

@ -36,6 +36,7 @@ export function AgentsHome() {
createThread.mutate({
prompt,
repo,
repo_explicitly_none: repoOverride === null,
model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null,
})

View file

@ -5,6 +5,7 @@ export type { AgentSchedule, AgentThread, Message }
export interface ThreadCreateRequest {
prompt: string
repo?: string | null
repo_explicitly_none?: boolean
model_id?: string | null
effort?: string | null
}

View file

@ -113,6 +113,7 @@ export interface TeamSettings {
default_agent_reasoning_effort?: string | null;
default_agent_subagent_model?: string | null;
default_agent_subagent_reasoning_effort?: string | null;
default_repo?: string | null;
default_reviewer_model?: string | null;
default_reviewer_reasoning_effort?: string | null;
default_reviewer_subagent_model?: string | null;

View file

@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import type { ModelOption, TeamSettings, UserMapping } from "@/lib/api";
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@ -156,6 +157,11 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
queryFn: api.getTeamSettings,
});
const [error, setError] = useState<string | null>(null);
const [defaultRepoDraft, setDefaultRepoDraft] = useState("");
useEffect(() => {
setDefaultRepoDraft(settings.data?.default_repo ?? "");
}, [settings.data?.default_repo]);
const save = useMutation({
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
@ -204,6 +210,26 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
}
disabled={!settings.data || save.isPending}
/>
<SettingsRow
label="Default Repository"
description="Global fallback used when a run has no explicit repo and the user has no profile default. Use owner/repo."
control={
<Input
className="w-56"
placeholder="owner/repo"
value={defaultRepoDraft}
onChange={(e) => setDefaultRepoDraft(e.target.value)}
onBlur={() =>
settings.data &&
save.mutate({
...settings.data,
default_repo: defaultRepoDraft.trim() || null,
})
}
disabled={!settings.data || save.isPending}
/>
}
/>
<RolePicker
label="Open SWE Reviewer"
description="Model used for PR review runs."