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
20 changes: 17 additions & 3 deletions dashboard/src/lib/components/views/ChatView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,23 @@
if (!workspaceReady) return;

if (plannerStore.phase === 'idle' || plannerStore.phase === 'submitted') {
const options = workspacesStore.isSelectedProtected && workspacesStore.verifiedPin
? { pin: workspacesStore.verifiedPin }
: undefined;
// Issue #193: forward workspace_type + github_repo on start
// (not just on submit) so the Planner's pre-fetch can
// resolve same-repo issue/PR refs during the conversation.
const options: {
pin?: string;
workspace_type?: string;
github_repo?: string | null;
} = {
workspace_type: workspacesStore.workspaceType,
github_repo:
workspacesStore.workspaceType === 'github'
? workspacesStore.githubRepo
: null,
};
if (workspacesStore.isSelectedProtected && workspacesStore.verifiedPin) {
options.pin = workspacesStore.verifiedPin;
}

if (plannerStore.phase === 'submitted') {
plannerStore.reset();
Expand Down
11 changes: 10 additions & 1 deletion dashboard/src/lib/stores/planner.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,11 @@ export const plannerStore = {
*/
async startSession(
workspace: string,
options?: { pin?: string }
options?: {
pin?: string;
workspace_type?: string;
github_repo?: string | null;
}
): Promise<boolean> {
const generation = ++requestGeneration;
error = null;
Expand All @@ -130,8 +134,13 @@ export const plannerStore = {
submittedTaskId = null;

try {
// Issue #193: forward workspace_type + github_repo so the
// Planner can resolve same-repo refs like "Issue #113" in
// user messages using the correct default owner/repo.
const payload: Record<string, string> = { workspace };
if (options?.pin) payload.pin = options.pin;
if (options?.workspace_type) payload.workspace_type = options.workspace_type;
if (options?.github_repo) payload.github_repo = options.github_repo;

const res = await fetch('/api/planner', {
method: 'POST',
Expand Down
110 changes: 106 additions & 4 deletions dev-suite/src/agents/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@
# Planner only needs a quick orientation, not full issue bodies.
PLANNER_GITHUB_REF_MAX_CHARS = 1200

# Issue #193: loose heuristic for "the user mentioned a `#N` ref but we
# couldn't resolve it." Used only for warning diagnostics when the
# precise `extract_github_refs` returns nothing because no default
# owner/repo is configured. Intentionally wider than the real pattern.
_LOOSE_HASH_REF_RE = re.compile(r"(?<![\w&])#\d+\b")


# ---------------------------------------------------------------------------
# Models
Expand Down Expand Up @@ -196,6 +202,12 @@ class PlannerSession(BaseModel):
created_at: float = Field(default_factory=time.time)
last_activity: float = Field(default_factory=time.time)
submitted: bool = False
# Issue #193: default GitHub repo for resolving same-repo refs like
# "Issue #113" in user messages. Populated from the dashboard repo
# picker (REMOTE mode) or auto-detected from `.git/config` for LOCAL
# workspaces. Falls back to GITHUB_OWNER/GITHUB_REPO env vars when
# both the session value and auto-detect come up empty.
github_repo: str | None = None

@property
def is_expired(self) -> bool:
Expand Down Expand Up @@ -438,15 +450,67 @@ def build_checklist(task_spec: TaskSpec) -> ChecklistStatus:
# ---------------------------------------------------------------------------


_GIT_REMOTE_GITHUB_RE = re.compile(
r"""
(?:git@github\.com:|https?://(?:[^@/]+@)?github\.com/) # ssh or https prefix
(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+?) # owner/repo
(?:\.git)?/?\s*$ # optional .git + trailing slash
""",
re.VERBOSE | re.IGNORECASE,
)


def _detect_github_repo_from_workspace(workspace: str) -> str | None:
"""Best-effort parse of `.git/config` for the origin GitHub repo.

Returns "owner/repo" or None. Handles both SSH (`git@github.com:o/r.git`)
and HTTPS (`https://github.com/o/r(.git)`) remote formats. Never raises.
"""
if not workspace:
return None
try:
config_path = Path(workspace) / ".git" / "config"
if not config_path.is_file():
return None
text = config_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None

in_origin = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith("[") and line.endswith("]"):
in_origin = line.lower() == '[remote "origin"]'
continue
if not in_origin:
continue
if line.lower().startswith("url"):
_, _, value = line.partition("=")
match = _GIT_REMOTE_GITHUB_RE.search(value.strip())
if match:
owner = match.group("owner")
repo = match.group("repo")
if repo.endswith(".git"):
repo = repo[:-4]
return f"{owner}/{repo}"
return None


def create_planner_session(
workspace: str,
languages: list[str] | None = None,
frameworks: list[str] | None = None,
github_repo: str | None = None,
) -> PlannerSession:
"""Create a new planner session with optional pre-populated fields.

The workspace is always set. Languages and frameworks can be
pre-populated from auto-inference (infer_workspace_stack).

If ``github_repo`` is not provided, attempts to auto-detect it by
parsing ``remote.origin.url`` from the workspace's ``.git/config``.
The resolved repo is used as the default owner/repo when the Planner
pre-fetches issue/PR refs like "Issue #113" from user messages.
"""
task_spec = TaskSpec(
workspace=workspace,
Expand All @@ -455,9 +519,12 @@ def create_planner_session(
)
checklist = build_checklist(task_spec)

resolved_repo = github_repo or _detect_github_repo_from_workspace(workspace)

session = PlannerSession(
task_spec=task_spec,
checklist=checklist,
github_repo=resolved_repo,
)

# System message with pre-populated context — formatted with clear
Expand Down Expand Up @@ -507,8 +574,18 @@ def create_planner_session(
message labelled "=== PRE-FETCHED GITHUB CONTEXT ===" below. Treat that \
block as authoritative source material — never tell the user you cannot \
access GitHub when that block is present; summarise its contents and move \
the task forward. You also work with information the user provides and \
any auto-detected project context.
the task forward.

CRITICAL anti-hallucination rule: if NO "=== PRE-FETCHED GITHUB CONTEXT ===" \
block is present below, the pre-fetch did not run (missing token, wrong \
repo configured, or network error). In that case you MUST NOT invent, \
guess, or describe the issue/PR contents — doing so produces confidently \
wrong task specs. Instead, briefly tell the user the context wasn't \
injected and ask them to paste the issue title and body, or confirm the \
repo configuration.

You also work with information the user provides and any auto-detected \
project context.

Your responsibilities:
1. Understand the user's objective
Expand Down Expand Up @@ -641,8 +718,18 @@ async def _prefetch_github_refs_for_message(
messages that mention the same issue don't re-fetch). Best-effort:
missing GITHUB_TOKEN or network errors quietly return no new items.
"""
default_owner = os.getenv("GITHUB_OWNER", "")
default_repo = os.getenv("GITHUB_REPO", "")
# Resolve default owner/repo for same-repo refs ("Issue #113" without
# an owner/repo prefix). Session-level value wins — that's what the
# dashboard repo picker or the git-remote auto-detect stored.
# Env vars are a fallback for CLI/testing scenarios.
default_owner = ""
default_repo = ""
source_repo = session.github_repo or ""
if source_repo and "/" in source_repo:
default_owner, _, default_repo = source_repo.partition("/")
if not default_owner or not default_repo:
default_owner = os.getenv("GITHUB_OWNER", "") or default_owner
default_repo = os.getenv("GITHUB_REPO", "") or default_repo

detected_refs = extract_github_refs(
user_message,
Expand All @@ -651,6 +738,21 @@ async def _prefetch_github_refs_for_message(
max_refs=PLANNER_MAX_GITHUB_REFS,
)

# Loose heuristic: catch the case where the user clearly referenced
# something like "Issue #113" but we had no default repo configured
# to resolve it. `extract_github_refs` drops same-repo refs silently
# when default_owner/repo are empty, so we'd otherwise have no
# breadcrumb in the logs pointing at the missing config.
if not detected_refs and (not default_owner or not default_repo):
if _LOOSE_HASH_REF_RE.search(user_message):
logger.warning(
"[PLANNER] Session %s message contains '#N' refs but no "
"GitHub repo is configured — pre-fetch skipped. Set the "
"dashboard repo picker, ensure the workspace has a GitHub "
"`remote.origin.url`, or set GITHUB_OWNER/GITHUB_REPO.",
session.session_id,
)

token = os.getenv("GITHUB_TOKEN", "")
if not token:
if detected_refs:
Expand Down
9 changes: 7 additions & 2 deletions dev-suite/src/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,14 @@ async def start_planner_session(
logger.warning("Auto-inference failed for %s: %s", body.workspace, e)
stack = {"languages": [], "frameworks": []}

# Create session
# Create session (Issue #193: pass github_repo through so the
# Planner's deterministic pre-fetch can resolve same-repo refs
# like "Issue #113" in user messages).
session = create_planner_session(
workspace=body.workspace,
languages=stack["languages"],
frameworks=stack["frameworks"],
github_repo=body.github_repo,
)
planner_sessions.create(session)

Expand All @@ -376,11 +379,13 @@ async def start_planner_session(
resp.message = session.messages[0].content if session.messages else ""

logger.info(
"Planner session started: %s (workspace=%s, languages=%s, frameworks=%s)",
"Planner session started: %s (workspace=%s, languages=%s, "
"frameworks=%s, github_repo=%s)",
session.session_id,
body.workspace,
stack["languages"],
stack["frameworks"],
session.github_repo,
)

return _ok(resp)
Expand Down
17 changes: 17 additions & 0 deletions dev-suite/src/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,23 @@ class PlannerStartRequest(BaseModel):
default=None,
description="Admin PIN for protected workspaces.",
)
# Issue #193: let the dashboard tell the Planner which GitHub repo
# to use when resolving same-repo refs like "Issue #113" in user
# messages. If omitted, the server auto-detects from the workspace's
# `.git/config` (LOCAL) or falls back to env vars.
workspace_type: str = Field(
default="local",
description="Workspace kind: 'local' or 'github'.",
)
github_repo: str | None = Field(
default=None,
description=(
"GitHub repository in 'owner/repo' format. Optional — used "
"as the default owner/repo when pre-fetching issue/PR refs "
"mentioned in user messages. Sent by REMOTE mode, "
"auto-detected from `.git/config` in LOCAL mode."
),
)


class PlannerMessageRequest(BaseModel):
Expand Down
Loading
Loading