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
19 changes: 13 additions & 6 deletions app/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,8 @@ def handle_proposal_response(
action = metadata.get("action", "")
params = metadata.get("parameters", {})

service_type = ACTION_REGISTRY.get(action)
if service_type is None:
registry_entry = ACTION_REGISTRY.get(action)
if registry_entry is None:
msg = ChatMessage(
role="system",
content=f"Unknown action: {action}",
Expand All @@ -311,9 +311,12 @@ def handle_proposal_response(
)
return {"type": "immediate", "message": msg}

# Enqueue the service task through the normal queue
# Enqueue the service task through the normal queue.
# payload_defaults are opaque fields provided by the integration layer
# at registration time (e.g. "integration" ID).
payload: dict[str, Any] = {
"type": service_type,
**registry_entry.get("payload_defaults", {}),
"type": registry_entry["task_type"],
"inputs": params,
"on_result": [
{"type": "chat_reply", "conversation_id": conversation_id},
Expand Down Expand Up @@ -437,9 +440,13 @@ def _build_action_prompt() -> str:
# Action registry — populated at startup by integration registration
# ---------------------------------------------------------------------------

# Maps action name -> service task type (e.g., "service.github.create_issue").
# Maps action name -> {"task_type": str, "payload_defaults": dict}.
# ``task_type`` is the queue task type (e.g. "service.github.create_issue").
# ``payload_defaults`` carries opaque fields that the integration layer needs
# in the task payload (e.g. ``{"integration": "github.my_repos"}``). The chat
# layer merges them into the payload without interpreting them.
# Populated by _register_single_service when a service has a chat config.
ACTION_REGISTRY: dict[str, str] = {}
ACTION_REGISTRY: dict[str, dict[str, Any]] = {}

# Maps action name -> list of response options for the confirmation UI.
# The system attaches these to proposals; the LLM never chooses them.
Expand Down
74 changes: 56 additions & 18 deletions app/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,24 +108,63 @@ def check_git() -> bool:
return True


def check_gh() -> tuple[bool, bool]:
"""Check GitHub CLI. Returns (installed, authenticated)."""
gh = shutil.which("gh")
if not gh:
_warn("GitHub CLI not found (optional, needed for GitHub integration)")
return False, False
def check_github_app() -> bool:
"""Check GitHub App credentials can produce an installation token."""
config_path = PROJECT_ROOT / "config.yaml"
if not config_path.exists():
return True # No config yet, skip

_pass("GitHub CLI found")
try:
raw = _load_config_yaml()
except Exception:
_warn("Could not check GitHub App credentials (config parse error)")
return False

result = subprocess.run( # nosec B603
[gh, "auth", "status"], capture_output=True, text=True
)
if result.returncode == 0:
_pass("GitHub CLI authenticated")
return True, True
else:
_warn("GitHub CLI not authenticated (run: gh auth login)")
return True, False
if not raw:
return True

integrations = raw.get("integrations", [])
github_integrations = [i for i in integrations if i.get("type") == "github"]
if not github_integrations:
_warn("No GitHub integrations configured (optional)")
return True

for ig in github_integrations:
ig_name = ig.get("name", "unknown")
app_id = ig.get("app_id", "")
installation_id = ig.get("installation_id", "")
private_key = ig.get("private_key", "")

if not all([app_id, installation_id, private_key]):
_fail(f"GitHub integration '{ig_name}': missing app credentials")
return False

try:
import httpx
import jwt

now = int(__import__("time").time())
payload = {"iss": str(app_id), "iat": now - 60, "exp": now + 600}
app_jwt = jwt.encode(payload, private_key, algorithm="RS256")
resp = httpx.post(
f"https://api.github.com/app/installations/{installation_id}/access_tokens",
headers={
"Authorization": f"Bearer {app_jwt}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=10,
)
if resp.is_success:
_pass(f"GitHub App credentials valid (integration: {ig_name})")
else:
_fail(f"GitHub App token exchange failed for '{ig_name}': HTTP {resp.status_code}")
return False
except Exception as e:
_fail(f"GitHub App credential check failed for '{ig_name}': {e}")
return False

return True


def _load_config_yaml() -> dict[str, Any] | None:
Expand Down Expand Up @@ -376,8 +415,7 @@ def run_doctor() -> int:
failures += 1
if not check_git():
failures += 1
gh_installed, _gh_authed = check_gh()
if not gh_installed:
if not check_github_app():
warnings += 1

# Configuration
Expand Down
21 changes: 19 additions & 2 deletions app/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,25 @@ def _register_single_service(
chat_config = getattr(service_manifest, "chat", None)
if chat_config is not None:
from app.chat import ACTION_METADATA, ACTION_OPTIONS, ACTION_REGISTRY

ACTION_REGISTRY[key] = key # value is the service task type
from app.config import config

# Build opaque payload defaults the handler needs (e.g. integration ID).
payload_defaults: dict[str, str] = {}
instances = config.get_integrations_by_type(domain)
if len(instances) == 1:
payload_defaults["integration"] = instances[0].id
elif instances:
log.warning(
"Chat action %s: multiple %s instances configured; "
"integration must be resolved at invocation time",
key,
domain,
)

ACTION_REGISTRY[key] = {
"task_type": key,
"payload_defaults": payload_defaults,
}
if chat_config.options:
ACTION_OPTIONS[key] = chat_config.options
ACTION_METADATA[key] = {
Expand Down
1 change: 1 addition & 0 deletions app/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ def _load_manifest(
path=integration_dir,
builtin=builtin,
services=services,
setup_hook=raw.get("setup_hook"),
)


Expand Down
112 changes: 49 additions & 63 deletions app/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
assistant setup --reconfigure # Reconfigure an existing installation
"""

import importlib
import shutil
import subprocess # nosec B404
import sys
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -205,69 +205,55 @@ def setup_email() -> tuple[list[dict[str, Any]], dict[str, str]]:
return integrations, secrets


def setup_github() -> list[dict[str, Any]]:
"""Configure GitHub integration. Returns integrations list."""
_heading("GitHub Integration")
class _SetupPrompts:
"""Adapter that exposes setup prompt helpers to integration setup hooks."""

# Check if gh is available
gh = shutil.which("gh")
if not gh:
_warn("GitHub CLI (gh) not found. Skipping GitHub integration.")
_info("Install gh from https://cli.github.com/ and re-run: assistant setup --reconfigure")
return []
prompt = staticmethod(_prompt)
prompt_yn = staticmethod(_prompt_yn)
prompt_choice = staticmethod(_prompt_choice)
info = staticmethod(_info)
success = staticmethod(_success)
warn = staticmethod(_warn)
heading = staticmethod(_heading)

# Check auth status
result = subprocess.run( # nosec B603
[gh, "auth", "status"], capture_output=True, text=True
)
if result.returncode != 0:
_warn("GitHub CLI not authenticated. Skipping GitHub integration.")
_info("Run 'gh auth login' and then: assistant setup --reconfigure")
return []

_success("GitHub CLI authenticated")

if not _prompt_yn("Set up GitHub integration?", default=True):
return []

name = _prompt("Integration name", "my_repos")
schedule = _prompt("Check frequency", "10m")

pr_enabled = _prompt_yn("Monitor pull requests?", default=True)
issues_enabled = _prompt_yn("Monitor issues?", default=True)

platforms: dict[str, Any] = {}
if pr_enabled:
platforms["pull_requests"] = {
"classifications": {
"complexity": (
"how complex is this PR to review? 0 = trivial, 1 = major architectural change"
),
"risk": "how risky is this change? 0 = no risk, 1 = high risk of breaking things",
}
}
if issues_enabled:
platforms["issues"] = {
"classifications": {
"urgency": "how urgently does this issue need attention?",
"actionable": {
"prompt": "can you take a concrete next step on this issue?",
"type": "boolean",
},
}
}

if not platforms:
return []
def _load_setup_hook(hook_path: str, module_name: str) -> Any:
"""Load a setup hook function from a dotted path relative to a module."""
parts = hook_path[1:].split(".") if hook_path.startswith(".") else hook_path.split(".")
func_name = parts.pop()
sub_module = ".".join(parts)
full_module_path = f"{module_name}.{sub_module}" if sub_module else module_name
mod = importlib.import_module(full_module_path)
return getattr(mod, func_name)

integration: dict[str, Any] = {
"type": "github",
"name": name,
"schedule": {"every": schedule},
"llm": "default",
"platforms": platforms,
}
return [integration]

def _run_integration_setup_hooks() -> tuple[list[dict[str, Any]], dict[str, str]]:
"""Discover integrations with setup hooks and run them."""
from app.loader import discover_integrations

builtin_dir = PROJECT_ROOT / "app" / "integrations"
manifests = discover_integrations(builtin_dir)

all_integrations: list[dict[str, Any]] = []
all_secrets: dict[str, str] = {}
prompts = _SetupPrompts()

for domain, manifest in manifests.items():
if not manifest.setup_hook:
continue

module_name = manifest.entry_point_module or f"app.integrations.{domain}"
try:
hook = _load_setup_hook(manifest.setup_hook, module_name)
except (ImportError, AttributeError):
_warn(f"Could not load setup hook for {manifest.name} -- skipping")
continue

integrations, secrets = hook(prompts)
all_integrations.extend(integrations)
all_secrets.update(secrets)

return all_integrations, all_secrets


def setup_directories() -> dict[str, str]:
Expand Down Expand Up @@ -446,12 +432,12 @@ def run_setup(reconfigure: bool = False) -> int:
# Run each setup section
llm_config, llm_secrets = setup_llm()
email_integrations, email_secrets = setup_email()
github_integrations = setup_github()
hook_integrations, hook_secrets = _run_integration_setup_hooks()
directories = setup_directories()

# Merge
all_integrations = email_integrations + github_integrations
all_secrets = {**llm_secrets, **email_secrets}
all_integrations = email_integrations + hook_integrations
all_secrets = {**llm_secrets, **email_secrets, **hook_secrets}

# Generate files
config_content = _build_config_yaml(llm_config, all_integrations, directories)
Expand Down
18 changes: 18 additions & 0 deletions example.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ integrations:
- type: note
path: research/privacy_policy_updates/

# GitHub integration: track PRs and issues via GitHub App credentials
- type: github
name: personal
github_user: your-github-username
app_id: !secret github_app_id
installation_id: !secret github_installation_id
private_key: !secret github_private_key
schedule:
every: 12h
llm: default
# orgs: [myorg] # Restrict to specific orgs
# repos: [myorg/myrepo] # Restrict to specific repos (org/repo format)
platforms:
pull_requests:
include_mentions: true
issues:
include_mentions: true

# Gemini web research service used for privacy policy updates
- type: gemini
name: default
Expand Down
6 changes: 6 additions & 0 deletions example.secrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@
openai_api_key: sk-your-openai-api-key
personal_email_password: your-personal-app-password
work_email_password: your-work-app-password
github_app_id: "123456"
github_installation_id: "78901234"
github_private_key: |
-----BEGIN RSA PRIVATE KEY-----
... contents of your GitHub App .pem file ...
-----END RSA PRIVATE KEY-----
8 changes: 4 additions & 4 deletions packages/assistant-github/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# assistant-github

The GitHub integration. Handles pull requests and issues via the `gh` CLI.
The GitHub integration. Handles pull requests and issues via the GitHub REST API using GitHub App credentials.

Discovered at startup via Python entry points. Can be shadowed by a local override during development.

Expand All @@ -9,7 +9,7 @@ Discovered at startup via Python entry points. Can be shadowed by a local overri
```
src/assistant_github/
__init__.py
client.py # GitHub API client (wraps gh CLI)
client.py # GitHub API client (httpx + GitHub App auth)
entity_store.py # GitHubEntityStore base class for PR and issue stores
manifest.yaml
platforms/
Expand Down Expand Up @@ -42,13 +42,13 @@ src/assistant_github/

## Key patterns

**`gh` CLI as API client**: `client.py` shells out to `subprocess.run(["gh", "api", ...])`. The `gh` CLI handles auth (OAuth device flow, SSH keys, token storage), rate limiting, and pagination. Hard dependency on `gh` being installed and authenticated.
**GitHub App authentication**: `client.py` uses `httpx` to call the GitHub REST API directly. Authentication uses GitHub App credentials: the client generates a JWT (RS256 via PyJWT) from the app's private key, exchanges it for an installation access token, and uses that token for all API requests. The `github_user` config field replaces the `@me` shorthand that the old `gh` CLI approach relied on. Retry with exponential backoff (3 attempts, 1s/2s/4s) is built into the `_request` method.

**GitHubEntityStore**: Base class in `entity_store.py` shared by `PullRequestStore` and `IssueStore`. Provides `find`, `find_anywhere`, `active_keys`, `update`, `move_to_synced`, `restore_to_active` -- all keyed by `(org, repo, number)`. Each subclass overrides only `save()` with entity-specific field mappings.

**Filename convention**: `{org}__{repo}__{number}.md`. Double underscore because org and repo names can contain hyphens.

**Services**: The `create_issue` service is declared in `manifest.yaml` with a `chat` block, which means it's automatically registered as a chat-proposable action at startup. The LLM can propose creating an issue; the user sees a confirmation card and clicks "Post issue" or "Cancel." Approval enqueues a `service.github.create_issue` task through the normal queue. The handler in `services/create_issue.py` calls `GitHubClient.create_issue()`, which hits the GitHub API via `gh api repos/{org}/{repo}/issues --method POST`. Currently uses whatever auth `gh` has configured. Future work: switch to a dedicated GitHub App token so issues appear as the bot, not the logged-in user.
**Services**: The `create_issue` service is declared in `manifest.yaml` with a `chat` block, which means it's automatically registered as a chat-proposable action at startup. The LLM can propose creating an issue; the user sees a confirmation card and clicks "Post issue" or "Cancel." Approval enqueues a `service.github.create_issue` task through the normal queue. The handler in `services/create_issue.py` calls `GitHubClient.create_issue()`, which POSTs to the GitHub REST API using the App's installation token. Actions taken by the assistant appear under the GitHub App's identity, not the user's.

## Tests

Expand Down
Loading