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
738 changes: 32 additions & 706 deletions backend/src/mainloop/api.py

Large diffs are not rendered by default.

480 changes: 4 additions & 476 deletions backend/src/mainloop/db/postgres.py

Large diffs are not rendered by default.

8 changes: 0 additions & 8 deletions backend/src/mainloop/services/chat_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from dbos import SetWorkflowID
from mainloop.config import settings
from mainloop.db import db
from mainloop.services.task_router import extract_keywords
from mainloop.workflows.dbos_config import worker_queue

from models import (
Expand Down Expand Up @@ -97,7 +96,6 @@ async def spawn_session_impl(args: dict[str, Any]) -> dict[str, Any]:
print(f"[SESSION] spawn_session_impl called with args: {args}")
title = args.get("title", "")
repo_url = args.get("repo_url") # Optional - if provided, this is code work
skip_plan = args.get("skip_plan", False)
request_message_id = args.get(
"request_message_id"
) # ID of the user's original request
Expand Down Expand Up @@ -169,9 +167,6 @@ async def spawn_session_impl(args: dict[str, Any]) -> dict[str, Any]:
conv = await db.create_conversation(user_id, title=title)
print(f"[SESSION] Created conversation: {conv.id}")

# Extract keywords for code work
keywords = extract_keywords(prompt) if is_code_work else []

# Create project from repo URL if provided (so it shows in sidebar)
project_id = None
if repo_url:
Expand Down Expand Up @@ -201,8 +196,6 @@ async def spawn_session_impl(args: dict[str, Any]) -> dict[str, Any]:
# Code work fields (optional)
repo_url=repo_url,
project_id=project_id,
skip_plan=skip_plan,
keywords=keywords,
)
session = await db.create_session(session)
print(f"[SESSION] Session saved to DB: {session.id}")
Expand Down Expand Up @@ -286,7 +279,6 @@ def create_spawn_session_tool(
"title": str, # Short title for the session (e.g., "Add quickstart to README")
"request_message_id": str, # ID from conversation [ID: ...] with the user's request
"repo_url": str, # Optional - if provided, enables code work with GitHub
"skip_plan": bool, # Optional - skip planning phase for code work
},
)
async def spawn_session(args: dict[str, Any]) -> dict[str, Any]:
Expand Down
287 changes: 0 additions & 287 deletions backend/src/mainloop/services/github_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -1078,293 +1078,6 @@ async def get_comment_reactions(
return []


def format_plan_for_issue(plan_text: str) -> str:
"""Format implementation plan as a GitHub issue comment with approval instructions.

Args:
plan_text: The plan markdown content

Returns:
Markdown-formatted comment body with approval instructions

"""
lines = [
"## 📋 Implementation Plan",
"",
plan_text,
"",
"---",
"",
"**To approve:** React with 👍 on this comment, or reply `lgtm`",
"",
"**To request changes:** Reply with your feedback",
"",
"_You can also review in the [Mainloop app](https://mainloop.olds.network)._",
]
return "\n".join(lines)


def format_questions_for_issue(questions: list[dict]) -> str:
"""Format task questions as a GitHub issue comment.

Args:
questions: List of question dicts with id, header, question, options, multi_select

Returns:
Markdown-formatted comment body

"""
lines = [
"## ❓ Questions",
"",
"I have some questions before I can create the implementation plan:",
"",
]

for i, q in enumerate(questions, 1):
header = q.get("header", f"Question {i}")
question_text = q.get("question", "")
options = q.get("options", [])
multi = q.get("multi_select", False)

lines.append(f"### {i}. [{header}]")
lines.append(f"**{question_text}**")
if multi:
lines.append("_(Select all that apply)_")
lines.append("")

for j, opt in enumerate(options):
letter = chr(ord("A") + j)
label = opt.get("label", "")
desc = opt.get("description", "")
if desc:
lines.append(f"- **{letter})** {label} - {desc}")
else:
lines.append(f"- **{letter})** {label}")

lines.append("")

# Add instructions for responding
lines.extend(
[
"---",
"",
"**To answer**, reply to this issue with your choices:",
"```",
]
)
for i in range(1, len(questions) + 1):
lines.append(f"{i}. A")
lines.extend(
[
"```",
"Or answer using the option label: `1. JWT tokens`",
"",
"_You can also answer in the [Mainloop app](https://mainloop.olds.network)._",
]
)

return "\n".join(lines)


def parse_plan_approval_from_comment(comment_body: str) -> str | None:
"""Parse plan approval or revision request from an issue comment.

Returns:
- "approve" if the comment approves the plan
- The revision text if requesting changes
- None if no approval/revision detected

"""
body = comment_body.strip()
body_lower = body.lower()

# Skip bot comments
if (
body_lower.startswith("🤖")
or body_lower.startswith("##")
or body_lower.startswith("❓")
or body_lower.startswith("📋")
):
return None

# Approval keywords (case-insensitive)
approval_patterns = [
"lgtm",
"looks good",
"approve",
"/approve",
"ship it",
"go ahead",
"proceed",
"approved",
]

# Check for approval - must be short or start with approval keyword
for pattern in approval_patterns:
if body_lower == pattern or body_lower.startswith(pattern):
return "approve"

# Check for emoji-only approval
if body.strip() in ["👍", "✅", "🚀", "💯"]:
return "approve"

# If the comment is substantial (>20 chars) and not an approval,
# treat it as revision feedback
if len(body) > 20:
return body # Return as revision text

return None


def parse_question_answers_from_comment(
comment_body: str,
questions: list[dict],
) -> dict[str, str] | None:
"""Parse answers to questions from an issue comment.

Supports formats:
- "1. A" or "1: A" or "1) A"
- "1. JWT tokens" (by label)
- "Auth method: JWT tokens" (by header)
- Multi-select: "1. A, B" or "1. A and B"

Args:
comment_body: The comment text to parse
questions: List of question dicts to match against

Returns:
Dict mapping question ID to answer string, or None if no valid answers found

"""
import re

body = comment_body.strip()
answers: dict[str, str] = {}

# Skip if this looks like a bot comment or system message
if body.startswith("🤖") or body.startswith("##") or body.startswith("❓"):
return None

# Build lookup maps for each question
for i, q in enumerate(questions, 1):
q_id = q.get("id", str(i))
header = q.get("header", "").lower().strip()
options = q.get("options", [])

# Build option lookup: letter -> label, label_lower -> label
option_by_letter: dict[str, str] = {}
option_by_label: dict[str, str] = {}
for j, opt in enumerate(options):
letter = chr(ord("A") + j).upper()
label = opt.get("label", "")
option_by_letter[letter] = label
option_by_label[label.lower().strip()] = label

# Try to find answer by number: "1. A" or "1: JWT" or "1) A, B"
patterns = [
rf"^{i}[\.\:\)]\s*(.+)$", # "1. answer" or "1: answer" or "1) answer"
rf"^\#{i}[\.\:\)]?\s*(.+)$", # "#1. answer"
]

for pattern in patterns:
match = re.search(pattern, body, re.MULTILINE | re.IGNORECASE)
if match:
answer_text = match.group(1).strip()
resolved = _resolve_answer(
answer_text, option_by_letter, option_by_label
)
if resolved:
answers[q_id] = resolved
break

# Also try by header: "Auth method: JWT tokens"
if q_id not in answers and header:
header_pattern = rf"{re.escape(header)}[\:\s]+(.+)"
match = re.search(header_pattern, body, re.MULTILINE | re.IGNORECASE)
if match:
answer_text = match.group(1).strip()
resolved = _resolve_answer(
answer_text, option_by_letter, option_by_label
)
if resolved:
answers[q_id] = resolved

return answers if answers else None


def _resolve_answer(
answer_text: str,
option_by_letter: dict[str, str],
option_by_label: dict[str, str],
) -> str | None:
"""Resolve an answer text to an option label.

Args:
answer_text: Raw answer like "A" or "JWT tokens" or "A, B"
option_by_letter: Map of letter -> label
option_by_label: Map of lowercase label -> label

Returns:
The resolved label(s) or None

"""
import re

answer_text = answer_text.strip()

# First, try exact match on the whole string
if answer_text.upper() in option_by_letter:
return option_by_letter[answer_text.upper()]
if answer_text.lower() in option_by_label:
return option_by_label[answer_text.lower()]

# Try fuzzy match on whole string first (e.g., "JWT" matches "JWT tokens")
for label_lower, label in option_by_label.items():
if (
label_lower.startswith(answer_text.lower())
or answer_text.lower() in label_lower
):
return label

# Handle multi-select: "A, B" or "A and B"
# Only split on comma or "and" - not spaces (to preserve "JWT tokens")
parts = re.split(r"\s*[,&]\s*|\s+and\s+", answer_text)
if len(parts) == 1:
return None # Single part already tried above

resolved_parts = []
seen = set() # Avoid duplicates

for part in parts:
part = part.strip().rstrip(".,;")
if not part:
continue

resolved = None

# Check if it's a letter (A, B, C, etc.)
if len(part) == 1 and part.upper() in option_by_letter:
resolved = option_by_letter[part.upper()]
# Check if it matches an option label
elif part.lower() in option_by_label:
resolved = option_by_label[part.lower()]
# Fuzzy match
else:
for label_lower, label in option_by_label.items():
if label_lower.startswith(part.lower()) or part.lower() in label_lower:
resolved = label
break

if resolved and resolved not in seen:
resolved_parts.append(resolved)
seen.add(resolved)

if resolved_parts:
return ", ".join(resolved_parts)
return None


# ============= Project/Repo Metadata Functions =============


Expand Down
Loading
Loading