Merge pull request #108 from Pythagora-io/feature/ENG-443-loading-apps-convo

feature/ENG-443-loading-apps-convo
This commit is contained in:
LeonOstrez
2025-04-03 14:56:24 +01:00
committed by GitHub
29 changed files with 871 additions and 176 deletions

View File

@@ -9,6 +9,15 @@ from core.agents.convo import AgentConvo
from core.agents.mixins import ChatWithBreakdownMixin, TestSteps
from core.agents.response import AgentResponse
from core.config import CHECK_LOGS_AGENT_NAME, magic_words
from core.config.actions import (
BH_ADDITIONAL_FEEDBACK,
BH_HUMAN_TEST_AGAIN,
BH_IS_BUG_FIXED,
BH_START_BUG_HUNT,
BH_START_USER_TEST,
BH_STARTING_PAIR_PROGRAMMING,
BH_WAIT_BUG_REP_INSTRUCTIONS,
)
from core.config.constants import CONVO_ITERATIONS_LIMIT
from core.db.models.project_state import IterationStatus
from core.llm.parser import JSONParser
@@ -45,12 +54,6 @@ class ImportantLogsForDebugging(BaseModel):
logs: list[ImportantLog] = Field(description="Important logs that will help the human debug the current bug.")
BH_STARTING_PAIR_PROGRAMMING = "Start pair programming for task #{}"
BH_START_USER_TEST = "Start user testing for task #{}"
BH_WAIT_BUG_REP_INSTRUCTIONS = "Awaiting bug reproduction instructions for task #{}"
BH_START_BUG_HUNT = "Start bug hunt for task #{}"
class BugHunter(ChatWithBreakdownMixin, BaseAgent):
agent_type = "bug-hunter"
display_name = "Bug Hunter"
@@ -162,7 +165,7 @@ class BugHunter(ChatWithBreakdownMixin, BaseAgent):
await self.ui.send_run_command(self.current_state.run_command)
await self.ask_question(
"Please test the app again.",
BH_HUMAN_TEST_AGAIN,
buttons={"done": "I am done testing"},
buttons_only=True,
default="continue",
@@ -173,7 +176,7 @@ class BugHunter(ChatWithBreakdownMixin, BaseAgent):
if awaiting_user_test:
buttons = {"yes": "Yes, the issue is fixed", "no": "No", "start_pair_programming": "Start Pair Programming"}
user_feedback = await self.ask_question(
"Is the bug you reported fixed now?",
BH_IS_BUG_FIXED,
buttons=buttons,
default="yes",
buttons_only=True,
@@ -201,7 +204,7 @@ class BugHunter(ChatWithBreakdownMixin, BaseAgent):
}
)
user_feedback = await self.ask_question(
"Please add any additional feedback that could help Pythagora solve this bug",
BH_ADDITIONAL_FEEDBACK,
buttons=buttons,
default="continue",
extra_info="collect_logs",

View File

@@ -11,6 +11,7 @@ from core.agents.convo import AgentConvo
from core.agents.mixins import FileDiffMixin
from core.agents.response import AgentResponse, ResponseType
from core.config import CODE_MONKEY_AGENT_NAME, CODE_REVIEW_AGENT_NAME, DESCRIBE_FILES_AGENT_NAME
from core.config.actions import CM_UPDATE_FILES
from core.db.models import File
from core.llm.parser import JSONParser, OptionalCodeBlockParser
from core.log import get_logger
@@ -57,9 +58,6 @@ class FileDescription(BaseModel):
)
CM_UPDATE_FILES = "Updating files"
class CodeMonkey(FileDiffMixin, BaseAgent):
agent_type = "code-monkey"
display_name = "Code Monkey"

View File

@@ -10,6 +10,14 @@ from core.agents.convo import AgentConvo
from core.agents.mixins import ChatWithBreakdownMixin, RelevantFilesMixin
from core.agents.response import AgentResponse
from core.config import PARSE_TASK_AGENT_NAME, TASK_BREAKDOWN_AGENT_NAME
from core.config.actions import (
DEV_EXECUTE_TASK,
DEV_TASK_BREAKDOWN,
DEV_TASK_REVIEW_FEEDBACK,
DEV_TASK_START,
DEV_TROUBLESHOOT,
DEV_WAIT_TEST,
)
from core.db.models.project_state import IterationStatus, TaskStatus
from core.db.models.specification import Complexity
from core.llm.parser import JSONParser
@@ -72,13 +80,6 @@ class TaskSteps(BaseModel):
steps: list[Step]
DEV_WAIT_TEST = "Awaiting user test"
DEV_TASK_STARTING = "Starting task #{}"
DEV_TASK_BREAKDOWN = "Task #{} breakdown"
DEV_TROUBLESHOOT = "Troubleshooting #{}"
DEV_TASK_REVIEW_FEEDBACK = "Task review feedback"
class Developer(ChatWithBreakdownMixin, RelevantFilesMixin, BaseAgent):
agent_type = "developer"
display_name = "Developer"
@@ -214,6 +215,8 @@ class Developer(ChatWithBreakdownMixin, RelevantFilesMixin, BaseAgent):
# Check which files are relevant to the current task
await self.get_relevant_files_parallel()
current_task_index = self.current_state.tasks.index(current_task)
await self.send_message("Thinking about how to implement this task ...")
await self.ui.start_breakdown_stream()
@@ -255,6 +258,7 @@ class Developer(ChatWithBreakdownMixin, RelevantFilesMixin, BaseAgent):
# There might be state leftovers from previous tasks that we need to clean here
self.next_state.modified_files = {}
self.set_next_steps(response, source)
self.next_state.action = DEV_TASK_START.format(current_task_index + 1)
await telemetry.trace_code_event(
"task-start",
{
@@ -322,12 +326,11 @@ class Developer(ChatWithBreakdownMixin, RelevantFilesMixin, BaseAgent):
"task_index": task_index,
}
)
self.next_state.action = DEV_TASK_STARTING.format(task_index)
await self.send_message(f"Starting task #{task_index} with the description:\n\n" + description)
if self.current_state.run_command:
await self.ui.send_run_command(self.current_state.run_command)
user_response = await self.ask_question(
"Do you want to execute the above task?",
DEV_EXECUTE_TASK,
buttons=buttons,
default="yes",
buttons_only=True,

View File

@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
from core.agents.base import BaseAgent
from core.agents.convo import AgentConvo
from core.agents.response import AgentResponse
from core.config.actions import EX_RUN_COMMAND, EX_SKIP_COMMAND, RUN_COMMAND
from core.llm.parser import JSONParser
from core.log import get_logger
from core.proc.exec_log import ExecLog
@@ -32,10 +33,6 @@ class CommandResult(BaseModel):
)
EX_SKIP_COMMAND = 'Skip "{}"'
EX_RUN_COMMAND = 'Run "{}"'
class Executor(BaseAgent):
agent_type = "executor"
display_name = "Executor"
@@ -82,9 +79,9 @@ class Executor(BaseAgent):
timeout = options.get("timeout")
if timeout:
q = f"Can I run command: {cmd} with {timeout}s timeout?"
q = f"{RUN_COMMAND} {cmd} with {timeout}s timeout?"
else:
q = f"Can I run command: {cmd}?"
q = f"{RUN_COMMAND} {cmd}?"
confirm = await self.ask_question(
q,

View File

@@ -8,6 +8,15 @@ from core.agents.git import GitMixin
from core.agents.mixins import FileDiffMixin
from core.agents.response import AgentResponse
from core.config import FRONTEND_AGENT_NAME
from core.config.actions import (
FE_CHANGE_REQ,
FE_CONTINUE,
FE_DONE_WITH_UI,
FE_INIT,
FE_ITERATION,
FE_ITERATION_DONE,
FE_START,
)
from core.llm.parser import DescriptiveCodeBlockParser
from core.log import get_logger
from core.telemetry import telemetry
@@ -16,12 +25,6 @@ from core.ui.base import ProjectStage
log = get_logger(__name__)
FE_INIT = "Frontend init"
FE_START = "Frontend start"
FE_CONTINUE = "Frontend continue"
FE_ITERATION = "Frontend iteration"
FE_ITERATION_DONE = "Frontend iteration done"
class Frontend(FileDiffMixin, GitMixin, BaseAgent):
agent_type = "frontend"
@@ -176,7 +179,7 @@ class Frontend(FileDiffMixin, GitMixin, BaseAgent):
await self.ui.send_project_stage({"stage": ProjectStage.ITERATE_FRONTEND})
answer = await self.ask_question(
"Do you want to change anything or report a bug? Keep in mind that currently ONLY frontend is implemented.",
FE_CHANGE_REQ,
buttons={
"yes": "I'm done building the UI",
},
@@ -187,7 +190,7 @@ class Frontend(FileDiffMixin, GitMixin, BaseAgent):
if answer.button == "yes":
answer = await self.ask_question(
"Are you sure you're done building the UI and want to start building the backend functionality now?",
FE_DONE_WITH_UI,
buttons={
"yes": "Yes, let's build the backend",
"no": "No, continue working on the UI",

View File

@@ -1,5 +1,6 @@
from core.agents.base import BaseAgent
from core.agents.response import AgentResponse, ResponseType
from core.config.actions import HUMAN_INTERVENTION_QUESTION
class HumanInput(BaseAgent):
@@ -16,7 +17,7 @@ class HumanInput(BaseAgent):
description = step["human_intervention_description"]
await self.ask_question(
f"I need human intervention: {description}",
f"{HUMAN_INTERVENTION_QUESTION} {description}",
buttons={"continue": "Continue"},
default="continue",
buttons_only=True,

View File

@@ -1,13 +1,14 @@
import asyncio
import json
from difflib import unified_diff
from typing import List, Optional
from pydantic import BaseModel, Field
from core.agents.convo import AgentConvo
from core.agents.response import AgentResponse
from core.cli.helpers import get_line_changes
from core.config import GET_RELEVANT_FILES_AGENT_NAME, TASK_BREAKDOWN_AGENT_NAME, TROUBLESHOOTER_BUG_REPORT
from core.config.actions import MIX_BREAKDOWN_CHAT_PROMPT
from core.config.constants import CONVO_ITERATIONS_LIMIT
from core.llm.parser import JSONParser
from core.log import get_logger
@@ -56,7 +57,7 @@ class ChatWithBreakdownMixin:
)
chat = await self.ask_question(
"Are you happy with the breakdown? Now is a good time to ask questions or suggest changes.",
MIX_BREAKDOWN_CHAT_PROMPT,
buttons={"yes": "Yes, looks good!"},
default="yes",
verbose=False,
@@ -191,18 +192,4 @@ class FileDiffMixin:
:return: a tuple (added_lines, deleted_lines)
"""
from_lines = old_content.splitlines(keepends=True)
to_lines = new_content.splitlines(keepends=True)
diff_gen = unified_diff(from_lines, to_lines)
added_lines = 0
deleted_lines = 0
for line in diff_gen:
if line.startswith("+") and not line.startswith("+++"): # Exclude the file headers
added_lines += 1
elif line.startswith("-") and not line.startswith("---"): # Exclude the file headers
deleted_lines += 1
return added_lines, deleted_lines
return get_line_changes(old_content, new_content)

View File

@@ -2,6 +2,7 @@ from core.agents.base import BaseAgent
from core.agents.convo import AgentConvo
from core.agents.response import AgentResponse, ResponseType
from core.config import SPEC_WRITER_AGENT_NAME
from core.config.actions import SPEC_CHANGE_FEATURE_STEP_NAME, SPEC_CHANGE_STEP_NAME, SPEC_CREATE_STEP_NAME
from core.db.models import Complexity
from core.db.models.project_state import IterationStatus
from core.llm.parser import StringParser
@@ -14,10 +15,6 @@ ANALYZE_THRESHOLD = 1500
INITIAL_PROJECT_HOWTO_URL = (
"https://github.com/Pythagora-io/gpt-pilot/wiki/How-to-write-a-good-initial-project-description"
)
SPEC_CREATE_STEP_NAME = "Create specification"
SPEC_CHANGE_STEP_NAME = "Change specification"
SPEC_CHANGE_FEATURE_STEP_NAME = "Change specification due to new feature"
log = get_logger(__name__)

View File

@@ -1,13 +1,12 @@
from core.agents.base import BaseAgent
from core.agents.git import GitMixin
from core.agents.response import AgentResponse
from core.config.actions import TC_TASK_DONE
from core.log import get_logger
from core.telemetry import telemetry
log = get_logger(__name__)
TC_TASK_DONE = "Task #{} complete"
class TaskCompleter(BaseAgent, GitMixin):
agent_type = "pythagora"

View File

@@ -9,6 +9,13 @@ from core.agents.convo import AgentConvo
from core.agents.mixins import RelevantFilesMixin
from core.agents.response import AgentResponse
from core.config import TECH_LEAD_EPIC_BREAKDOWN, TECH_LEAD_PLANNING
from core.config.actions import (
TL_CREATE_INITIAL_EPIC,
TL_CREATE_PLAN,
TL_EDIT_DEV_PLAN,
TL_INITIAL_PROJECT_NAME,
TL_START_FEATURE,
)
from core.db.models import Complexity
from core.db.models.project_state import TaskStatus
from core.llm.parser import JSONParser
@@ -46,11 +53,6 @@ class EpicPlan(BaseModel):
plan: list[Task] = Field(description="List of tasks that need to be done to implement the entire epic.")
TL_CREATE_INITIAL_EPIC = "Create initial project epic"
TL_CREATE_PLAN = "Create a development plan for epic: {}"
TL_START_FEATURE = "Start of feature #{}"
class TechLead(RelevantFilesMixin, BaseAgent):
agent_type = "tech-lead"
display_name = "Tech Lead"
@@ -94,7 +96,7 @@ class TechLead(RelevantFilesMixin, BaseAgent):
self.next_state.epics = self.current_state.epics + [
{
"id": uuid4().hex,
"name": "Initial Project",
"name": TL_INITIAL_PROJECT_NAME,
"source": "app",
"description": self.current_state.specification.description,
"test_instructions": None,
@@ -301,7 +303,7 @@ class TechLead(RelevantFilesMixin, BaseAgent):
await self.ui.send_project_stage({"stage": ProjectStage.OPEN_PLAN})
response = await self.ask_question(
"Open and edit your development plan in the Progress tab",
TL_EDIT_DEV_PLAN,
buttons={"done_editing": "I'm done editing, the plan looks good"},
default="done_editing",
buttons_only=True,

View File

@@ -1,14 +1,13 @@
from core.agents.base import BaseAgent
from core.agents.convo import AgentConvo
from core.agents.response import AgentResponse
from core.config.actions import TW_WRITE
from core.db.models.project_state import TaskStatus
from core.log import get_logger
from core.ui.base import success_source
log = get_logger(__name__)
TW_WRITE = "Write documentation"
class TechnicalWriter(BaseAgent):
agent_type = "tech-writer"

View File

@@ -9,6 +9,7 @@ from core.agents.convo import AgentConvo
from core.agents.mixins import ChatWithBreakdownMixin, IterationPromptMixin, RelevantFilesMixin, TestSteps
from core.agents.response import AgentResponse
from core.config import TROUBLESHOOTER_GET_RUN_COMMAND
from core.config.actions import TS_ALT_SOLUTION, TS_APP_WORKING, TS_DESCRIBE_ISSUE, TS_TASK_REVIEWED
from core.db.models.file import File
from core.db.models.project_state import IterationStatus, TaskStatus
from core.llm.parser import JSONParser, OptionalCodeBlockParser
@@ -31,10 +32,6 @@ class RouteFilePaths(BaseModel):
files: list[str] = Field(description="List of paths for files that contain routes")
TS_TASK_REVIEWED = "Task #{} reviewed"
TS_ALT_SOLUTION = "Alternative solution (attempt #{})"
class Troubleshooter(ChatWithBreakdownMixin, IterationPromptMixin, RelevantFilesMixin, BaseAgent):
agent_type = "troubleshooter"
display_name = "Troubleshooter"
@@ -265,7 +262,7 @@ class Troubleshooter(ChatWithBreakdownMixin, IterationPromptMixin, RelevantFiles
while True:
await self.ui.send_project_stage({"stage": ProjectStage.GET_USER_FEEDBACK})
test_message = "Please check if the app is working"
test_message = TS_APP_WORKING
if user_instructions:
hint = " Here is a description of what should be working:\n\n" + user_instructions
@@ -307,7 +304,7 @@ class Troubleshooter(ChatWithBreakdownMixin, IterationPromptMixin, RelevantFiles
elif user_response.button == "bug":
await self.ui.send_project_stage({"stage": ProjectStage.DESCRIBE_ISSUE})
user_description = await self.ask_question(
"Please describe the issue you found (one at a time) and share any relevant server logs",
TS_DESCRIBE_ISSUE,
extra_info="collect_logs",
buttons={"back": "Back"},
)

View File

@@ -3,22 +3,48 @@ import os
import os.path
import sys
from argparse import ArgumentParser, ArgumentTypeError, Namespace
from difflib import unified_diff
from typing import Optional
from urllib.parse import urlparse
from uuid import UUID
from core.config import Config, LLMProvider, LocalIPCConfig, ProviderConfig, UIAdapter, get_config, loader
from core.config.actions import (
BH_ADDITIONAL_FEEDBACK,
BH_HUMAN_TEST_AGAIN,
BH_IS_BUG_FIXED,
BH_START_BUG_HUNT,
BH_START_USER_TEST,
BH_STARTING_PAIR_PROGRAMMING,
BH_WAIT_BUG_REP_INSTRUCTIONS,
CM_UPDATE_FILES,
DEV_EXECUTE_TASK,
DEV_TASK_BREAKDOWN,
DEV_TASK_START,
DEV_TROUBLESHOOT,
FE_CHANGE_REQ,
FE_DONE_WITH_UI,
HUMAN_INTERVENTION_QUESTION,
MIX_BREAKDOWN_CHAT_PROMPT,
RUN_COMMAND,
TC_TASK_DONE,
TL_EDIT_DEV_PLAN,
TS_APP_WORKING,
TS_DESCRIBE_ISSUE,
)
from core.config.env_importer import import_from_dotenv
from core.config.version import get_version
from core.db.session import SessionManager
from core.db.setup import run_migrations
from core.log import setup
from core.log import get_logger, setup
from core.state.state_manager import StateManager
from core.ui.base import UIBase
from core.ui.base import AgentSource, UIBase, UISource
from core.ui.console import PlainConsoleUI
from core.ui.ipc_client import IPCClientUI
from core.ui.virtual import VirtualUI
log = get_logger(__name__)
def parse_llm_endpoint(value: str) -> Optional[tuple[LLMProvider, str]]:
"""
@@ -47,6 +73,35 @@ def parse_llm_endpoint(value: str) -> Optional[tuple[LLMProvider, str]]:
return provider, url.geturl()
def get_line_changes(old_content: str, new_content: str) -> tuple[int, int]:
"""
Get the number of added and deleted lines between two files.
This uses Python difflib to produce a unified diff, then counts
the number of added and deleted lines.
:param old_content: old file content
:param new_content: new file content
:return: a tuple (added_lines, deleted_lines)
"""
from_lines = old_content.splitlines(keepends=True)
to_lines = new_content.splitlines(keepends=True)
diff_gen = unified_diff(from_lines, to_lines)
added_lines = 0
deleted_lines = 0
for line in diff_gen:
if line.startswith("+") and not line.startswith("+++"): # Exclude the file headers
added_lines += 1
elif line.startswith("-") and not line.startswith("---"): # Exclude the file headers
deleted_lines += 1
return added_lines, deleted_lines
def parse_llm_key(value: str) -> Optional[tuple[LLMProvider, str]]:
"""
Parse --llm-key command-line option.
@@ -196,44 +251,346 @@ async def list_projects_json(db: SessionManager):
"""
sm = StateManager(db)
projects = await sm.list_projects()
data = []
for project in projects:
last_updated = None
p = {
"name": project.name,
"id": project.id.hex,
"branches": [],
}
for branch in project.branches:
b = {
"name": branch.name,
"id": branch.id.hex,
"steps": [],
projects_list = []
for row in projects:
project_id, project_name, created_at, folder_name = row
projects_list.append(
{
"id": project_id.hex,
"name": project_name,
"folder_name": folder_name,
"updated_at": created_at.isoformat(),
}
for state in branch.states:
if not last_updated or state.created_at > last_updated:
last_updated = state.created_at
s = {
"name": state.action or f"Step #{state.step_index}",
"step": state.step_index,
}
b["steps"].append(s)
if b["steps"]:
b["steps"][-1]["name"] = "Latest step"
p["branches"].append(b)
p["updated_at"] = last_updated.isoformat() if last_updated else None
data.append(p)
)
print(json.dumps(data, indent=2))
print(json.dumps(projects_list, indent=2, default=str))
async def list_projects(db: SessionManager):
def find_first_todo_task(tasks):
"""
List all projects in the database.
Find the first task with status 'todo' from a list of tasks.
:param tasks: List of task objects
:return: First task with status 'todo', or None if not found
"""
if not tasks:
return None
for task in tasks:
if task.get("status") == "todo":
return task
return None
def trim_logs(logs: str) -> str:
"""
Trim logs by removing everything after specific marker phrases.
This function cuts off the string at the first occurrence of
"Here are the backend logs" or "Here are the frontend logs".
:param logs: Log text to trim
:return: Trimmed log text with the marker phrase removed
"""
if not logs:
return ""
# Define marker phrases
markers = ["Here are the backend logs", "Here are the frontend logs"]
# Find the first occurrence of any marker
index = float("inf")
for marker in markers:
pos = logs.find(marker)
if pos != -1 and pos < index:
index = pos
# If a marker was found, trim the string
if index != float("inf"):
return logs[:index]
return logs
def get_source_for_history(msg_type: Optional[str] = "", question: Optional[str] = ""):
if question in [TL_EDIT_DEV_PLAN]:
return AgentSource("Tech Lead", "tech-lead")
if question in [FE_CHANGE_REQ, FE_DONE_WITH_UI]:
return AgentSource("Frontend", "frontend")
elif question in [
TS_DESCRIBE_ISSUE,
BH_HUMAN_TEST_AGAIN,
BH_IS_BUG_FIXED,
TS_APP_WORKING,
BH_ADDITIONAL_FEEDBACK,
] or msg_type in ["instructions", "bh_breakdown"]:
return AgentSource("Bug Hunter", "bug-hunter")
elif msg_type in ["bug_reproduction_instructions", "bug_description"]:
return AgentSource("Troubleshooter", "troubleshooter")
elif HUMAN_INTERVENTION_QUESTION in question:
return AgentSource("Human Input", "human-input")
elif RUN_COMMAND in question:
return AgentSource("Executor", "executor")
elif msg_type in ["task_description", "task_breakdown"]:
return AgentSource("Developer", "developer")
else:
return UISource("Pythagora", "pythagora")
async def print_convo(
ui: UIBase,
convo: list,
):
for msg in convo:
if "bh_breakdown" in msg:
await ui.send_message(
msg["bh_breakdown"],
source=get_source_for_history(msg_type="bh_breakdown"),
project_state_id=msg["id"],
)
if "task_description" in msg:
await ui.send_message(
msg["task_description"],
source=get_source_for_history(msg_type="task_description"),
project_state_id=msg["id"],
)
if "task_breakdown" in msg:
await ui.send_message(
msg["task_breakdown"],
source=get_source_for_history(msg_type="task_breakdown"),
project_state_id=msg["id"],
)
if "test_instructions" in msg:
await ui.send_test_instructions(
msg["test_instructions"],
project_state_id=msg["id"],
)
if "bh_testing_instructions" in msg:
await ui.send_test_instructions(
msg["bh_testing_instructions"],
project_state_id=msg["id"],
)
if "files" in msg:
for f in msg["files"]:
await ui.send_file_status(f["path"], "done")
await ui.generate_diff(
file_path=f["path"],
file_old=f.get("old_content", ""),
file_new=f.get("new_content", ""),
n_new_lines=f["diff"][0],
n_del_lines=f["diff"][1],
)
if "user_inputs" in msg and msg["user_inputs"]:
for input_item in msg["user_inputs"]:
if "question" in input_item:
await ui.send_message(
input_item["question"],
source=get_source_for_history(question=input_item["question"]),
project_state_id=msg["id"],
)
if "answer" in input_item:
if input_item["question"] != TL_EDIT_DEV_PLAN:
await ui.send_user_input_history(input_item["answer"], project_state_id=msg["id"])
async def load_convo(
sm: StateManager,
project_id: Optional[UUID] = None,
branch_id: Optional[UUID] = None,
) -> list:
"""
Loads the conversation from an existing project.
returns: list of dictionaries with the conversation history
"""
convo = []
if branch_id is None and project_id is not None:
branches = await sm.get_branches_for_project_id(project_id)
if not branches:
return convo
branch_id = branches[0].id
project_states = await sm.get_project_states(project_id, branch_id)
task_counter = 1
for i, state in enumerate(project_states):
prev_state = project_states[i - 1] if i > 0 else None
convo_el = {}
convo_el["id"] = str(state.id)
user_inputs = await sm.find_user_input(state, branch_id)
todo_task = find_first_todo_task(state.tasks)
if todo_task:
task_counter = state.tasks.index(todo_task) + 1
if user_inputs:
convo_el["user_inputs"] = []
for ui in user_inputs:
if ui.question:
if ui.question == MIX_BREAKDOWN_CHAT_PROMPT:
if len(state.iterations) > 0:
# as it's not available in the current state, take the next state's description - that is the bug description!
next_state = project_states[i + 1] if i + 1 < len(project_states) else None
if next_state is not None and next_state.iterations is not None:
si = next_state.iterations[-1]
if si is not None:
if si.get("description", None) is not None:
convo_el["bh_breakdown"] = si["description"]
else:
# if there are no iterations, it means developer made task breakdown, take the next state's first task with status = todo
next_state = project_states[i + 1] if i + 1 < len(project_states) else None
if next_state is not None:
task = find_first_todo_task(next_state.tasks)
if task.get("test_instructions", None) is not None:
convo_el["test_instructions"] = task["test_instructions"]
if task.get("instructions", None) is not None:
convo_el["task_breakdown"] = task["instructions"]
# skip parsing that questions and its answers due to the fact that we do not keep states inside breakdown convo
break
if ui.question == BH_HUMAN_TEST_AGAIN:
if len(state.iterations) > 0:
si = state.iterations[-1]
if si is not None:
if si.get("bug_reproduction_description", None) is not None:
convo_el["bh_testing_instructions"] = si["bug_reproduction_description"]
if ui.question == TS_APP_WORKING:
task = find_first_todo_task(state.tasks)
if task:
if task.get("test_instructions", None) is not None:
convo_el["test_instructions"] = task["test_instructions"]
if ui.question == DEV_EXECUTE_TASK:
task = find_first_todo_task(state.tasks)
if task:
if task.get("description", None) is not None:
convo_el["task_description"] = f"Task #{task_counter} - " + task["description"]
answer = trim_logs(ui.answer_text) if ui.answer_text is not None else ui.answer_button
if answer == "bug":
answer = "There is an issue"
elif answer == "change":
answer = "I want to make a change"
convo_el["user_inputs"].append({"question": ui.question, "answer": answer})
if state.action is not None:
if state.action == DEV_TROUBLESHOOT.format(task_counter):
if state.iterations is not None and len(state.iterations) > 0:
si = state.iterations[-1]
if si is not None:
if si.get("user_feedback", None) is not None:
convo_el["user_feedback"] = si["user_feedback"]
if si.get("description", None) is not None:
convo_el["description"] = si["description"]
elif state.action == DEV_TASK_BREAKDOWN.format(task_counter):
task = state.tasks[task_counter - 1]
if task.get("description", None) is not None:
convo_el["task_description"] = f"Task #{task_counter} - " + task["description"]
if task.get("instructions", None) is not None:
convo_el["task_breakdown"] = task["instructions"]
elif state.action == TC_TASK_DONE.format(task_counter):
if state.tasks:
next_task = find_first_todo_task(state.tasks)
if next_task is not None and next_task.get("description", None) is not None:
convo_el["task_description"] = f"Task #{task_counter} - " + next_task["description"]
elif state.action == DEV_TASK_START:
task = state.tasks[task_counter - 1]
if task.get("instructions", None) is not None:
convo_el["task_breakdown"] = task["instructions"]
elif state.action == CM_UPDATE_FILES:
files = []
for steps in state.steps:
file = {}
if "save_file" in steps and "path" in steps["save_file"]:
path = steps["save_file"]["path"]
file["path"] = path
current_file = await sm.get_file_for_project(state.id, path)
prev_file = await sm.get_file_for_project(prev_state.id, path) if prev_state else None
old_content = prev_file.content.content if prev_file and prev_file.content else ""
new_content = current_file.content.content if current_file and current_file.content else ""
file["diff"] = get_line_changes(
old_content=old_content,
new_content=new_content,
)
file["old_content"] = old_content
file["new_content"] = new_content
if file["diff"] != (0, 0):
files.append(file)
convo_el["files"] = files
if state.iterations is not None and len(state.iterations) > 0:
si = state.iterations[-1]
if state.action == BH_START_BUG_HUNT.format(task_counter):
if si.get("user_feedback", None) is not None:
convo_el["user_feedback"] = si["user_feedback"]
if si.get("description", None) is not None:
convo_el["description"] = si["description"]
elif state.action == BH_WAIT_BUG_REP_INSTRUCTIONS.format(task_counter):
for si in state.iterations:
if si.get("bug_reproduction_description", None) is not None:
convo_el["bug_reproduction_description"] = si["bug_reproduction_description"]
elif state.action == BH_START_USER_TEST.format(task_counter):
if si.get("bug_hunting_cycles", None) is not None:
cycle = si["bug_hunting_cycles"][-1]
if cycle is not None:
if "user_feedback" in cycle and cycle["user_feedback"] is not None:
convo_el["user_feedback"] = cycle["user_feedback"]
if (
"human_readable_instructions" in cycle
and cycle["human_readable_instructions"] is not None
):
convo_el["human_readable_instructions"] = cycle["human_readable_instructions"]
elif state.action == BH_STARTING_PAIR_PROGRAMMING.format(task_counter):
if "user_feedback" in si and si["user_feedback"] is not None:
convo_el["user_feedback"] = si["user_feedback"]
if "initial_explanation" in si and si["initial_explanation"] is not None:
convo_el["initial_explanation"] = si["initial_explanation"]
convo_el["action"] = state.action
convo.append(convo_el)
return convo
async def list_projects_branches_states(db: SessionManager):
"""
List all projects in the database, including their branches and project states
"""
sm = StateManager(db)
projects = await sm.list_projects()
projects = await sm.list_projects_with_branches_states()
print(f"Available projects ({len(projects)}):")
for project in projects:
@@ -329,4 +686,11 @@ def init() -> tuple[UIBase, SessionManager, Namespace]:
return (ui, db, args)
__all__ = ["parse_arguments", "load_config", "list_projects_json", "list_projects", "load_project", "init"]
__all__ = [
"parse_arguments",
"load_config",
"list_projects_json",
"list_projects_branches_states",
"load_project",
"init",
]

View File

@@ -14,7 +14,16 @@ except ImportError:
SENTRY_AVAILABLE = False
from core.agents.orchestrator import Orchestrator
from core.cli.helpers import delete_project, init, list_projects, list_projects_json, load_project, show_config
from core.cli.helpers import (
delete_project,
init,
list_projects_branches_states,
list_projects_json,
load_convo,
load_project,
print_convo,
show_config,
)
from core.db.session import SessionManager
from core.db.v0importer import LegacyDatabaseImporter
from core.llm.anthropic_client import CustomAssertionError
@@ -22,11 +31,16 @@ from core.llm.base import APIError
from core.log import get_logger
from core.state.state_manager import StateManager
from core.telemetry import telemetry
from core.ui.base import ProjectStage, UIBase, UIClosedError, UserInput, pythagora_source
from core.ui.base import (
ProjectStage,
UIBase,
UIClosedError,
UserInput,
pythagora_source,
)
log = get_logger(__name__)
telemetry_sent = False
@@ -201,6 +215,10 @@ async def run_pythagora_session(sm: StateManager, ui: UIBase, args: Namespace):
success = await load_project(sm, args.project, args.branch, args.step)
if not success:
return False
convo = await load_convo(sm, args.project, args.branch)
await print_convo(ui, convo)
else:
success = await start_new_project(sm, ui)
if not success:
@@ -225,7 +243,7 @@ async def async_main(
global telemetry_sent
if args.list:
await list_projects(db)
await list_projects_branches_states(db)
return True
elif args.list_json:
await list_projects_json(db)

View File

@@ -5,6 +5,8 @@ from typing import Any, Literal, Optional, Union
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing_extensions import Annotated
from core.config.constants import LOGS_LINE_LIMIT
ROOT_DIR = abspath(join(dirname(__file__), "..", ".."))
DEFAULT_IGNORE_PATHS = [
".git",
@@ -218,6 +220,10 @@ class LogConfig(_StrictModel):
"pythagora.log",
description="Output file for logs (if not specified, logs are printed to stderr)",
)
max_lines: int = Field(
LOGS_LINE_LIMIT,
description="Maximum number of lines to keep in the log file",
)
class DBConfig(_StrictModel):

57
core/config/actions.py Normal file
View File

@@ -0,0 +1,57 @@
BH_START_BUG_HUNT = "Start bug hunt for task #{}"
BH_WAIT_BUG_REP_INSTRUCTIONS = "Awaiting bug reproduction instructions for task #{}"
BH_START_USER_TEST = "Start user testing for task #{}"
BH_STARTING_PAIR_PROGRAMMING = "Start pair programming for task #{}"
CM_UPDATE_FILES = "Updating files"
DEV_WAIT_TEST = "Awaiting user test"
DEV_TASK_START = "Task #{} start"
DEV_TASK_BREAKDOWN = "Task #{} breakdown"
DEV_TROUBLESHOOT = "Troubleshooting #{}"
DEV_TASK_REVIEW_FEEDBACK = "Task review feedback"
TC_TASK_DONE = "Task #{} complete"
FE_INIT = "Frontend init"
FE_START = "Frontend start"
FE_CONTINUE = "Frontend continue"
FE_ITERATION = "Frontend iteration"
FE_ITERATION_DONE = "Frontend iteration done"
TL_CREATE_INITIAL_EPIC = "Create initial project epic"
TL_CREATE_PLAN = "Create a development plan for epic: {}"
TL_START_FEATURE = "Start of feature #{}"
TL_INITIAL_PROJECT_NAME = "Initial Project"
TW_WRITE = "Write documentation"
EX_SKIP_COMMAND = 'Skip "{}"'
EX_RUN_COMMAND = 'Run "{}"'
SPEC_CREATE_STEP_NAME = "Create specification"
SPEC_CHANGE_STEP_NAME = "Change specification"
SPEC_CHANGE_FEATURE_STEP_NAME = "Change specification due to new feature"
TS_TASK_REVIEWED = "Task #{} reviewed"
TS_ALT_SOLUTION = "Alternative solution (attempt #{})"
TS_APP_WORKING = "Please check if the app is working"
PS_EPIC_COMPLETE = "Epic {} completed"
# other constants
TL_EDIT_DEV_PLAN = "Open and edit your development plan in the Progress tab"
MIX_BREAKDOWN_CHAT_PROMPT = "Are you happy with the breakdown? Now is a good time to ask questions or suggest changes."
FE_CHANGE_REQ = (
"Do you want to change anything or report a bug? Keep in mind that currently ONLY frontend is implemented."
)
FE_DONE_WITH_UI = "Are you sure you're done building the UI and want to start building the backend functionality now?"
TS_DESCRIBE_ISSUE = "Please describe the issue you found (one at a time) and share any relevant server logs"
BH_HUMAN_TEST_AGAIN = "Please test the app again."
BH_IS_BUG_FIXED = "Is the bug you reported fixed now?"
BH_ADDITIONAL_FEEDBACK = "Please add any additional feedback that could help Pythagora solve this bug"
HUMAN_INTERVENTION_QUESTION = "I need human intervention:"
RUN_COMMAND = "Can I run command:"
DEV_EXECUTE_TASK = "Do you want to execute the above task?"

View File

@@ -1 +1,2 @@
CONVO_ITERATIONS_LIMIT = 8
LOGS_LINE_LIMIT = 20000

View File

@@ -4,12 +4,12 @@ from typing import TYPE_CHECKING, Optional, Union
from unicodedata import normalize
from uuid import UUID, uuid4
from sqlalchemy import and_, delete, inspect, select
from sqlalchemy import Row, and_, delete, inspect, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload
from sqlalchemy.sql import func
from core.db.models import Base
from core.db.models import Base, File
if TYPE_CHECKING:
from core.db.models import Branch
@@ -67,7 +67,28 @@ class Project(Base):
return result.scalar_one_or_none()
@staticmethod
async def get_all_projects(session: "AsyncSession") -> list["Project"]:
async def get_file_for_project(session: AsyncSession, project_state_id: UUID, path: str) -> Optional["File"]:
file_result = await session.execute(
select(File).where(File.project_state_id == project_state_id, File.path == path)
)
return file_result.scalar_one_or_none()
@staticmethod
async def get_branches_for_project_id(session: AsyncSession, project_id: UUID) -> list["Branch"]:
from core.db.models import Branch
branch_result = await session.execute(select(Branch).where(Branch.project_id == project_id))
return branch_result.scalars().all()
@staticmethod
async def get_all_projects(session: "AsyncSession") -> list[Row]:
query = select(Project.id, Project.name, Project.created_at, Project.folder_name).order_by(Project.name)
result = await session.execute(query)
return result.fetchall()
@staticmethod
async def get_all_projects_with_branches_states(session: "AsyncSession") -> list["Project"]:
"""
Get all projects.

View File

@@ -3,12 +3,13 @@ from datetime import datetime
from typing import TYPE_CHECKING, Optional, Union
from uuid import UUID, uuid4
from sqlalchemy import ForeignKey, UniqueConstraint, delete, inspect
from sqlalchemy import ForeignKey, UniqueConstraint, delete, inspect, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.sql import func
from core.config.actions import PS_EPIC_COMPLETE
from core.db.models import Base, FileContent
from core.log import get_logger
@@ -46,10 +47,6 @@ class IterationStatus:
DONE = "done"
PS_EPIC_COMPLETE = "Epic {} completed"
PS_TASK_COMPLETE = "Task {} completed"
class ProjectState(Base):
__tablename__ = "project_states"
__table_args__ = (
@@ -216,8 +213,41 @@ class ProjectState(Base):
branch=branch,
specification=Specification(),
step_index=1,
action="Initial project state",
)
@staticmethod
async def get_project_states(
session: "AsyncSession",
project_id: Optional[UUID] = None,
branch_id: Optional[UUID] = None,
) -> list["ProjectState"]:
from core.db.models import Branch, ProjectState
branch = None
limit = 100
if branch_id:
branch = await session.execute(select(Branch).where(Branch.id == branch_id))
branch = branch.scalar_one_or_none()
elif project_id:
branch = await session.execute(select(Branch).where(Branch.project_id == project_id))
branch = branch.scalar_one_or_none()
if branch:
query = (
select(ProjectState)
.where(ProjectState.branch_id == branch.id)
.order_by(ProjectState.step_index.desc()) # Get the latest 100 states
.limit(limit)
)
project_states_result = await session.execute(query)
project_states = project_states_result.scalars().all()
return sorted(project_states, key=lambda x: x.step_index)
return []
async def create_next_state(self) -> "ProjectState":
"""
Create the next project state for the branch.
@@ -436,18 +466,43 @@ class ProjectState(Base):
async def delete_after(self):
"""
Delete all states in the branch after this one.
Delete all states in the branch after this one, along with related data.
This includes:
- ProjectState records after this one
- Related UserInput records (including those for the current state)
- Related File records
- Orphaned FileContent records
"""
from core.db.models import FileContent, UserInput
session: AsyncSession = inspect(self).async_session
log.debug(f"Deleting all project states in branch {self.branch_id} after {self.id}")
await session.execute(
delete(ProjectState).where(
# Get all project states to be deleted
states_to_delete = await session.execute(
select(ProjectState).where(
ProjectState.branch_id == self.branch_id,
ProjectState.step_index > self.step_index,
)
)
states_to_delete = states_to_delete.scalars().all()
state_ids = [state.id for state in states_to_delete]
# Delete user inputs for the current state
await session.execute(delete(UserInput).where(UserInput.project_state_id == self.id))
if state_ids:
# Delete related user inputs for states to be deleted
await session.execute(delete(UserInput).where(UserInput.project_state_id.in_(state_ids)))
# Delete project states
await session.execute(delete(ProjectState).where(ProjectState.id.in_(state_ids)))
# Clean up orphaned file content and user inputs
await FileContent.delete_orphans(session)
await UserInput.delete_orphans(session)
def get_last_iteration_steps(self) -> list:
"""

View File

@@ -2,7 +2,8 @@ from datetime import datetime
from typing import TYPE_CHECKING, Optional
from uuid import UUID
from sqlalchemy import ForeignKey, inspect
from sqlalchemy import ForeignKey, and_, delete, inspect, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
@@ -57,3 +58,24 @@ class UserInput(Base):
)
session.add(obj)
return obj
@staticmethod
async def find_user_inputs(session: AsyncSession, project_state, branch_id) -> Optional[list["UserInput"]]:
from core.db.models import UserInput
user_input = await session.execute(
select(UserInput).where(
and_(UserInput.branch_id == branch_id, UserInput.project_state_id == project_state.id)
)
)
user_input = user_input.scalars().all()
return user_input if len(user_input) > 0 else []
@classmethod
async def delete_orphans(cls, session: AsyncSession):
"""
Delete UserInput objects that have no associated ProjectState.
:param session: The database session.
"""
await session.execute(delete(UserInput).where(UserInput.project_state_id.is_(None)))

View File

@@ -1,6 +1,65 @@
import os
from collections import deque
from logging import FileHandler, Formatter, Logger, StreamHandler, getLogger
from core.config import LogConfig
from core.config.constants import LOGS_LINE_LIMIT
class LineCountLimitedFileHandler(FileHandler):
"""
A file handler that limits the number of lines in the log file.
It keeps a fixed number of the most recent log lines.
"""
def __init__(self, filename, max_lines=LOGS_LINE_LIMIT, mode="a", encoding=None, delay=False):
"""
Initialize the handler with the file and max lines.
:param filename: Log file path
:param max_lines: Maximum number of lines to keep in the file
:param mode: File open mode
:param encoding: File encoding
:param delay: Delay file opening until first emit
"""
super().__init__(filename, mode, encoding, delay)
self.max_lines = max_lines
self.line_buffer = deque(maxlen=max_lines)
self._load_existing_lines()
def _load_existing_lines(self):
"""Load existing lines from the file into the buffer if the file exists."""
if os.path.exists(self.baseFilename):
try:
with open(self.baseFilename, "r", encoding=self.encoding) as f:
for line in f:
if len(self.line_buffer) < self.max_lines:
self.line_buffer.append(line)
else:
self.line_buffer.popleft()
self.line_buffer.append(line)
except Exception:
# If there's an error reading the file, we'll just start with an empty buffer
self.line_buffer.clear()
def emit(self, record):
"""
Emit a record and maintain the line count limit.
:param record: Log record to emit
"""
try:
msg = self.format(record)
line = msg + self.terminator
self.line_buffer.append(line)
# Rewrite the entire file with the current buffer
with open(self.baseFilename, "w", encoding=self.encoding) as f:
f.writelines(self.line_buffer)
self.flush()
except Exception:
self.handleError(record)
def setup(config: LogConfig, force: bool = False):
@@ -27,7 +86,9 @@ def setup(config: LogConfig, force: bool = False):
formatter = Formatter(config.format)
if config.output:
handler = FileHandler(config.output, encoding="utf-8")
# Use our custom handler that limits line count
max_lines = getattr(config, "max_lines", LOGS_LINE_LIMIT)
handler = LineCountLimitedFileHandler(config.output, max_lines=max_lines, encoding="utf-8")
else:
handler = StreamHandler()

View File

@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Optional
from uuid import UUID, uuid4
from sqlalchemy import inspect, select
from sqlalchemy import Row, inspect, select
from tenacity import retry, stop_after_attempt, wait_fixed
from core.config import FileSystemType, get_config
@@ -69,15 +69,32 @@ class StateManager:
finally:
self.blockDb = False # Unset the block
async def list_projects(self) -> list[Project]:
async def list_projects(self) -> list[Row]:
"""
List projects with branches
:return: List of projects with all their branches.
:return: List of projects
"""
async with self.session_manager as session:
return await Project.get_all_projects(session)
async def list_projects_with_branches_states(self) -> list[Project]:
"""
:return: List of projects with branches and states (old) - for debugging
"""
async with self.session_manager as session:
return await Project.get_all_projects_with_branches_states(session)
async def get_project_states(self, project_id: Optional[UUID], branch_id: Optional[UUID]) -> list[ProjectState]:
return await ProjectState.get_project_states(self.current_session, project_id, branch_id)
async def get_branches_for_project_id(self, project_id: UUID) -> list[Branch]:
return await Project.get_branches_for_project_id(self.current_session, project_id)
async def find_user_input(self, project_state, branch_id) -> Optional[list["UserInput"]]:
return await UserInput.find_user_inputs(self.current_session, project_state, branch_id)
async def get_file_for_project(self, state_id: UUID, path: str):
return await Project.get_file_for_project(self.current_session, state_id, path)
async def create_project(self, name: str, folder_name: Optional[str] = None) -> Project:
"""
Create a new project and set it as the current one.

View File

@@ -122,6 +122,21 @@ class UIBase:
"""
raise NotImplementedError()
async def send_user_input_history(
self,
message: str,
source: Optional[UISource] = None,
project_state_id: Optional[str] = None,
):
"""
Send a user input history (what the user said to Pythagora) message to the UI.
:param message: Message content.
:param source: Source of the message (if any).
:param project_state_id: Current project state id.
"""
raise NotImplementedError()
async def send_message(
self,
message: str,

View File

@@ -29,6 +29,17 @@ class PlainConsoleUI(UIBase):
else:
print(chunk, end="", flush=True)
async def send_user_input_history(
self,
message: str,
source: Optional[UISource] = None,
project_state_id: Optional[str] = None,
):
if source:
print(f"[{source}] {message}")
else:
print(message)
async def send_message(
self,
message: str,

View File

@@ -56,6 +56,7 @@ class MessageType(str, Enum):
KNOWLEDGE_BASE_UPDATE = "updatedKnowledgeBase"
STOP_APP = "stopApp"
TOKEN_EXPIRED = "tokenExpired"
USER_INPUT_HISTORY = "userInputHistory"
class Message(BaseModel):
@@ -210,6 +211,19 @@ class IPCClientUI(UIBase):
project_state_id=project_state_id,
)
async def send_user_input_history(
self,
message: str,
source: Optional[UISource] = None,
project_state_id: Optional[str] = None,
):
await self._send(
MessageType.USER_INPUT_HISTORY,
content=message,
category=source.type_name if source else None,
project_state_id=project_state_id,
)
async def send_message(
self,
message: str,
@@ -497,7 +511,12 @@ class IPCClientUI(UIBase):
content=stats,
)
async def send_test_instructions(self, test_instructions: str, project_state_id: Optional[str] = None):
async def send_test_instructions(
self,
test_instructions: str,
project_state_id: Optional[str] = None,
source: Optional[UISource] = None,
):
try:
log.debug("Sending test instructions")
parsed_instructions = json.loads(test_instructions)
@@ -511,6 +530,7 @@ class IPCClientUI(UIBase):
"test_instructions": parsed_instructions,
},
project_state_id=project_state_id,
category=source.type_name if source else None,
)
async def knowledge_base_update(self, knowledge_base: dict):

View File

@@ -30,6 +30,14 @@ class VirtualUI(UIBase):
else:
print(chunk, end="", flush=True)
async def send_user_input_history(
self,
message: str,
source: Optional[UISource] = None,
project_state_id: Optional[str] = None,
):
raise NotImplementedError()
async def send_message(
self,
message: str,

View File

@@ -7,7 +7,7 @@ import pytest
from core.cli.helpers import (
init,
list_projects,
list_projects_branches_states,
list_projects_json,
load_config,
load_project,
@@ -167,46 +167,29 @@ def test_show_default_config(capsys):
async def test_list_projects_json(mock_StateManager, capsys):
sm = mock_StateManager.return_value
branch = MagicMock(
id=MagicMock(hex="1234"),
states=[
MagicMock(step_index=1, action="foo", created_at=datetime(2021, 1, 1)),
MagicMock(step_index=2, action=None, created_at=datetime(2021, 1, 2)),
MagicMock(step_index=3, action="baz", created_at=datetime(2021, 1, 3)),
],
)
branch.name = "branch1"
project_id1 = MagicMock(hex="abcd")
project_id2 = MagicMock(hex="efgh")
project = MagicMock(
id=MagicMock(hex="abcd"),
branches=[branch],
)
project.name = "project1"
sm.list_projects = AsyncMock(return_value=[project])
created_at1 = datetime(2021, 1, 1)
created_at2 = datetime(2021, 1, 2)
projects_data = [
(project_id1, "project1", created_at1, "folder1"),
(project_id2, "project2", created_at2, "folder2"),
]
sm.list_projects = AsyncMock(return_value=projects_data)
await list_projects_json(None)
mock_StateManager.assert_called_once_with(None)
sm.list_projects.assert_awaited_once_with()
data = json.loads(capsys.readouterr().out)
captured = capsys.readouterr().out
data = json.loads(captured)
assert data == [
{
"name": "project1",
"id": "abcd",
"updated_at": "2021-01-03T00:00:00",
"branches": [
{
"name": "branch1",
"id": "1234",
"steps": [
{"step": 1, "name": "foo"},
{"step": 2, "name": "Step #2"},
{"step": 3, "name": "Latest step"},
],
},
],
},
{"id": "abcd", "name": "project1", "folder_name": "folder1", "updated_at": "2021-01-01T00:00:00"},
{"id": "efgh", "name": "project2", "folder_name": "folder2", "updated_at": "2021-01-02T00:00:00"},
]
@@ -229,11 +212,12 @@ async def test_list_projects(mock_StateManager, capsys):
branches=[branch],
)
project.name = "project1"
sm.list_projects = AsyncMock(return_value=[project])
await list_projects(None)
sm.list_projects_with_branches_states = AsyncMock(return_value=[project])
await list_projects_branches_states(None)
mock_StateManager.assert_called_once_with(None)
sm.list_projects.assert_awaited_once_with()
sm.list_projects_with_branches_states.assert_awaited_once_with()
data = capsys.readouterr().out

View File

@@ -1,8 +1,13 @@
import json
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from core.cli.helpers import list_projects_json
from core.db.models import Branch, Project
from core.state.state_manager import StateManager
from .factories import create_project_state
@@ -72,16 +77,58 @@ async def test_get_branch_no_session():
@pytest.mark.asyncio
async def test_get_all_projects(testdb):
state1 = create_project_state()
state2 = create_project_state()
async def test_get_all_projects(testdb, capsys):
state1 = create_project_state(project_name="Test Project 1")
state2 = create_project_state(project_name="Test Project 2")
testdb.add(state1)
testdb.add(state2)
await testdb.commit() # Ensure changes are committed
projects = await Project.get_all_projects(testdb)
assert len(projects) == 2
assert state1.branch.project in projects
assert state2.branch.project in projects
# Set folder names for the test
folder_name1 = "folder1"
folder_name2 = "folder2"
sm = StateManager(testdb)
sm.list_projects = AsyncMock(
return_value=[
(
MagicMock(hex=state1.branch.project.id.hex),
state1.branch.project.name,
datetime(2021, 1, 1),
folder_name1,
),
(
MagicMock(hex=state2.branch.project.id.hex),
state2.branch.project.name,
datetime(2021, 1, 2),
folder_name2,
),
]
)
with patch("core.cli.helpers.StateManager", return_value=sm):
await list_projects_json(testdb)
captured = capsys.readouterr()
data = json.loads(captured.out)
expected_output = [
{
"id": state1.branch.project.id.hex,
"name": "Test Project 1",
"folder_name": folder_name1,
"updated_at": "2021-01-01T00:00:00",
},
{
"id": state2.branch.project.id.hex,
"name": "Test Project 2",
"folder_name": folder_name2,
"updated_at": "2021-01-02T00:00:00",
},
]
assert data == expected_output
@pytest.mark.asyncio

View File

@@ -31,7 +31,8 @@ async def test_create_project(mock_get_config, testmanager):
assert sm.current_state == initial_state
projects = await sm.list_projects()
assert projects == [project]
assert projects[0][0] == project.id
assert projects[0][1] == project.name
@pytest.mark.asyncio
@@ -54,7 +55,8 @@ async def test_delete_project(mock_get_config, testmanager):
project = await sm.create_project("test")
projects = await sm.list_projects()
assert projects == [project]
assert projects[0][0] == project.id
assert projects[0][1] == project.name
await sm.delete_project(project.id)
projects = await sm.list_projects()