| Total Complexity | 50 | 
| Total Lines | 334 | 
| Duplicated Lines | 8.98 % | 
| Changes | 0 | ||
Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.
Common duplication problems, and corresponding solutions are:
Complex classes like menderbot.__main__ often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
| 1 | import sys  | 
            ||
| 2 | |||
| 3 | import rich_click as click  | 
            ||
| 4 | from click import Abort  | 
            ||
| 5 | from rich.console import Console  | 
            ||
| 6 | from rich.progress import Progress  | 
            ||
| 7 | from rich.prompt import Confirm  | 
            ||
| 8 | |||
| 9 | from menderbot import __version__  | 
            ||
| 10 | from menderbot.check import run_check  | 
            ||
| 11 | from menderbot.config import create_default_config, has_config, has_llm_consent  | 
            ||
| 12 | from menderbot.git_client import git_commit, git_diff_head, git_show_top_level  | 
            ||
| 13 | from menderbot.ingest import ask_index, get_chat_engine, index_exists, ingest_repo  | 
            ||
| 14 | from menderbot.llm import (  | 
            ||
| 15 | INSTRUCTIONS,  | 
            ||
| 16 | get_response,  | 
            ||
| 17 | has_key,  | 
            ||
| 18 | key_env_var,  | 
            ||
| 19 | unwrap_codeblock,  | 
            ||
| 20 | )  | 
            ||
| 21 | from menderbot.prompts import (  | 
            ||
| 22 | change_list_prompt,  | 
            ||
| 23 | code_review_prompt,  | 
            ||
| 24 | commit_msg_prompt,  | 
            ||
| 25 | type_prompt,  | 
            ||
| 26 | )  | 
            ||
| 27 | from menderbot.source_file import SourceFile  | 
            ||
| 28 | |||
| 29 | console = Console()  | 
            ||
| 30 | |||
| 31 | |||
| 32 | @click.group(context_settings=dict(help_option_names=["-h", "--help"]))  | 
            ||
| 33 | @click.version_option(__version__, prog_name="menderbot")  | 
            ||
| 34 | @click.pass_context  | 
            ||
| 35 | def cli(ctx):  | 
            ||
| 36 | """  | 
            ||
| 37 | An AI-powered command line tool for working with legacy code.  | 
            ||
| 38 | |||
| 39 | You can try using --help at the top level and also for  | 
            ||
| 40 | specific subcommands.  | 
            ||
| 41 | |||
| 42 | Connects to OpenAI using OPENAI_API_KEY environment variable.  | 
            ||
| 43 | """  | 
            ||
| 44 | if not has_key():  | 
            ||
| 45 |         console.log(f"{key_env_var()} not found in env, will not be able to connect.") | 
            ||
| 46 | ctx.ensure_object(dict)  | 
            ||
| 47 | |||
| 48 | |||
| 49 | @cli.command()  | 
            ||
| 50 | @click.argument("q", required=False) | 
            ||
| 51 | def ask(q):  | 
            ||
| 52 | """Ask a question about a specific piece of code or concept."""  | 
            ||
| 53 | if not index_exists():  | 
            ||
| 54 |         console.print("[red]Index not found[/red]: please run menderbot ingest") | 
            ||
| 55 | return  | 
            ||
| 56 | check_llm_consent()  | 
            ||
| 57 | new_question = q  | 
            ||
| 58 | if not new_question:  | 
            ||
| 59 |         new_question = console.input("[green]Ask[/green]: ") | 
            ||
| 60 | with Progress(transient=True) as progress:  | 
            ||
| 61 |         task = progress.add_task("[green]Processing...", total=None) | 
            ||
| 62 | response = ask_index(new_question)  | 
            ||
| 63 | progress.update(task, completed=True)  | 
            ||
| 64 |     console.print(f"[cyan]Bot[/cyan]: {response}") | 
            ||
| 65 | |||
| 66 | |||
| 67 | @cli.command()  | 
            ||
| 68 | def chat():  | 
            ||
| 69 | """Interactively chat in the context of the current directory."""  | 
            ||
| 70 | if not index_exists():  | 
            ||
| 71 |         console.print("[red]Index not found[/red]: please run menderbot ingest") | 
            ||
| 72 | else:  | 
            ||
| 73 |         console.print("Loading index...") | 
            ||
| 74 | check_llm_consent()  | 
            ||
| 75 | chat_engine = get_chat_engine()  | 
            ||
| 76 | while True:  | 
            ||
| 77 |         new_question = console.input("[green]Ask[/green]: ") | 
            ||
| 78 | # new_question += "\nUse your tool to query for context."  | 
            ||
| 79 | if new_question:  | 
            ||
| 80 | streaming_response = chat_engine.stream_chat(new_question)  | 
            ||
| 81 |             console.print("[cyan]Bot[/cyan]: ", end="") | 
            ||
| 82 | for token in streaming_response.response_gen:  | 
            ||
| 83 | console.out(token, end="")  | 
            ||
| 84 |             console.out("\n") | 
            ||
| 85 | |||
| 86 | |||
| 87 | def try_function_type_hints(  | 
            ||
| 88 | mypy_cmd, source_file, function_ast, needs_typing  | 
            ||
| 89 | ):  | 
            ||
| 90 | from menderbot.typing import add_type_hints, parse_type_hint_answer # Lazy import  | 
            ||
| 91 | function_text = function_ast.text  | 
            ||
| 92 | check_command = (  | 
            ||
| 93 |         f"{mypy_cmd} --shadow-file {source_file.path} {source_file.path}.shadow" | 
            ||
| 94 | )  | 
            ||
| 95 | max_tries = 2  | 
            ||
| 96 | check_output = None  | 
            ||
| 97 | # First set them all to wrong type, to produce an error message.  | 
            ||
| 98 | hints = [(ident, "None") for ident in needs_typing]  | 
            ||
| 99 | # TODO  | 
            ||
| 100 | imports = []  | 
            ||
| 101 | insertions_for_function = add_type_hints(function_ast, hints, imports)  | 
            ||
| 102 | if insertions_for_function:  | 
            ||
| 103 | source_file.update_file(insertions_for_function, suffix=".shadow")  | 
            ||
| 104 |         console.print("> ", check_command) | 
            ||
| 105 | (success, pre_check_output) = run_check(check_command)  | 
            ||
| 106 | if not success:  | 
            ||
| 107 | check_output = pre_check_output  | 
            ||
| 108 | for try_num in range(0, max_tries):  | 
            ||
| 109 | if try_num > 0:  | 
            ||
| 110 |             console.print("Retrying") | 
            ||
| 111 | prompt = type_prompt(function_text, needs_typing, previous_error=check_output)  | 
            ||
| 112 | answer = get_response_with_progress(INSTRUCTIONS, [], prompt)  | 
            ||
| 113 | hints = parse_type_hint_answer(answer)  | 
            ||
| 114 | |||
| 115 | insertions_for_function = add_type_hints(function_ast, hints, imports)  | 
            ||
| 116 | if insertions_for_function:  | 
            ||
| 117 |             console.print(f"[cyan]Bot[/cyan]: {hints}") | 
            ||
| 118 | source_file.update_file(insertions_for_function, suffix=".shadow")  | 
            ||
| 119 | (success, check_output) = run_check(check_command)  | 
            ||
| 120 | if success:  | 
            ||
| 121 |                 console.print("[green]Type checker passed[/green], keeping") | 
            ||
| 122 | return insertions_for_function  | 
            ||
| 123 | else:  | 
            ||
| 124 | console.out(check_output)  | 
            ||
| 125 |                 console.print("\n[red]Type checker failed[/red], discarding") | 
            ||
| 126 | else:  | 
            ||
| 127 |             console.print("[cyan]Bot[/cyan]: No changes") | 
            ||
| 128 | # No retry if it didn't try to hint anything.  | 
            ||
| 129 | return []  | 
            ||
| 130 | return []  | 
            ||
| 131 | |||
| 132 | |||
| 133 | @cli.command("type") | 
            ||
| 134 | @click.argument("file") | 
            ||
| 135 | def type_command(file):  | 
            ||
| 136 | """Insert type hints (Python only)"""  | 
            ||
| 137 | from menderbot.typing import process_untyped_functions # Lazy import  | 
            ||
| 138 | |||
| 139 | check_llm_consent()  | 
            ||
| 140 |     console.print("Running type-checker baseline") | 
            ||
| 141 |     mypy_cmd = f"mypy --ignore-missing-imports --no-error-summary --soft-error-limit 10 '{file}'" | 
            ||
| 142 |     (success, check_output) = run_check(f"{mypy_cmd}") | 
            ||
| 143 | if not success:  | 
            ||
| 144 | console.print(check_output)  | 
            ||
| 145 |         console.print("Baseline failed, aborting.") | 
            ||
| 146 | return  | 
            ||
| 147 | source_file = SourceFile(file)  | 
            ||
| 148 | insertions = []  | 
            ||
| 149 | for function_ast, needs_typing in process_untyped_functions(  | 
            ||
| 150 | source_file  | 
            ||
| 151 | ):  | 
            ||
| 152 | insertions += try_function_type_hints(  | 
            ||
| 153 | mypy_cmd, source_file, function_ast, needs_typing  | 
            ||
| 154 | )  | 
            ||
| 155 | if not insertions:  | 
            ||
| 156 |         console.print(f"No changes for '{file}.") | 
            ||
| 157 | return  | 
            ||
| 158 |     if not Confirm.ask(f"Write '{file}'?"): | 
            ||
| 159 |         console.print("Skipping.") | 
            ||
| 160 | return  | 
            ||
| 161 | source_file.update_file(insertions, suffix="")  | 
            ||
| 162 |     console.print("Done.") | 
            ||
| 163 | |||
| 164 | |||
| 165 | def get_response_with_progress(instructions, history, question):  | 
            ||
| 166 | with Progress(transient=True) as progress:  | 
            ||
| 167 |         task = progress.add_task("[green]Processing...", total=None) | 
            ||
| 168 | answer = get_response(instructions, history, question)  | 
            ||
| 169 | progress.update(task, completed=True)  | 
            ||
| 170 | return answer  | 
            ||
| 171 | |||
| 172 | |||
| 173 | def generate_doc(code, file_extension):  | 
            ||
| 174 | if not file_extension == ".py":  | 
            ||
| 175 | # Until more types are supported.  | 
            ||
| 176 | return None  | 
            ||
| 177 | question = f"""  | 
            ||
| 178 | Write a short Python docstring for this code.  | 
            ||
| 179 | Do not include Arg lists.  | 
            ||
| 180 | Respond with docstring only, no code.  | 
            ||
| 181 | CODE:  | 
            ||
| 182 | {code} | 
            ||
| 183 | """  | 
            ||
| 184 | from menderbot.code import function_indent, reindent # Lazy import  | 
            ||
| 185 | |||
| 186 | with Progress(transient=True) as progress:  | 
            ||
| 187 |         task = progress.add_task("[green]Processing...", total=None) | 
            ||
| 188 | doc_text = get_response(INSTRUCTIONS, [], question)  | 
            ||
| 189 | progress.update(task, completed=True)  | 
            ||
| 190 | if '"""' in doc_text:  | 
            ||
| 191 |             doc_text = doc_text[0 : doc_text.rfind('"""') + 3] | 
            ||
| 192 |             doc_text = doc_text[doc_text.find('"""') :] | 
            ||
| 193 | indent = function_indent(code)  | 
            ||
| 194 | doc_text = reindent(doc_text, indent)  | 
            ||
| 195 | return doc_text  | 
            ||
| 196 | |||
| 197 | |||
| 198 | @cli.command()  | 
            ||
| 199 | @click.argument("file") | 
            ||
| 200 | def doc(file):  | 
            ||
| 201 | """Generate function-level documentation for the existing code (Python only)."""  | 
            ||
| 202 | from menderbot.doc import document_file # Lazy import  | 
            ||
| 203 | |||
| 204 | check_llm_consent()  | 
            ||
| 205 | source_file = SourceFile(file)  | 
            ||
| 206 | insertions = document_file(source_file, generate_doc)  | 
            ||
| 207 | if not insertions:  | 
            ||
| 208 |         console.print(f"No updates found for '{file}'.") | 
            ||
| 209 | return  | 
            ||
| 210 |     if not Confirm.ask(f"Write '{file}'?"): | 
            ||
| 211 |         console.print("Skipping.") | 
            ||
| 212 | return  | 
            ||
| 213 | source_file.update_file(insertions, suffix="")  | 
            ||
| 214 |     console.print("Done.") | 
            ||
| 215 | |||
| 216 | |||
| 217 | View Code Duplication | @cli.command()  | 
            |
| 
                                                                                                    
                        
                         | 
                |||
| 218 | def review():  | 
            ||
| 219 | """Review a code block or changeset and provide feedback."""  | 
            ||
| 220 | check_llm_consent()  | 
            ||
| 221 |     console.print("Reading diff from STDIN...") | 
            ||
| 222 |     diff_text = click.get_text_stream("stdin").read() | 
            ||
| 223 | new_question = code_review_prompt(diff_text)  | 
            ||
| 224 | with Progress(transient=True) as progress:  | 
            ||
| 225 |         task = progress.add_task("[green]Processing...", total=None) | 
            ||
| 226 | response_1 = get_response(INSTRUCTIONS, [], new_question)  | 
            ||
| 227 | progress.update(task, completed=True)  | 
            ||
| 228 |     console.print(f"[cyan]Bot[/cyan]:\n{response_1}") | 
            ||
| 229 | |||
| 230 | |||
| 231 | @cli.command()  | 
            ||
| 232 | def commit():  | 
            ||
| 233 | """Generate an informative commit message based on a changeset."""  | 
            ||
| 234 | check_llm_consent()  | 
            ||
| 235 | diff_text = git_diff_head(staged=True)  | 
            ||
| 236 | if not diff_text.strip():  | 
            ||
| 237 |         console.print("[yellow]No changes staged. Please stage some then try again.") | 
            ||
| 238 | return  | 
            ||
| 239 | new_question = change_list_prompt(diff_text)  | 
            ||
| 240 | with Progress(transient=True) as progress:  | 
            ||
| 241 |         task = progress.add_task("[green]Processing...", total=None) | 
            ||
| 242 | response_1 = get_response(INSTRUCTIONS, [], new_question)  | 
            ||
| 243 | progress.update(task, completed=True)  | 
            ||
| 244 |     console.print(f"[cyan]Bot (initial pass)[/cyan]:\n{response_1}") | 
            ||
| 245 | |||
| 246 | if not response_1.strip():  | 
            ||
| 247 |         console.print("[red]Didn't get a response for change list summary. Try again?") | 
            ||
| 248 | return  | 
            ||
| 249 | question_2 = commit_msg_prompt(response_1)  | 
            ||
| 250 | with Progress(transient=True) as progress:  | 
            ||
| 251 |         task = progress.add_task("[green]Processing...", total=None) | 
            ||
| 252 | response_2 = unwrap_codeblock(get_response(INSTRUCTIONS, [], question_2))  | 
            ||
| 253 | progress.update(task, completed=True)  | 
            ||
| 254 |     console.print(f"[cyan]Bot[/cyan]:\n{response_2}") | 
            ||
| 255 | if not response_2.strip():  | 
            ||
| 256 |         console.print("[red]Didn't get a response for commit message. Try again?") | 
            ||
| 257 | return  | 
            ||
| 258 | git_commit(response_2)  | 
            ||
| 259 | |||
| 260 | |||
| 261 | View Code Duplication | @cli.command()  | 
            |
| 262 | def diff():  | 
            ||
| 263 | """  | 
            ||
| 264 | Summarize the differences between two versions of a codebase. Takes diff from STDIN.  | 
            ||
| 265 | |||
| 266 | Try:  | 
            ||
| 267 | git diff HEAD | menderbot diff  | 
            ||
| 268 | git diff main..HEAD | menderbot diff  | 
            ||
| 269 | """  | 
            ||
| 270 | check_llm_consent()  | 
            ||
| 271 |     console.print("Reading diff from STDIN...") | 
            ||
| 272 |     diff_text = click.get_text_stream("stdin").read() | 
            ||
| 273 | new_question = change_list_prompt(diff_text)  | 
            ||
| 274 | with Progress(transient=True) as progress:  | 
            ||
| 275 |         task = progress.add_task("[green]Processing...", total=None) | 
            ||
| 276 | response_1 = get_response(INSTRUCTIONS, [], new_question)  | 
            ||
| 277 | progress.update(task, completed=True)  | 
            ||
| 278 |     console.print(f"[cyan]Bot[/cyan]:\n{response_1}") | 
            ||
| 279 | |||
| 280 | |||
| 281 | @cli.command()  | 
            ||
| 282 | def ingest():  | 
            ||
| 283 | """Index files in current repo to be used with 'ask' and 'chat'."""  | 
            ||
| 284 | check_llm_consent()  | 
            ||
| 285 | ingest_repo()  | 
            ||
| 286 | |||
| 287 | |||
| 288 | def check_llm_consent():  | 
            ||
| 289 | if not has_llm_consent():  | 
            ||
| 290 | console.print(  | 
            ||
| 291 | "[red]Error[/red]: This repo does not have consent recorded in .menderbot-config.yaml"  | 
            ||
| 292 | )  | 
            ||
| 293 | if not has_config():  | 
            ||
| 294 | create_default_config()  | 
            ||
| 295 | raise Abort()  | 
            ||
| 296 | |||
| 297 | |||
| 298 | @cli.command()  | 
            ||
| 299 | def check():  | 
            ||
| 300 | """Verify we have what we need to run."""  | 
            ||
| 301 | git_dir = git_show_top_level()  | 
            ||
| 302 | failed = False  | 
            ||
| 303 | |||
| 304 | def check_condition(condition, ok_msg, failed_msg):  | 
            ||
| 305 | nonlocal failed  | 
            ||
| 306 | if condition:  | 
            ||
| 307 |             console.print(f"[green]OK[/green]      {ok_msg}") | 
            ||
| 308 | else:  | 
            ||
| 309 |             console.print(f"[red]Failed[/red]  {failed_msg}") | 
            ||
| 310 | failed = True  | 
            ||
| 311 | |||
| 312 | check_condition(  | 
            ||
| 313 |         git_dir, f"Git repo {git_dir}", "Not in repo directory or git not installed" | 
            ||
| 314 | )  | 
            ||
| 315 | check_condition(  | 
            ||
| 316 | has_key(),  | 
            ||
| 317 |         f"OpenAI API key found in {key_env_var()}", | 
            ||
| 318 |         f"OpenAI API key not found in {key_env_var()}", | 
            ||
| 319 | )  | 
            ||
| 320 | if not has_config():  | 
            ||
| 321 |         create_default_config("No .menderbot-config.yaml file found, creating...") | 
            ||
| 322 | check_condition(  | 
            ||
| 323 | has_llm_consent(),  | 
            ||
| 324 | "LLM consent configured for this repo",  | 
            ||
| 325 | "LLM consent not recorded in .menderbot-config.yaml for this repo, please edit it",  | 
            ||
| 326 | )  | 
            ||
| 327 | if failed:  | 
            ||
| 328 | sys.exit(1)  | 
            ||
| 329 | |||
| 330 | |||
| 331 | if __name__ == "__main__":  | 
            ||
| 332 | # pylint: disable-next=no-value-for-parameter  | 
            ||
| 333 |     cli(obj={}) | 
            ||
| 334 |