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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
- run: uv sync
- run: uv run pip-audit
- run: uv run pip-audit --ignore-vuln CVE-2026-4539 # pygments 2.19.2, no fix available yet

quality:
runs-on: ubuntu-latest
Expand Down
10 changes: 7 additions & 3 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,13 @@ def _json_schema_to_field(

python_type: Any
if json_type == "array":
item_type_str = prop_def.get("items", {}).get("type", "string")
item_type = _JSON_TYPE_MAP.get(item_type_str, str)
python_type = list[item_type] # type: ignore[valid-type]
items_def = prop_def.get("items", {})
if "oneOf" in items_def or "anyOf" in items_def:
python_type = list
else:
item_type_str = items_def.get("type", "string")
item_type = _JSON_TYPE_MAP.get(item_type_str, str)
python_type = list[item_type] # type: ignore[valid-type]
else:
python_type = _JSON_TYPE_MAP.get(json_type, str)

Expand Down
21 changes: 21 additions & 0 deletions app/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,30 @@ def _register_single_service(
"description": chat_config.description,
"input_schema": getattr(service_manifest, "input_schema", {}),
}

_inject_chat_context(ACTION_METADATA[key], module_name, chat_config, instances)

log.info("Registered chat action: %s", key)


def _inject_chat_context(
metadata: dict[str, object],
module_name: str,
chat_config: object,
instances: list[object],
) -> None:
"""Load and invoke the context_builder declared in a chat config, if any."""
builder_path = getattr(chat_config, "context_builder", None)
if not builder_path:
return
builder = _load_handler(module_name, builder_path)
if not builder:
return
chat_context = builder(instances) # type: ignore[arg-type]
if chat_context:
metadata["chat_context"] = chat_context


def register_all() -> None:
Comment on lines +123 to 147
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you including details specific to the GitHub integration here? These should be separate concerns by design.

"""Register handlers and entry tasks from all loaded integration modules."""
manifests = get_manifests()
Expand Down
1 change: 1 addition & 0 deletions app/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ def _load_manifest(
chat_config = ChatActionConfig(
description=raw_chat.get("description", svc_def.get("description", "")),
options=raw_chat.get("options"),
context_builder=raw_chat.get("context_builder"),
)
services[svc_name] = ServiceManifest(
name=svc_def.get("name", svc_name),
Expand Down
6 changes: 6 additions & 0 deletions app/templates/action_prompt.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,10 @@ Available actions:
- {{ param_name }}: {{ param_def.get('type', 'string') }}{{ ' (required)' if param_name in required else '' }}{{ ' — ' ~ param_def.get('description') if param_def.get('description') else '' }}
{% endfor %}
{% endif %}
{% if meta.get('chat_context') %}
Context:
{% for ctx in meta.get('chat_context') %}
- {{ ctx.label }}: {{ ctx.description }}
{% endfor %}
{% endif %}
{% endfor %}
6 changes: 6 additions & 0 deletions example.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ integrations:
llm: default
# orgs: [myorg] # Restrict to specific orgs
# repos: [myorg/myrepo] # Restrict to specific repos (org/repo format)
# repos: # Or use object form to add context for the LLM:
# - repo: myorg/backend
# context: "Python API server. Issues should include endpoint and error details."
# - repo: myorg/frontend
# context: "React SPA. Include browser and component info in issues."
# - myorg/docs # Plain strings still work alongside object entries
platforms:
pull_requests:
include_mentions: true
Expand Down
6 changes: 5 additions & 1 deletion packages/assistant-github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ integrations:
llm: default
# orgs: [myorg] # Restrict to specific orgs
# repos: [myorg/myrepo] # Restrict to specific repos (org/repo format)
# repos: # Or use object form to add context for the LLM:
# - repo: myorg/backend
# context: "Python API server. Issues should include endpoint and error details."
# - myorg/docs # Plain strings still work alongside object entries
platforms:
pull_requests:
# include_mentions: true # Also track PRs that mention you (off by default)
Expand All @@ -37,7 +41,7 @@ integrations:

`github_user` is the GitHub username whose activity should be monitored (replaces the `@me` shorthand used internally).

Both `orgs` and `repos` are optional. Leave them out and the integration discovers repos from your GitHub activity. Use `repos` for a specific list in `org/repo` format, or `orgs` to track everything in an organization.
Both `orgs` and `repos` are optional. Leave them out and the integration discovers repos from your GitHub activity. Use `repos` for a specific list in `org/repo` format, or `orgs` to track everything in an organization. Repo entries can be plain strings or objects with `repo` and `context` fields — the `context` is included in the chat system prompt so the LLM knows which repo to target and what details to include when proposing issues.

Credentials go in `secrets.yaml`:

Expand Down
16 changes: 14 additions & 2 deletions packages/assistant-github/src/assistant_github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@
GITHUB_API_BASE = "https://api.github.com"


def normalize_repo_entry(entry: str | dict[str, str]) -> dict[str, str]:
"""Normalize a repo config entry to {"repo": ..., "context": ...}.

Accepts either a plain string ("org/repo") or a dict with required
"repo" key and optional "context" key.
"""
if isinstance(entry, str):
return {"repo": entry, "context": ""}
return {"repo": entry["repo"], "context": entry.get("context", "")}


def _parse_search_item(item: dict[str, Any]) -> dict[str, Any]:
"""Parse an item from the GitHub search/issues endpoint into a standard dict."""
repo_url = item.get("repository_url", "")
Expand Down Expand Up @@ -252,8 +263,9 @@ def _scope_qualifiers(self, integration: Any) -> list[str]:
qualifiers = []
for org in integration.orgs or []:
qualifiers.append(f"org:{org}")
for repo in integration.repos or []:
qualifiers.append(f"repo:{repo}")
for entry in integration.repos or []:
normalized = normalize_repo_entry(entry)
qualifiers.append(f"repo:{normalized['repo']}")
return qualifiers or [""]

def _request(
Expand Down
12 changes: 11 additions & 1 deletion packages/assistant-github/src/assistant_github/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,16 @@ config_schema:
repos:
type: array
items:
type: string
oneOf:
- type: string
- type: object
properties:
repo:
type: string
context:
type: string
required:
- repo
required:
- github_user
- app_id
Expand Down Expand Up @@ -72,6 +81,7 @@ services:
required: [repo, title]
chat:
description: "Create a GitHub issue"
context_builder: ".services.create_issue.build_chat_context"
options:
- id: approve
label: "Post issue"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,31 @@
log = logging.getLogger(__name__)


def _get_field(entry: Any, key: str) -> str:
"""Read *key* from a dict or object, defaulting to empty string."""
if isinstance(entry, dict):
return str(entry.get(key, ""))
return str(getattr(entry, key, ""))


def build_chat_context(instances: list[Any]) -> list[dict[str, str]]:
"""Build chat context entries from GitHub integration config instances.

Extracts repos with non-empty context descriptions and returns them
as generic {label, description} pairs for the chat system prompt.
"""
entries: list[dict[str, str]] = []
for instance in instances:
for repo_entry in getattr(instance, "repos", None) or []:
if isinstance(repo_entry, str):
continue
repo = _get_field(repo_entry, "repo")
context = _get_field(repo_entry, "context")
if repo and context:
entries.append({"label": repo, "description": context})
return entries


def handle(task: TaskRecord) -> dict[str, Any]:
"""Handle a service.github.create_issue queue task.

Expand Down
40 changes: 39 additions & 1 deletion packages/assistant-github/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import jwt
import pytest

from assistant_github.client import GitHubClient, _parse_search_item
from assistant_github.client import GitHubClient, _parse_search_item, normalize_repo_entry


def _make_client(**overrides):
Expand Down Expand Up @@ -347,6 +347,44 @@ def test_empty_lists_returns_empty_string(self):
qualifiers = client._scope_qualifiers(integration)
assert qualifiers == [""]

def test_dict_form_repos(self):
client = _make_client()
integration = MagicMock()
integration.orgs = None
integration.repos = [{"repo": "myorg/myrepo", "context": "Python API"}]
qualifiers = client._scope_qualifiers(integration)
assert qualifiers == ["repo:myorg/myrepo"]

def test_mixed_string_and_dict_repos(self):
client = _make_client()
integration = MagicMock()
integration.orgs = None
integration.repos = [
"myorg/frontend",
{"repo": "myorg/backend", "context": "API server"},
]
qualifiers = client._scope_qualifiers(integration)
assert qualifiers == ["repo:myorg/frontend", "repo:myorg/backend"]


# ---------------------------------------------------------------------------
# normalize_repo_entry
# ---------------------------------------------------------------------------


class TestNormalizeRepoEntry:
def test_string_entry(self):
result = normalize_repo_entry("myorg/myrepo")
assert result == {"repo": "myorg/myrepo", "context": ""}

def test_dict_entry_with_context(self):
result = normalize_repo_entry({"repo": "myorg/myrepo", "context": "Python API"})
assert result == {"repo": "myorg/myrepo", "context": "Python API"}

def test_dict_entry_without_context(self):
result = normalize_repo_entry({"repo": "myorg/myrepo"})
assert result == {"repo": "myorg/myrepo", "context": ""}


# ---------------------------------------------------------------------------
# GitHubClient._search_entities — deduplication
Expand Down
1 change: 1 addition & 0 deletions packages/assistant-sdk/src/assistant_sdk/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class ChatActionConfig:

description: str
options: list[dict[str, str]] | None = None
context_builder: str | None = None


@dataclass
Expand Down
1 change: 1 addition & 0 deletions packages/integration-guide/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ JSON Schema syntax. The system maps types like this:
| `integer` | `int` |
| `boolean` | `bool` |
| `array` (with `items: {type: string}`) | `list[str]` |
| `array` (with `items: {oneOf: [...]}`) | `list` (unparameterized) |
| `object` | `dict` |

Fields with a `default` become optional. Fields in `required` are mandatory.
Expand Down
43 changes: 43 additions & 0 deletions tests/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,49 @@ def test_includes_action_name_and_description(self):
assert "title" in result
assert "Issue title" in result

def test_includes_chat_context_when_present(self):
from app.chat import _build_action_prompt, ACTION_METADATA

ACTION_METADATA["service.github.create_issue"] = {
"description": "Create a GitHub issue",
"input_schema": {
"properties": {
"repo": {"type": "string"},
"title": {"type": "string"},
},
"required": ["repo", "title"],
},
"chat_context": [
{
"label": "myorg/backend",
"description": "Python API server. Include endpoint and error details.",
},
{
"label": "myorg/frontend",
"description": "React SPA. Include browser and component info.",
},
],
}
result = _build_action_prompt()
assert "Context:" in result
assert "myorg/backend" in result
assert "Python API server" in result
assert "myorg/frontend" in result
assert "React SPA" in result

def test_no_chat_context_section_when_absent(self):
from app.chat import _build_action_prompt, ACTION_METADATA

ACTION_METADATA["service.github.create_issue"] = {
"description": "Create a GitHub issue",
"input_schema": {
"properties": {"repo": {"type": "string"}},
"required": ["repo"],
},
}
result = _build_action_prompt()
assert "Context:" not in result

def test_system_prompt_includes_actions(self, svc):
from app.chat import ACTION_METADATA

Expand Down
Loading
Loading