diff --git a/examples/di/software_company.py b/examples/di/software_company.py new file mode 100644 index 000000000..ac9999ca9 --- /dev/null +++ b/examples/di/software_company.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import fire + +from metagpt.roles.di.data_interpreter import DataInterpreter + + +async def main(): + prompt = """ +This is a software requirement: +```text +write a snake game +``` +--- +1. Writes a PRD based on software requirements. +2. Writes a design to the project repository, based on the PRD of the project. +3. Writes a project plan to the project repository, based on the design of the project. +4. Writes codes to the project repository, based on the project plan of the project. +5. Run QA test on the project repository. +6. Stage and commit changes for the project repository using Git. +Note: All required dependencies and environments have been fully installed and configured. +""" + di = DataInterpreter( + tools=[ + "write_prd", + "write_design", + "write_project_plan", + "write_codes", + "run_qa_test", + "fix_bug", + "git_archive", + ] + ) + + await di.run(prompt) + + +if __name__ == "__main__": + fire.Fire(main) diff --git a/metagpt/actions/action.py b/metagpt/actions/action.py index 1b93213f7..5fd538720 100644 --- a/metagpt/actions/action.py +++ b/metagpt/actions/action.py @@ -104,3 +104,8 @@ class Action(SerializationMixin, ContextMixin, BaseModel): if self.node: return await self._run_action_node(*args, **kwargs) raise NotImplementedError("The run method should be implemented in a subclass.") + + def override_context(self): + """Set `private_context` and `context` to the same `Context` object.""" + if not self.private_context: + self.private_context = self.context diff --git a/metagpt/actions/prepare_documents.py b/metagpt/actions/prepare_documents.py index ab069dc11..08f2c2fcb 100644 --- a/metagpt/actions/prepare_documents.py +++ b/metagpt/actions/prepare_documents.py @@ -14,8 +14,6 @@ from typing import Optional from metagpt.actions import Action, ActionOutput from metagpt.const import REQUIREMENT_FILENAME from metagpt.utils.file_repository import FileRepository -from metagpt.utils.git_repository import GitRepository -from metagpt.utils.project_repo import ProjectRepo class PrepareDocuments(Action): @@ -38,8 +36,7 @@ class PrepareDocuments(Action): if path.exists() and not self.config.inc: shutil.rmtree(path) self.config.project_path = path - self.context.git_repo = GitRepository(local_path=path, auto_init=True) - self.context.repo = ProjectRepo(self.context.git_repo) + self.context.set_repo_dir(path) async def run(self, with_messages, **kwargs): """Create and initialize the workspace folder, initialize the Git environment.""" diff --git a/metagpt/context.py b/metagpt/context.py index 0add4c71a..09e6ec2f9 100644 --- a/metagpt/context.py +++ b/metagpt/context.py @@ -5,6 +5,8 @@ @Author : alexanderwu @File : context.py """ +from __future__ import annotations + import os from pathlib import Path from typing import Any, Optional @@ -78,11 +80,10 @@ class Context(BaseModel): # env.update({k: v for k, v in i.items() if isinstance(v, str)}) return env - # def use_llm(self, name: Optional[str] = None, provider: LLMType = LLMType.OPENAI) -> BaseLLM: - # """Use a LLM instance""" - # self._llm_config = self.config.get_llm_config(name, provider) - # self._llm = None - # return self._llm + def set_repo_dir(self, path: str | Path): + repo_path = Path(path) + self.git_repo = GitRepository(local_path=repo_path, auto_init=True) + self.repo = ProjectRepo(self.git_repo) def _select_costmanager(self, llm_config: LLMConfig) -> CostManager: """Return a CostManager instance""" diff --git a/metagpt/tools/libs/__init__.py b/metagpt/tools/libs/__init__.py index 91596fd3d..eb5ffbc5c 100644 --- a/metagpt/tools/libs/__init__.py +++ b/metagpt/tools/libs/__init__.py @@ -12,6 +12,15 @@ from metagpt.tools.libs import ( web_scraping, email_login, ) +from metagpt.tools.libs.software_development import ( + write_prd, + write_design, + write_project_plan, + write_codes, + run_qa_test, + fix_bug, + git_archive, +) _ = ( data_preprocess, @@ -20,4 +29,11 @@ _ = ( gpt_v_generator, web_scraping, email_login, + write_prd, + write_design, + write_project_plan, + write_codes, + run_qa_test, + fix_bug, + git_archive, ) # Avoid pre-commit error diff --git a/metagpt/tools/libs/software_development.py b/metagpt/tools/libs/software_development.py new file mode 100644 index 000000000..41d6fec57 --- /dev/null +++ b/metagpt/tools/libs/software_development.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from metagpt.const import BUGFIX_FILENAME, REQUIREMENT_FILENAME +from metagpt.schema import BugFixContext, Message +from metagpt.tools.tool_registry import register_tool +from metagpt.utils.common import any_to_str + + +@register_tool(tags=["software development", "ProductManager"]) +async def write_prd(idea: str, project_path: Optional[str | Path] = None) -> Path: + """Writes a PRD based on user requirements. + + Args: + idea (str): The idea or concept for the PRD. + project_path (Optional[str|Path], optional): The path to an existing project directory. + If it's None, a new project path will be created. Defaults to None. + + Returns: + Path: The path to the PRD files under the project directory + + Example: + >>> # Create a new project: + >>> from metagpt.tools.libs.software_development import write_prd + >>> prd_path = await write_prd("Create a new feature for the application") + >>> print(prd_path) + '/path/to/project_path/docs/prd/' + + >>> # Add user requirements to the exists project: + >>> from metagpt.tools.libs.software_development import write_prd + >>> project_path = '/path/to/exists_project_path' + >>> prd_path = await write_prd("Create a new feature for the application", project_path=project_path) + >>> print(prd_path = ) + '/path/to/project_path/docs/prd/' + """ + from metagpt.actions import UserRequirement + from metagpt.context import Context + from metagpt.roles import ProductManager + + ctx = Context() + if project_path: + ctx.config.project_path = Path(project_path) + ctx.config.inc = True + role = ProductManager(context=ctx) + msg = await role.run(with_message=Message(content=idea, cause_by=UserRequirement)) + await role.run(with_message=msg) + return ctx.repo.docs.prd.workdir + + +@register_tool(tags=["software development", "Architect"]) +async def write_design(prd_path: str | Path) -> Path: + """Writes a design to the project repository, based on the PRD of the project. + + Args: + prd_path (str|Path): The path to the PRD files under the project directory. + + Returns: + Path: The path to the system design files under the project directory. + + Example: + >>> from metagpt.tools.libs.software_development import write_design + >>> prd_path = '/path/to/project_path/docs/prd' # Returned by `write_prd` + >>> system_design_path = await write_desgin(prd_path) + >>> print(system_design_path) + '/path/to/project_path/docs/system_design/' + + """ + from metagpt.actions import WritePRD + from metagpt.context import Context + from metagpt.roles import Architect + + ctx = Context() + project_path = Path(prd_path).parent.parent + ctx.set_repo_dir(project_path) + + role = Architect(context=ctx) + await role.run(with_message=Message(content="", cause_by=WritePRD)) + return ctx.repo.docs.system_design.workdir + + +@register_tool(tags=["software development", "Architect"]) +async def write_project_plan(system_design_path: str | Path) -> Path: + """Writes a project plan to the project repository, based on the design of the project. + + Args: + system_design_path (str|Path): The path to the system design files under the project directory. + + Returns: + Path: The path to task files under the project directory. + + Example: + >>> from metagpt.tools.libs.software_development import write_project_plan + >>> system_design_path = '/path/to/project_path/docs/system_design/' # Returned by `write_design` + >>> task_path = await write_project_plan(system_design_path) + >>> print(task_path) + '/path/to/project_path/docs/task' + + """ + from metagpt.actions import WriteDesign + from metagpt.context import Context + from metagpt.roles import ProjectManager + + ctx = Context() + project_path = Path(system_design_path).parent.parent + ctx.set_repo_dir(project_path) + + role = ProjectManager(context=ctx) + await role.run(with_message=Message(content="", cause_by=WriteDesign)) + return ctx.repo.docs.task.workdir + + +@register_tool(tags=["software development", "Engineer"]) +async def write_codes(task_path: str | Path, inc: bool = False) -> Path: + """Writes codes to the project repository, based on the project plan of the project. + + Args: + task_path (str|Path): The path to task files under the project directory. + inc (bool, optional): Whether to write incremental codes. Defaults to False. + + Returns: + Path: The path to the source code files under the project directory. + + Example: + # Write codes to a new project + >>> from metagpt.tools.libs.software_development import write_codes + >>> task_path = '/path/to/project_path/docs/task' # Returned by `write_project_plan` + >>> src_path = await write_codes(task_path) + >>> print(src_path) + '/path/to/project_path/src/' + + # Write increment codes to the exists project + >>> from metagpt.tools.libs.software_development import write_codes + >>> task_path = '/path/to/project_path/docs/task' # Returned by `write_prd` + >>> src_path = await write_codes(task_path, inc=True) + >>> print(src_path) + '/path/to/project_path/src/' + """ + from metagpt.actions import WriteTasks + from metagpt.context import Context + from metagpt.roles import Engineer + + ctx = Context() + ctx.config.inc = inc + project_path = Path(task_path).parent.parent + ctx.set_repo_dir(project_path) + + role = Engineer(context=ctx) + msg = Message(content="", cause_by=WriteTasks, send_to=role) + me = {any_to_str(role), role.name} + while me.intersection(msg.send_to): + msg = await role.run(with_message=msg) + return ctx.repo.srcs.workdir + + +@register_tool(tags=["software development", "QaEngineer"]) +async def run_qa_test(src_path: str | Path) -> Path: + """Run QA test on the project repository. + + Args: + src_path (str|Path): The path to the source code files under the project directory. + + Returns: + Path: The path to the unit tests under the project directory + + Example: + >>> from metagpt.tools.libs.software_development import run_qa_test + >>> src_path = '/path/to/project_path/src/' # Returned by `write_codes` + >>> test_path = await run_qa_test(src_path) + >>> print(test_path) + '/path/to/project_path/tests' + """ + from metagpt.actions.summarize_code import SummarizeCode + from metagpt.context import Context + from metagpt.environment import Environment + from metagpt.roles import QaEngineer + + ctx = Context() + project_path = Path(src_path).parent + ctx.set_repo_dir(project_path) + ctx.src_workspace = ctx.git_repo.workdir / ctx.git_repo.workdir.name + + env = Environment(context=ctx) + role = QaEngineer(context=ctx) + env.add_role(role) + + msg = Message(content="", cause_by=SummarizeCode, send_to=role) + env.publish_message(msg) + + while not env.is_idle: + await env.run() + return ctx.repo.tests.workdir + + +@register_tool(tags=["software development", "Engineer"]) +async def fix_bug(project_path: str | Path, issue: str) -> Path: + """Fix bugs in the project repository. + + Args: + project_path (str|Path): The path to the project repository. + issue (str): Description of the bug or issue. + + Returns: + Path: The path to the project directory + + Example: + >>> from metagpt.tools.libs.software_development import fix_bug + >>> project_path = '/path/to/project_path' # Returned by `write_codes` + >>> issue = 'Exception: exception about ...; Bug: bug about ...; Issue: issue about ...' + >>> project_path = await fix_bug(project_path=project_path, issue=issue) + >>> print(project_path) + '/path/to/project_path' + """ + from metagpt.actions.fix_bug import FixBug + from metagpt.context import Context + from metagpt.roles import Engineer + + ctx = Context() + ctx.set_repo_dir(project_path) + ctx.src_workspace = ctx.git_repo.workdir / ctx.git_repo.workdir.name + await ctx.repo.docs.save(filename=BUGFIX_FILENAME, content=issue) + await ctx.repo.docs.save(filename=REQUIREMENT_FILENAME, content="") + + role = Engineer(context=ctx) + bug_fix = BugFixContext(filename=BUGFIX_FILENAME) + msg = Message( + content=bug_fix.model_dump_json(), + instruct_content=bug_fix, + role="", + cause_by=FixBug, + sent_from=role, + send_to=role, + ) + me = {any_to_str(role), role.name} + while me.intersection(msg.send_to): + msg = await role.run(with_message=msg) + return project_path + + +@register_tool(tags=["software development", "git"]) +async def git_archive(project_path: str | Path) -> str: + """Stage and commit changes for the project repository using Git. + + Args: + project_path (str|Path): The path to the project repository. + + + Returns: + git log + + Example: + >>> from metagpt.tools.libs.software_development import git_archive + >>> project_path = '/path/to/project_path' # Returned by `write_prd` + >>> git_log = await git_archive(project_path=project_path) + >>> print(git_log) + commit a221d1c418c07f2b4fc07001e486285ead1a520a (HEAD -> feature/toollib/software_company, geekan/main) + Merge: e01afd09 4a72f398 + Author: Sirui Hong + Date: Tue Mar 19 15:16:03 2024 +0800 + Merge pull request #1037 from iorisa/fixbug/issues/1018 + fixbug: #1018 + + """ + from metagpt.context import Context + + ctx = Context() + ctx.set_repo_dir(project_path) + ctx.git_repo.archive() + return ctx.git_repo.log() diff --git a/metagpt/utils/git_repository.py b/metagpt/utils/git_repository.py index c4bdf0f4a..8ed22cadf 100644 --- a/metagpt/utils/git_repository.py +++ b/metagpt/utils/git_repository.py @@ -55,7 +55,7 @@ class GitRepository: self._dependency = None self._gitignore_rules = None if local_path: - self.open(local_path=local_path, auto_init=auto_init) + self.open(local_path=Path(local_path), auto_init=auto_init) def open(self, local_path: Path, auto_init=False): """Open an existing Git repository or initialize a new one if auto_init is True. @@ -71,7 +71,7 @@ class GitRepository: if not auto_init: return local_path.mkdir(parents=True, exist_ok=True) - return self._init(local_path) + self._init(local_path) def _init(self, local_path: Path): """Initialize a new Git repository at the specified path. @@ -306,7 +306,7 @@ class GitRepository: stdout, stderr, return_code = await shell_execute(command=command, cwd=str(to_path), env=env, timeout=600) info = f"{stdout}\n{stderr}\nexit: {return_code}\n" logger.info(info) - dir_name = Path(url).with_suffix("").name + dir_name = Path(url).stem to_path = to_path / dir_name if not cls.is_git_dir(to_path): raise ValueError(info) @@ -316,3 +316,7 @@ class GitRepository: async def checkout(self, commit_id: str): self._repository.git.checkout(commit_id) logger.info(f"git checkout {commit_id}") + + def log(self) -> str: + """Return git log""" + return self._repository.git.log() diff --git a/tests/metagpt/tools/libs/test_software_development.py b/tests/metagpt/tools/libs/test_software_development.py new file mode 100644 index 000000000..d51fc3dc1 --- /dev/null +++ b/tests/metagpt/tools/libs/test_software_development.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import pytest + +from metagpt.tools.libs import ( + fix_bug, + git_archive, + run_qa_test, + write_codes, + write_design, + write_prd, + write_project_plan, +) + + +@pytest.mark.asyncio +async def test_software_team(): + path = await write_prd("snake game") + assert path + + path = await write_design(path) + assert path + + path = await write_project_plan(path) + assert path + + path = await write_codes(path) + assert path + + path = await run_qa_test(path) + assert path + + issue = """ +pygame 2.0.1 (SDL 2.0.14, Python 3.9.17) +Hello from the pygame community. https://www.pygame.org/contribute.html +Traceback (most recent call last): + File "/Users/ix/github/bak/MetaGPT/workspace/snake_game/snake_game/main.py", line 10, in + main() + File "/Users/ix/github/bak/MetaGPT/workspace/snake_game/snake_game/main.py", line 7, in main + game.start_game() + File "/Users/ix/github/bak/MetaGPT/workspace/snake_game/snake_game/game.py", line 81, in start_game + x +NameError: name 'x' is not defined + """ + path = await fix_bug(path, issue) + assert path + + new_path = await write_prd("snake game with moving enemy", path) + assert new_path == path + + git_log = await git_archive(new_path) + assert git_log + + +if __name__ == "__main__": + pytest.main([__file__, "-s"])