Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/git-draft.1.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ If a bot needs more information, its question will be persisted and displayed wh
Set bot name.
The default is to use the first bot defined in the configuration.

-o OPTION::
--bot-option=OPTION::
Set bot option.
Each option has the format `<key>[=<value>]` (if the value is omitted, it will default to true).
This will override any option set in configuration files.

-e::
--edit::
Enable interactive editing of draft prompts and templates.
Expand Down
9 changes: 8 additions & 1 deletion src/git_draft/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ def callback(
dest="bot",
help="AI bot name",
)
parser.add_option(
"-o",
"--bot-option",
action="append",
dest="bot_options",
help="AI bot options",
)
parser.add_option(
"-e",
"--edit",
Expand Down Expand Up @@ -193,7 +200,7 @@ async def run() -> None: # noqa: PLR0912 PLR0915
bot_config = bot_configs[0]
elif config.bots:
bot_config = config.bots[0]
bot = load_bot(bot_config)
bot = load_bot(bot_config, overrides=opts.bot_options)

prompt: str | TemplatedPrompt
if args:
Expand Down
62 changes: 36 additions & 26 deletions src/git_draft/bots/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
"""Bot interfaces and built-in implementations"""

from collections.abc import Sequence
import importlib
import os
import sys

from ..common import BotConfig, reindent
from ..common import (
BotConfig,
JSONObject,
JSONValue,
UnreachableError,
reindent,
)
from .common import ActionSummary, Bot, Goal, UserFeedback, Worktree


Expand All @@ -17,10 +23,15 @@
]


def load_bot(config: BotConfig | None) -> Bot:
def load_bot(
config: BotConfig | None, *, overrides: Sequence[str] = ()
) -> Bot:
"""Load and return a Bot instance using the provided configuration"""
options = {**config.options} if config and config.options else {}
options.update(_parse_overrides(overrides))

if not config:
return _default_bot()
return _default_bot(options)

if config.pythonpath and config.pythonpath not in sys.path:
sys.path.insert(0, config.pythonpath)
Expand All @@ -35,34 +46,33 @@ def load_bot(config: BotConfig | None) -> Bot:
if not factory:
raise NotImplementedError(f"Unknown bot factory: {config.factory}")

kwargs = config.kwargs or {}
return factory(**kwargs)
return factory(**options)


def _default_bot() -> Bot:
if not os.environ.get("OPENAI_API_KEY"):
raise RuntimeError(
reindent(
"""
The default bot implementation requires an OpenAI API key.
Please specify one via the `$OPENAI_API_KEY` environment
variable or enable a different bot in your configuration.
"""
)
)
def _parse_overrides(overrides: Sequence[str]) -> JSONObject:
options = dict[str, JSONValue]()
for override in overrides:
match override.split("=", 1):
case [switch]:
options[switch] = True
case [flag, value]:
options[flag] = value
case _:
raise UnreachableError()
return options


def _default_bot(options: JSONObject) -> Bot:
try:
from .openai_api import new_threads_bot
from .openai_api import new_completions_bot

except ImportError:
raise RuntimeError(
reindent(
"""
The default bot implementation requires the `openai` Python
package. Please install it or specify a different bot in
your configuration.
"""
)
reindent("""
The default bot implementation requires the `openai` Python
package. Please install it or specify a different bot in
your configuration.
""")
)
else:
return new_threads_bot()
return new_completions_bot(**options)
2 changes: 1 addition & 1 deletion src/git_draft/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class BotConfig:

factory: str
name: str | None = None
kwargs: JSONObject | None = None
options: JSONObject | None = None
pythonpath: str | None = None


Expand Down
22 changes: 14 additions & 8 deletions tests/git_draft/bots/__init___test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@


class FakeBot(sut.Bot):
pass
def __init__(self, key: str="default", switch: bool=False) -> None:
self.key = key
self.switch = switch


class TestLoadBot:
Expand All @@ -20,15 +22,19 @@ def import_module(name):
monkeypatch.setattr(importlib, "import_module", import_module)

config = BotConfig(factory="fake_module:FakeBot")
bot = sut.load_bot(config)
assert isinstance(bot, FakeBot)

bot0 = sut.load_bot(config)
assert isinstance(bot0, FakeBot)
assert bot0.key == "default"
assert not bot0.switch

bot1 = sut.load_bot(config, overrides=["key=one", "switch"])
assert isinstance(bot1, FakeBot)
assert bot1.key == "one"
assert bot1.switch


def test_non_existing_factory(self) -> None:
config = BotConfig("git_draft:unknown_factory")
with pytest.raises(NotImplementedError):
sut.load_bot(config)

def test_default_no_key(self, monkeypatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "")
with pytest.raises(RuntimeError):
sut.load_bot(None)
4 changes: 2 additions & 2 deletions tests/git_draft/common_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def test_load_ok(self) -> None:
[[bots]]
name = "bar"
factory = "bar"
kwargs = {one=1}
options = {one=1}
"""
path = sut.Config.folder_path()
path.mkdir(parents=True, exist_ok=True)
Expand All @@ -42,7 +42,7 @@ def test_load_ok(self) -> None:
log_level=logging.DEBUG,
bots=[
sut.BotConfig(factory="foo:load", pythonpath="./abc"),
sut.BotConfig(factory="bar", name="bar", kwargs={"one": 1}),
sut.BotConfig(factory="bar", name="bar", options={"one": 1}),
],
)

Expand Down