Add Gitea commit lookup tools
This commit is contained in:
@@ -112,7 +112,7 @@ chat id.
|
|||||||
## Gitea integration
|
## Gitea integration
|
||||||
|
|
||||||
Set these optional values in `.env` to let Codex manage Gitea repositories,
|
Set these optional values in `.env` to let Codex manage Gitea repositories,
|
||||||
issues, pull requests, and HTTPS git authentication from Telegram:
|
commits, issues, pull requests, and HTTPS git authentication from Telegram:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
GITEA_BASE_URL=https://gitea.example.com
|
GITEA_BASE_URL=https://gitea.example.com
|
||||||
@@ -124,8 +124,8 @@ GITEA_DEFAULT_REPO=your-default-repo
|
|||||||
GITEA_GIT_AUTH_ENABLED=true
|
GITEA_GIT_AUTH_ENABLED=true
|
||||||
```
|
```
|
||||||
|
|
||||||
Codex receives Gitea MCP tools for repository, issue, branch, and pull-request
|
Codex receives Gitea MCP tools for repository, commit, issue, branch, and
|
||||||
metadata. The container also configures `GIT_ASKPASS` so HTTPS `git clone`,
|
pull-request metadata. The container also configures `GIT_ASKPASS` so HTTPS `git clone`,
|
||||||
`fetch`, and `push` can use the token without embedding it in command lines.
|
`fetch`, and `push` can use the token without embedding it in command lines.
|
||||||
|
|
||||||
Example Telegram workflow:
|
Example Telegram workflow:
|
||||||
|
|||||||
@@ -1331,7 +1331,7 @@ class TelegramCodexBot:
|
|||||||
f"{runtime_settings}\n"
|
f"{runtime_settings}\n"
|
||||||
"Configured Gitea integration:\n"
|
"Configured Gitea integration:\n"
|
||||||
f"{gitea_context}\n"
|
f"{gitea_context}\n"
|
||||||
"If the user asks for repository, issue, branch, or pull-request metadata in Gitea, "
|
"If the user asks for repository, commit, issue, branch, or pull-request metadata in Gitea, "
|
||||||
"use the gitea_* MCP tools. Never reveal GITEA_TOKEN or print commands that embed it. "
|
"use the gitea_* MCP tools. Never reveal GITEA_TOKEN or print commands that embed it. "
|
||||||
"For HTTPS git clone/fetch/push, the container may provide GIT_ASKPASS from the "
|
"For HTTPS git clone/fetch/push, the container may provide GIT_ASKPASS from the "
|
||||||
"configured Gitea environment.\n"
|
"configured Gitea environment.\n"
|
||||||
@@ -1788,7 +1788,11 @@ def _scheduled_prompt(schedule: dict) -> str:
|
|||||||
return (
|
return (
|
||||||
"This is a scheduled Telegram bridge task. Run the task now and produce a concise "
|
"This is a scheduled Telegram bridge task. Run the task now and produce a concise "
|
||||||
"Telegram-ready response. Do not create another schedule unless the task explicitly "
|
"Telegram-ready response. Do not create another schedule unless the task explicitly "
|
||||||
"asks you to change scheduling.\n\n"
|
"asks you to change scheduling. If repository history is relevant, prefer the Gitea "
|
||||||
|
"commit tools. Do not describe a missing tool or unavailable detail as a permission "
|
||||||
|
"problem unless an API error explicitly says permission was denied. Only include a "
|
||||||
|
"commit summary when the task asks for repository history or a repository can be "
|
||||||
|
"identified from the task/context.\n\n"
|
||||||
f"Schedule: {format_schedule(schedule)}\n\n"
|
f"Schedule: {format_schedule(schedule)}\n\n"
|
||||||
f"Task:\n{schedule.get('prompt') or ''}"
|
f"Task:\n{schedule.get('prompt') or ''}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class BridgeMcpServer:
|
|||||||
"notifications, manage bridge schedules, or persist personal assistant "
|
"notifications, manage bridge schedules, or persist personal assistant "
|
||||||
"records such as work history, todos, purchase candidates, birthdays, "
|
"records such as work history, todos, purchase candidates, birthdays, "
|
||||||
"contacts, and notes. Use the Gitea tools when the user asks to inspect "
|
"contacts, and notes. Use the Gitea tools when the user asks to inspect "
|
||||||
"or manage repositories, issues, pull requests, and code-review workflow "
|
"or manage repositories, commits, issues, pull requests, and code-review workflow "
|
||||||
"metadata in the user's configured Gitea instance. The Telegram chat_id and "
|
"metadata in the user's configured Gitea instance. The Telegram chat_id and "
|
||||||
"current thread_id are provided in the bridge context inside each user turn. "
|
"current thread_id are provided in the bridge context inside each user turn. "
|
||||||
"Use that chat_id as the default notification target and do not ask for a "
|
"Use that chat_id as the default notification target and do not ask for a "
|
||||||
@@ -125,6 +125,10 @@ class BridgeMcpServer:
|
|||||||
return self.gitea_create_pull_request(arguments)
|
return self.gitea_create_pull_request(arguments)
|
||||||
if name == "gitea_list_branches":
|
if name == "gitea_list_branches":
|
||||||
return self.gitea_list_branches(arguments)
|
return self.gitea_list_branches(arguments)
|
||||||
|
if name == "gitea_list_commits":
|
||||||
|
return self.gitea_list_commits(arguments)
|
||||||
|
if name == "gitea_get_commit":
|
||||||
|
return self.gitea_get_commit(arguments)
|
||||||
raise ValueError(f"Unknown tool: {name}")
|
raise ValueError(f"Unknown tool: {name}")
|
||||||
|
|
||||||
def create_schedule(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
def create_schedule(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -396,6 +400,35 @@ class BridgeMcpServer:
|
|||||||
"structuredContent": {"branches": branches},
|
"structuredContent": {"branches": branches},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def gitea_list_commits(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
client = GiteaClient.from_env()
|
||||||
|
owner, repo = _repo_arguments(arguments, client)
|
||||||
|
commits = client.list_commits(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
sha=_optional_string(arguments.get("sha") or arguments.get("branch")),
|
||||||
|
path=_optional_string(arguments.get("path")),
|
||||||
|
limit=int(arguments.get("limit") or 20),
|
||||||
|
page=int(arguments.get("page") or 1),
|
||||||
|
)
|
||||||
|
if not commits:
|
||||||
|
text = "No Gitea commits found."
|
||||||
|
else:
|
||||||
|
text = "\n".join(_format_gitea_commit(commit) for commit in commits)
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": text}],
|
||||||
|
"structuredContent": {"commits": commits},
|
||||||
|
}
|
||||||
|
|
||||||
|
def gitea_get_commit(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
client = GiteaClient.from_env()
|
||||||
|
owner, repo = _repo_arguments(arguments, client)
|
||||||
|
commit = client.get_commit(owner, repo, sha=str(arguments["sha"]))
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": _format_gitea_commit(commit, include_files=True)}],
|
||||||
|
"structuredContent": {"commit": commit},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _tools() -> list[dict[str, Any]]:
|
def _tools() -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
@@ -737,6 +770,44 @@ def _gitea_tools() -> list[dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
"annotations": {"title": "List Gitea branches", **readonly_annotations},
|
"annotations": {"title": "List Gitea branches", **readonly_annotations},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "gitea_list_commits",
|
||||||
|
"description": "List recent commits for a Gitea repository, optionally scoped to a branch, SHA, or path.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
**repo_properties,
|
||||||
|
"sha": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Branch name, tag, or commit SHA to list from. Defaults to the repository default branch.",
|
||||||
|
},
|
||||||
|
"branch": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Alias for sha when the user names a branch.",
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional repository path to filter commits by.",
|
||||||
|
},
|
||||||
|
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
||||||
|
"page": {"type": "integer", "minimum": 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"annotations": {"title": "List Gitea commits", **readonly_annotations},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gitea_get_commit",
|
||||||
|
"description": "Get one Gitea commit by SHA, including message, author, stats, and changed files when available.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
**repo_properties,
|
||||||
|
"sha": {"type": "string", "description": "Commit SHA to inspect."},
|
||||||
|
},
|
||||||
|
"required": ["sha"],
|
||||||
|
},
|
||||||
|
"annotations": {"title": "Get Gitea commit", **readonly_annotations},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -754,6 +825,13 @@ def _repo_arguments(arguments: dict[str, Any], client: GiteaClient) -> tuple[str
|
|||||||
return owner, repo
|
return owner, repo
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
def _format_gitea_repo(repo: dict[str, Any]) -> str:
|
def _format_gitea_repo(repo: dict[str, Any]) -> str:
|
||||||
full_name = repo.get("full_name") or repo.get("name") or "(unknown)"
|
full_name = repo.get("full_name") or repo.get("name") or "(unknown)"
|
||||||
default_branch = repo.get("default_branch") or repo.get("default_branch_name") or ""
|
default_branch = repo.get("default_branch") or repo.get("default_branch_name") or ""
|
||||||
@@ -787,6 +865,62 @@ def _format_gitea_pull_request(pull: dict[str, Any]) -> str:
|
|||||||
return f"#{index} [{state}] {title}{suffix}"
|
return f"#{index} [{state}] {title}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_gitea_commit(commit: dict[str, Any], include_files: bool = False) -> str:
|
||||||
|
sha = str(commit.get("sha") or "")[:10] or "(unknown)"
|
||||||
|
payload = commit.get("commit") if isinstance(commit.get("commit"), dict) else {}
|
||||||
|
message = str(payload.get("message") or commit.get("message") or "(no message)").strip()
|
||||||
|
subject = message.splitlines()[0] if message else "(no message)"
|
||||||
|
author = payload.get("author") if isinstance(payload.get("author"), dict) else {}
|
||||||
|
author_name = author.get("name") or commit.get("author_name") or ""
|
||||||
|
authored_at = commit.get("created") or author.get("date") or ""
|
||||||
|
html_url = commit.get("html_url") or ""
|
||||||
|
stats = commit.get("stats") if isinstance(commit.get("stats"), dict) else {}
|
||||||
|
|
||||||
|
bits = [sha]
|
||||||
|
if authored_at:
|
||||||
|
bits.append(str(authored_at))
|
||||||
|
if author_name:
|
||||||
|
bits.append(f"by {author_name}")
|
||||||
|
bits.append(subject)
|
||||||
|
if stats:
|
||||||
|
additions = stats.get("additions")
|
||||||
|
deletions = stats.get("deletions")
|
||||||
|
total = stats.get("total")
|
||||||
|
stat_bits = []
|
||||||
|
if total is not None:
|
||||||
|
stat_bits.append(f"{total} changed")
|
||||||
|
if additions is not None:
|
||||||
|
stat_bits.append(f"+{additions}")
|
||||||
|
if deletions is not None:
|
||||||
|
stat_bits.append(f"-{deletions}")
|
||||||
|
if stat_bits:
|
||||||
|
bits.append(", ".join(stat_bits))
|
||||||
|
if html_url:
|
||||||
|
bits.append(str(html_url))
|
||||||
|
|
||||||
|
text = " - ".join(bits)
|
||||||
|
if not include_files:
|
||||||
|
return text
|
||||||
|
|
||||||
|
files = commit.get("files")
|
||||||
|
if not isinstance(files, list) or not files:
|
||||||
|
return text
|
||||||
|
|
||||||
|
file_lines = []
|
||||||
|
for changed_file in files[:20]:
|
||||||
|
if not isinstance(changed_file, dict):
|
||||||
|
continue
|
||||||
|
filename = changed_file.get("filename") or changed_file.get("name")
|
||||||
|
status = changed_file.get("status")
|
||||||
|
if filename:
|
||||||
|
file_lines.append(f"- {filename}" + (f" ({status})" if status else ""))
|
||||||
|
if not file_lines:
|
||||||
|
return text
|
||||||
|
if len(files) > len(file_lines):
|
||||||
|
file_lines.append(f"- ... {len(files) - len(file_lines)} more files")
|
||||||
|
return text + "\nChanged files:\n" + "\n".join(file_lines)
|
||||||
|
|
||||||
|
|
||||||
def _result(request_id: Any, result: dict[str, Any]) -> dict[str, Any]:
|
def _result(request_id: Any, result: dict[str, Any]) -> dict[str, Any]:
|
||||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,34 @@ class GiteaClient:
|
|||||||
)
|
)
|
||||||
return result if isinstance(result, list) else []
|
return result if isinstance(result, list) else []
|
||||||
|
|
||||||
|
def list_commits(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
sha: str | None = None,
|
||||||
|
path: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
page: int = 1,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
query = {"limit": str(limit), "page": str(page)}
|
||||||
|
if sha:
|
||||||
|
query["sha"] = sha
|
||||||
|
if path:
|
||||||
|
query["path"] = path
|
||||||
|
result = self._request(
|
||||||
|
"GET",
|
||||||
|
f"/repos/{_quote(owner)}/{_quote(repo)}/commits",
|
||||||
|
query=query,
|
||||||
|
)
|
||||||
|
return result if isinstance(result, list) else []
|
||||||
|
|
||||||
|
def get_commit(self, owner: str, repo: str, sha: str) -> dict[str, Any]:
|
||||||
|
result = self._request(
|
||||||
|
"GET",
|
||||||
|
f"/repos/{_quote(owner)}/{_quote(repo)}/git/commits/{_quote(sha)}",
|
||||||
|
)
|
||||||
|
return result if isinstance(result, dict) else {}
|
||||||
|
|
||||||
def _request(
|
def _request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user