Passed
Push — main ( 453471...b6e05d )
by Ray
01:23
created

menderbot.__main__.commit()   B

Complexity

Conditions 6

Size

Total Lines 28
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 26
nop 0
dl 0
loc 28
rs 8.3226
c 0
b 0
f 0
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()
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
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()
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
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
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable failed does not seem to be defined.
Loading history...
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