github-kb 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
github_kb/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.1"
github_kb/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from github_kb.cli import cli
2
+
3
+
4
+ if __name__ == "__main__":
5
+ cli()
github_kb/cli.py ADDED
@@ -0,0 +1,25 @@
1
+ import click
2
+
3
+ from github_kb.commands.ask import ask_command
4
+ from github_kb.commands.audit import audit_command
5
+ from github_kb.commands.chat import chat_command
6
+ from github_kb.commands.doctor import doctor_command
7
+ from github_kb.commands.endpoints import endpoints_command
8
+ from github_kb.commands.explain import explain_command
9
+
10
+
11
+ @click.group()
12
+ def cli() -> None:
13
+ """Ask questions about a GitHub repository using Strands Agents and AWS Bedrock."""
14
+
15
+
16
+ cli.add_command(ask_command)
17
+ cli.add_command(audit_command)
18
+ cli.add_command(chat_command)
19
+ cli.add_command(doctor_command)
20
+ cli.add_command(endpoints_command)
21
+ cli.add_command(explain_command)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ cli()
@@ -0,0 +1 @@
1
+ # Commands package.
@@ -0,0 +1,32 @@
1
+ import click
2
+
3
+ from github_kb.commands.common import join_parts, run_prompt, runtime_options
4
+
5
+
6
+ @click.command(name="ask")
7
+ @click.argument("repository")
8
+ @click.argument("question", nargs=-1, required=True)
9
+ @click.option("--ref", default=None, help="Git branch, tag or commit to inspect.")
10
+ @click.option("--refresh", is_flag=True, help="Refresh the cached local checkout.")
11
+ @runtime_options
12
+ def ask_command(
13
+ repository: str,
14
+ question: tuple[str, ...],
15
+ ref: str | None,
16
+ refresh: bool,
17
+ aws_profile: str | None,
18
+ region: str | None,
19
+ model: str | None,
20
+ ) -> None:
21
+ """Ask an open question about a GitHub repository."""
22
+
23
+ run_prompt(
24
+ repository,
25
+ join_parts(question),
26
+ title="Answer",
27
+ ref=ref,
28
+ refresh=refresh,
29
+ aws_profile=aws_profile,
30
+ region=region,
31
+ model=model,
32
+ )
@@ -0,0 +1,33 @@
1
+ import click
2
+
3
+ from github_kb.commands.common import run_prompt, runtime_options
4
+ from github_kb.lib.prompts import build_audit_prompt
5
+
6
+
7
+ @click.command(name="audit")
8
+ @click.argument("repository")
9
+ @click.option("--focus", default=None, help="Optional area to focus the audit on.")
10
+ @click.option("--ref", default=None, help="Git branch, tag or commit to inspect.")
11
+ @click.option("--refresh", is_flag=True, help="Refresh the cached local checkout.")
12
+ @runtime_options
13
+ def audit_command(
14
+ repository: str,
15
+ focus: str | None,
16
+ ref: str | None,
17
+ refresh: bool,
18
+ aws_profile: str | None,
19
+ region: str | None,
20
+ model: str | None,
21
+ ) -> None:
22
+ """Run a code audit against the repository."""
23
+
24
+ run_prompt(
25
+ repository,
26
+ build_audit_prompt(focus=focus),
27
+ title="Audit",
28
+ ref=ref,
29
+ refresh=refresh,
30
+ aws_profile=aws_profile,
31
+ region=region,
32
+ model=model,
33
+ )
@@ -0,0 +1,60 @@
1
+ import click
2
+
3
+ from github_kb.commands.common import prepare_agent, runtime_options
4
+ from github_kb.lib.ui import get_console, print_repository_panel, print_result
5
+
6
+ EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q", "q"}
7
+
8
+
9
+ def is_exit_command(value: str) -> bool:
10
+ return value.strip().lower() in EXIT_COMMANDS
11
+
12
+
13
+ @click.command(name="chat")
14
+ @click.argument("repository")
15
+ @click.option("--ref", default=None, help="Git branch, tag or commit to inspect.")
16
+ @click.option("--refresh", is_flag=True, help="Refresh the cached local checkout.")
17
+ @runtime_options
18
+ def chat_command(
19
+ repository: str,
20
+ ref: str | None,
21
+ refresh: bool,
22
+ aws_profile: str | None,
23
+ region: str | None,
24
+ model: str | None,
25
+ ) -> None:
26
+ """Start a conversational session about a GitHub repository."""
27
+
28
+ console = get_console()
29
+ repository_context, agent = prepare_agent(
30
+ repository,
31
+ ref=ref,
32
+ refresh=refresh,
33
+ aws_profile=aws_profile,
34
+ region=region,
35
+ model=model,
36
+ )
37
+ print_repository_panel(repository_context)
38
+ console.print("Interactive mode. Type your question or `/exit` to finish.\n")
39
+
40
+ while True:
41
+ try:
42
+ question = console.input("[bold cyan]> [/]").strip()
43
+ except (EOFError, KeyboardInterrupt):
44
+ console.print("\nSession closed.")
45
+ break
46
+
47
+ if not question:
48
+ continue
49
+
50
+ if is_exit_command(question):
51
+ console.print("Session closed.")
52
+ break
53
+
54
+ try:
55
+ with console.status("Thinking with Bedrock...", spinner="dots"):
56
+ result = agent(question)
57
+ except Exception as error:
58
+ raise click.ClickException(str(error)) from error
59
+
60
+ print_result("Answer", str(result))
@@ -0,0 +1,109 @@
1
+ from collections.abc import Sequence
2
+ from functools import wraps
3
+
4
+ import click
5
+ from strands import Agent
6
+
7
+ from github_kb.lib.agent import create_agent
8
+ from github_kb.lib.github import ensure_repository, parse_repository
9
+ from github_kb.lib.models import RepositoryContext
10
+ from github_kb.lib.repository import RepositoryExplorer
11
+ from github_kb.lib.ui import get_console, print_repository_panel, print_result
12
+ from github_kb.settings import resolve_settings
13
+
14
+
15
+ def runtime_options(function):
16
+ @click.option(
17
+ "--aws-profile",
18
+ default=None,
19
+ help="AWS profile name to use. Falls back to AWS_PROFILE if unset.",
20
+ )
21
+ @click.option(
22
+ "--region",
23
+ default=None,
24
+ help="AWS region for Bedrock. Falls back to AWS_REGION if unset.",
25
+ )
26
+ @click.option(
27
+ "--model",
28
+ default=None,
29
+ help="Bedrock model id. Falls back to BEDROCK_MODEL_ID if unset.",
30
+ )
31
+ @wraps(function)
32
+ def wrapper(*args, **kwargs):
33
+ return function(*args, **kwargs)
34
+
35
+ return wrapper
36
+
37
+
38
+ def join_parts(parts: Sequence[str]) -> str:
39
+ return " ".join(part for part in parts if part).strip()
40
+
41
+
42
+ def run_prompt(
43
+ repository: str,
44
+ prompt: str,
45
+ *,
46
+ title: str,
47
+ ref: str | None = None,
48
+ refresh: bool = False,
49
+ aws_profile: str | None = None,
50
+ region: str | None = None,
51
+ model: str | None = None,
52
+ ) -> None:
53
+ repository_context, agent = prepare_agent(
54
+ repository,
55
+ ref=ref,
56
+ refresh=refresh,
57
+ aws_profile=aws_profile,
58
+ region=region,
59
+ model=model,
60
+ )
61
+ print_repository_panel(repository_context)
62
+
63
+ console = get_console()
64
+ try:
65
+ with console.status("Thinking with Bedrock...", spinner="dots"):
66
+ result = agent(prompt)
67
+ except Exception as error:
68
+ raise click.ClickException(str(error)) from error
69
+
70
+ print_result(title, str(result))
71
+
72
+
73
+ def prepare_agent(
74
+ repository: str,
75
+ *,
76
+ ref: str | None = None,
77
+ refresh: bool = False,
78
+ aws_profile: str | None = None,
79
+ region: str | None = None,
80
+ model: str | None = None,
81
+ ) -> tuple[RepositoryContext, Agent]:
82
+ settings = resolve_settings(
83
+ aws_profile=aws_profile,
84
+ aws_region=region,
85
+ bedrock_model_id=model,
86
+ )
87
+ console = get_console()
88
+
89
+ try:
90
+ repo_reference = parse_repository(repository, ref=ref)
91
+ except ValueError as error:
92
+ raise click.ClickException(str(error)) from error
93
+
94
+ try:
95
+ with console.status(f"Preparing {repo_reference.slug}...", spinner="dots"):
96
+ repository_context = ensure_repository(
97
+ repo_reference,
98
+ settings=settings,
99
+ refresh=refresh,
100
+ )
101
+ explorer = RepositoryExplorer(
102
+ root_path=repository_context.local_path,
103
+ settings=settings,
104
+ )
105
+ agent = create_agent(explorer, settings=settings)
106
+ except Exception as error:
107
+ raise click.ClickException(str(error)) from error
108
+
109
+ return repository_context, agent
@@ -0,0 +1,39 @@
1
+ import click
2
+ from rich.table import Table
3
+
4
+ from github_kb.commands.common import runtime_options
5
+ from github_kb.lib.doctor import run_diagnostics
6
+ from github_kb.lib.ui import get_console
7
+ from github_kb.settings import resolve_settings
8
+
9
+
10
+ @click.command(name="doctor")
11
+ @runtime_options
12
+ def doctor_command(
13
+ aws_profile: str | None,
14
+ region: str | None,
15
+ model: str | None,
16
+ ) -> None:
17
+ """Validate the local runtime, AWS credentials and Bedrock access."""
18
+
19
+ console = get_console()
20
+ settings = resolve_settings(
21
+ aws_profile=aws_profile,
22
+ aws_region=region,
23
+ bedrock_model_id=model,
24
+ )
25
+ results = run_diagnostics(settings)
26
+
27
+ table = Table(title="github-kb doctor")
28
+ table.add_column("Check")
29
+ table.add_column("Status")
30
+ table.add_column("Details")
31
+
32
+ for result in results:
33
+ status = "OK" if result.ok else "FAIL"
34
+ table.add_row(result.name, status, result.message)
35
+
36
+ console.print(table)
37
+
38
+ if not all(result.ok for result in results):
39
+ raise click.ClickException("One or more doctor checks failed.")
@@ -0,0 +1,31 @@
1
+ import click
2
+
3
+ from github_kb.commands.common import run_prompt, runtime_options
4
+ from github_kb.lib.prompts import build_endpoints_prompt
5
+
6
+
7
+ @click.command(name="endpoints")
8
+ @click.argument("repository")
9
+ @click.option("--ref", default=None, help="Git branch, tag or commit to inspect.")
10
+ @click.option("--refresh", is_flag=True, help="Refresh the cached local checkout.")
11
+ @runtime_options
12
+ def endpoints_command(
13
+ repository: str,
14
+ ref: str | None,
15
+ refresh: bool,
16
+ aws_profile: str | None,
17
+ region: str | None,
18
+ model: str | None,
19
+ ) -> None:
20
+ """List the API endpoints found in the repository."""
21
+
22
+ run_prompt(
23
+ repository,
24
+ build_endpoints_prompt(),
25
+ title="Endpoints",
26
+ ref=ref,
27
+ refresh=refresh,
28
+ aws_profile=aws_profile,
29
+ region=region,
30
+ model=model,
31
+ )
@@ -0,0 +1,33 @@
1
+ import click
2
+
3
+ from github_kb.commands.common import run_prompt, runtime_options
4
+ from github_kb.lib.prompts import build_explain_prompt
5
+
6
+
7
+ @click.command(name="explain")
8
+ @click.argument("repository")
9
+ @click.option("--topic", default=None, help="Specific topic, module or flow to explain.")
10
+ @click.option("--ref", default=None, help="Git branch, tag or commit to inspect.")
11
+ @click.option("--refresh", is_flag=True, help="Refresh the cached local checkout.")
12
+ @runtime_options
13
+ def explain_command(
14
+ repository: str,
15
+ topic: str | None,
16
+ ref: str | None,
17
+ refresh: bool,
18
+ aws_profile: str | None,
19
+ region: str | None,
20
+ model: str | None,
21
+ ) -> None:
22
+ """Explain how the repository works."""
23
+
24
+ run_prompt(
25
+ repository,
26
+ build_explain_prompt(topic=topic),
27
+ title="Explanation",
28
+ ref=ref,
29
+ refresh=refresh,
30
+ aws_profile=aws_profile,
31
+ region=region,
32
+ model=model,
33
+ )
@@ -0,0 +1,2 @@
1
+ AWS_REGION=us-west-2
2
+ BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-20250514-v1:0
@@ -0,0 +1 @@
1
+ # Library package.
github_kb/lib/agent.py ADDED
@@ -0,0 +1,61 @@
1
+ from boto3.session import Session
2
+ from strands import Agent, tool
3
+ from strands.models import BedrockModel
4
+
5
+ from github_kb.lib.prompts import SYSTEM_PROMPT
6
+ from github_kb.lib.repository import RepositoryExplorer
7
+ from github_kb.settings import Settings, create_boto_session
8
+
9
+
10
+ def create_agent(explorer: RepositoryExplorer, *, settings: Settings) -> Agent:
11
+ boto_session: Session = create_boto_session(settings)
12
+
13
+ return Agent(
14
+ model=BedrockModel(
15
+ boto_session=boto_session,
16
+ model_id=settings.bedrock_model_id,
17
+ region_name=settings.aws_region,
18
+ ),
19
+ tools=create_tools(explorer),
20
+ system_prompt=SYSTEM_PROMPT,
21
+ )
22
+
23
+
24
+ def create_tools(explorer: RepositoryExplorer) -> list:
25
+ @tool
26
+ def get_directory_tree(path: str = ".", max_depth: int = 4) -> str:
27
+ """Return a tree view for a directory inside the repository."""
28
+
29
+ return explorer.get_directory_tree(path=path, max_depth=max_depth)
30
+
31
+ @tool
32
+ def list_directory(path: str = ".") -> str:
33
+ """List the files and directories inside a repository path."""
34
+
35
+ return explorer.list_directory(path=path)
36
+
37
+ @tool
38
+ def read_file(path: str, start_line: int = 1, end_line: int | None = None) -> str:
39
+ """Read a text file from the repository with line numbers."""
40
+
41
+ return explorer.read_file(
42
+ path=path,
43
+ start_line=start_line,
44
+ end_line=end_line,
45
+ )
46
+
47
+ @tool
48
+ def search_code(
49
+ query: str,
50
+ glob_pattern: str | None = None,
51
+ max_results: int = 20,
52
+ ) -> str:
53
+ """Search for text in repository files."""
54
+
55
+ return explorer.search_code(
56
+ query=query,
57
+ glob_pattern=glob_pattern,
58
+ max_results=max_results,
59
+ )
60
+
61
+ return [get_directory_tree, list_directory, read_file, search_code]
@@ -0,0 +1,115 @@
1
+ import shutil
2
+
3
+ from botocore.exceptions import BotoCoreError, ClientError, ProfileNotFound
4
+ from pydantic import BaseModel
5
+
6
+ from github_kb.settings import Settings, create_boto_session
7
+
8
+
9
+ class DiagnosticResult(BaseModel):
10
+ name: str
11
+ ok: bool
12
+ message: str
13
+
14
+
15
+ def run_diagnostics(settings: Settings) -> list[DiagnosticResult]:
16
+ results = [
17
+ check_binary("git", settings.git_binary),
18
+ check_binary("ripgrep", settings.rg_binary),
19
+ ]
20
+ results.extend(check_aws(settings))
21
+ return results
22
+
23
+
24
+ def check_binary(name: str, command: str) -> DiagnosticResult:
25
+ resolved = shutil.which(command)
26
+ if resolved:
27
+ return DiagnosticResult(name=name, ok=True, message=f"Found at {resolved}")
28
+ return DiagnosticResult(name=name, ok=False, message=f"Command not found: {command}")
29
+
30
+
31
+ def check_aws(settings: Settings) -> list[DiagnosticResult]:
32
+ results: list[DiagnosticResult] = []
33
+
34
+ try:
35
+ session = create_boto_session(settings)
36
+ except ProfileNotFound as error:
37
+ return [
38
+ DiagnosticResult(
39
+ name="aws-profile",
40
+ ok=False,
41
+ message=str(error),
42
+ )
43
+ ]
44
+
45
+ profile_name = session.profile_name or settings.aws_profile or "default credential chain"
46
+ results.append(
47
+ DiagnosticResult(
48
+ name="aws-profile",
49
+ ok=True,
50
+ message=f"Using {profile_name}",
51
+ )
52
+ )
53
+
54
+ credentials = session.get_credentials()
55
+ if credentials is None:
56
+ results.append(
57
+ DiagnosticResult(
58
+ name="aws-credentials",
59
+ ok=False,
60
+ message="No AWS credentials found. Set AWS_PROFILE or configure the default AWS credential chain.",
61
+ )
62
+ )
63
+ return results
64
+
65
+ method = getattr(credentials, "method", "resolved")
66
+ results.append(
67
+ DiagnosticResult(
68
+ name="aws-credentials",
69
+ ok=True,
70
+ message=f"Resolved via {method}",
71
+ )
72
+ )
73
+
74
+ try:
75
+ identity = session.client("sts", region_name=settings.aws_region).get_caller_identity()
76
+ arn = identity.get("Arn", "unknown principal")
77
+ account = identity.get("Account", "unknown account")
78
+ results.append(
79
+ DiagnosticResult(
80
+ name="sts",
81
+ ok=True,
82
+ message=f"{arn} ({account})",
83
+ )
84
+ )
85
+ except (BotoCoreError, ClientError) as error:
86
+ results.append(
87
+ DiagnosticResult(
88
+ name="sts",
89
+ ok=False,
90
+ message=f"Unable to call STS: {error}",
91
+ )
92
+ )
93
+ return results
94
+
95
+ try:
96
+ session.client("bedrock", region_name=settings.aws_region).get_foundation_model(
97
+ modelIdentifier=settings.bedrock_model_id
98
+ )
99
+ results.append(
100
+ DiagnosticResult(
101
+ name="bedrock",
102
+ ok=True,
103
+ message=f"Model available: {settings.bedrock_model_id} in {settings.aws_region}",
104
+ )
105
+ )
106
+ except (BotoCoreError, ClientError) as error:
107
+ results.append(
108
+ DiagnosticResult(
109
+ name="bedrock",
110
+ ok=False,
111
+ message=f"Unable to validate model {settings.bedrock_model_id}: {error}",
112
+ )
113
+ )
114
+
115
+ return results
@@ -0,0 +1,111 @@
1
+ from pathlib import Path
2
+ import subprocess
3
+ from urllib.parse import urlparse
4
+
5
+ from github_kb.lib.models import GitHubRepositoryReference, RepositoryContext
6
+ from github_kb.settings import Settings
7
+
8
+ VALID_GITHUB_HOSTS = {"github.com", "www.github.com"}
9
+
10
+
11
+ def parse_repository(value: str, ref: str | None = None) -> GitHubRepositoryReference:
12
+ raw_value = value.strip()
13
+ if not raw_value:
14
+ raise ValueError("Repository cannot be empty.")
15
+
16
+ detected_ref: str | None = None
17
+
18
+ if raw_value.startswith("http://") or raw_value.startswith("https://"):
19
+ parsed = urlparse(raw_value)
20
+ if parsed.netloc not in VALID_GITHUB_HOSTS:
21
+ raise ValueError("Only github.com repositories are supported in this PoC.")
22
+
23
+ parts = [part for part in parsed.path.split("/") if part]
24
+ if len(parts) < 2:
25
+ raise ValueError("Repository must include owner and name.")
26
+
27
+ owner = parts[0]
28
+ name = parts[1].removesuffix(".git")
29
+
30
+ if len(parts) >= 4 and parts[2] == "tree":
31
+ detected_ref = parts[3]
32
+ else:
33
+ parts = [part for part in raw_value.split("/") if part]
34
+ if len(parts) != 2:
35
+ raise ValueError("Repository must look like owner/name or a GitHub URL.")
36
+
37
+ owner = parts[0]
38
+ name = parts[1].removesuffix(".git")
39
+
40
+ return GitHubRepositoryReference(
41
+ owner=owner,
42
+ name=name,
43
+ clone_url=f"https://github.com/{owner}/{name}.git",
44
+ html_url=f"https://github.com/{owner}/{name}",
45
+ ref=ref or detected_ref,
46
+ )
47
+
48
+
49
+ def ensure_repository(
50
+ repository: GitHubRepositoryReference,
51
+ *,
52
+ settings: Settings,
53
+ refresh: bool = False,
54
+ ) -> RepositoryContext:
55
+ cache_path = settings.repo_cache_path / repository.cache_key
56
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
57
+
58
+ if not cache_path.exists():
59
+ _run_git(
60
+ settings,
61
+ ["clone", "--depth", "1", repository.clone_url, str(cache_path)],
62
+ )
63
+ _checkout_ref(cache_path, repository.ref, settings)
64
+ elif refresh:
65
+ _refresh_repository(cache_path, repository.ref, settings)
66
+
67
+ return RepositoryContext(repository=repository, local_path=cache_path)
68
+
69
+
70
+ def _refresh_repository(path: Path, ref: str | None, settings: Settings) -> None:
71
+ if ref:
72
+ _run_git(settings, ["fetch", "--depth", "1", "origin", ref], cwd=path)
73
+ _run_git(settings, ["checkout", "--detach", "FETCH_HEAD"], cwd=path)
74
+ return
75
+
76
+ current_branch = _run_git(
77
+ settings,
78
+ ["rev-parse", "--abbrev-ref", "HEAD"],
79
+ cwd=path,
80
+ ).strip()
81
+ if current_branch == "HEAD":
82
+ current_branch = _run_git(
83
+ settings,
84
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
85
+ cwd=path,
86
+ ).removeprefix("origin/")
87
+ _run_git(settings, ["checkout", current_branch], cwd=path)
88
+ _run_git(settings, ["pull", "--ff-only", "origin", current_branch], cwd=path)
89
+
90
+
91
+ def _checkout_ref(path: Path, ref: str | None, settings: Settings) -> None:
92
+ if not ref:
93
+ return
94
+
95
+ _run_git(settings, ["fetch", "--depth", "1", "origin", ref], cwd=path)
96
+ _run_git(settings, ["checkout", "--detach", "FETCH_HEAD"], cwd=path)
97
+
98
+
99
+ def _run_git(settings: Settings, args: list[str], cwd: Path | None = None) -> str:
100
+ command = [settings.git_binary, *args]
101
+ completed = subprocess.run(
102
+ command,
103
+ cwd=cwd,
104
+ capture_output=True,
105
+ text=True,
106
+ check=False,
107
+ )
108
+ if completed.returncode != 0:
109
+ message = completed.stderr.strip() or completed.stdout.strip() or "Unknown git error"
110
+ raise RuntimeError(message)
111
+ return completed.stdout.strip()