briar-cli 1.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.
- briar/__init__.py +8 -0
- briar/__main__.py +11 -0
- briar/_registry.py +36 -0
- briar/agent/__init__.py +16 -0
- briar/agent/_llm.py +90 -0
- briar/agent/_llms/__init__.py +40 -0
- briar/agent/_llms/anthropic_llm.py +185 -0
- briar/agent/_llms/bedrock.py +154 -0
- briar/agent/_llms/gemini.py +152 -0
- briar/agent/_llms/openai_llm.py +129 -0
- briar/agent/runner.py +364 -0
- briar/agent/tools.py +355 -0
- briar/auth/__init__.py +42 -0
- briar/auth/_acquirer.py +121 -0
- briar/auth/_acquirers/__init__.py +62 -0
- briar/auth/_acquirers/aws_sso.py +185 -0
- briar/auth/_acquirers/aws_static.py +54 -0
- briar/auth/_acquirers/bitbucket.py +60 -0
- briar/auth/_acquirers/github_device.py +129 -0
- briar/auth/_acquirers/github_pat.py +44 -0
- briar/auth/_acquirers/infisical.py +80 -0
- briar/auth/_acquirers/jira_session.py +102 -0
- briar/auth/_acquirers/jira_token.py +61 -0
- briar/auth/_acquirers/linear.py +46 -0
- briar/auth/_prompt.py +155 -0
- briar/cli.py +149 -0
- briar/commands/__init__.py +48 -0
- briar/commands/agent.py +692 -0
- briar/commands/auth.py +281 -0
- briar/commands/base.py +46 -0
- briar/commands/context.py +143 -0
- briar/commands/dashboard.py +61 -0
- briar/commands/extract.py +79 -0
- briar/commands/iac.py +44 -0
- briar/commands/runbook.py +110 -0
- briar/commands/secrets.py +165 -0
- briar/commands/version.py +17 -0
- briar/credentials/__init__.py +48 -0
- briar/credentials/_bootstrap.py +93 -0
- briar/credentials/_bootstraps/__init__.py +80 -0
- briar/credentials/_bootstraps/infisical.py +115 -0
- briar/credentials/_store.py +63 -0
- briar/credentials/aws_secrets.py +102 -0
- briar/credentials/envfile.py +171 -0
- briar/credentials/infisical.py +183 -0
- briar/credentials/ssm.py +83 -0
- briar/credentials/vault.py +109 -0
- briar/dashboard/__init__.py +13 -0
- briar/dashboard/collectors.py +1354 -0
- briar/dashboard/server.py +154 -0
- briar/dashboard/templates/index.html +678 -0
- briar/decorators.py +48 -0
- briar/env_vars.py +92 -0
- briar/error_policy.py +273 -0
- briar/errors.py +48 -0
- briar/extract/__init__.py +52 -0
- briar/extract/_cloud.py +157 -0
- briar/extract/_clouds/__init__.py +39 -0
- briar/extract/_clouds/aws.py +190 -0
- briar/extract/_clouds/azure.py +152 -0
- briar/extract/_clouds/gcp.py +135 -0
- briar/extract/_gh.py +159 -0
- briar/extract/_provider.py +218 -0
- briar/extract/_providers/__init__.py +60 -0
- briar/extract/_providers/bitbucket.py +276 -0
- briar/extract/_providers/github.py +277 -0
- briar/extract/_tracker.py +124 -0
- briar/extract/_trackers/__init__.py +44 -0
- briar/extract/_trackers/_jira_auth.py +258 -0
- briar/extract/_trackers/bitbucket.py +131 -0
- briar/extract/_trackers/github_issues.py +139 -0
- briar/extract/_trackers/jira.py +191 -0
- briar/extract/_trackers/linear.py +172 -0
- briar/extract/_user_filter.py +150 -0
- briar/extract/active_tickets.py +71 -0
- briar/extract/active_work.py +87 -0
- briar/extract/aws_infra.py +79 -0
- briar/extract/aws_services/__init__.py +31 -0
- briar/extract/aws_services/base.py +25 -0
- briar/extract/aws_services/ecs.py +43 -0
- briar/extract/aws_services/lambda_.py +38 -0
- briar/extract/aws_services/logs.py +39 -0
- briar/extract/aws_services/rds.py +35 -0
- briar/extract/aws_services/sqs.py +25 -0
- briar/extract/base.py +290 -0
- briar/extract/code_hotspots.py +134 -0
- briar/extract/codebase_conventions.py +72 -0
- briar/extract/composer.py +85 -0
- briar/extract/github_deployments.py +106 -0
- briar/extract/language_detectors/__init__.py +24 -0
- briar/extract/language_detectors/base.py +29 -0
- briar/extract/language_detectors/go.py +26 -0
- briar/extract/language_detectors/node.py +42 -0
- briar/extract/language_detectors/python.py +41 -0
- briar/extract/pr_archaeology.py +131 -0
- briar/extract/pr_review_context.py +123 -0
- briar/extract/reviewer_profile.py +141 -0
- briar/extract/ticket_archaeology.py +115 -0
- briar/extract/ticket_context.py +95 -0
- briar/formatting/__init__.py +67 -0
- briar/formatting/base.py +25 -0
- briar/formatting/csv.py +34 -0
- briar/formatting/json.py +19 -0
- briar/formatting/quiet.py +29 -0
- briar/formatting/table.py +97 -0
- briar/formatting/yaml.py +35 -0
- briar/iac/__init__.py +18 -0
- briar/iac/config_file.py +114 -0
- briar/iac/models.py +232 -0
- briar/iac/reference_map.py +33 -0
- briar/iac/runbook/__init__.py +32 -0
- briar/iac/runbook/executor.py +365 -0
- briar/iac/runbook/models.py +156 -0
- briar/iac/runbook/scheduler.py +187 -0
- briar/iac/scaffold/__init__.py +25 -0
- briar/iac/scaffold/_composer.py +308 -0
- briar/iac/scaffold/_knowledge.py +119 -0
- briar/iac/scaffold/archetypes/__init__.py +26 -0
- briar/iac/scaffold/archetypes/base.py +85 -0
- briar/iac/scaffold/archetypes/engineer.py +64 -0
- briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
- briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
- briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
- briar/iac/scaffold/archetypes/triager.py +50 -0
- briar/iac/scaffold/base.py +19 -0
- briar/iac/scaffold/implementation.py +59 -0
- briar/iac/scaffold/pr_fixes.py +52 -0
- briar/iac/scaffold/rules/__init__.py +64 -0
- briar/iac/scaffold/rules/base.py +121 -0
- briar/iac/scaffold/rules/commit_as_human.md +22 -0
- briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
- briar/iac/scaffold/rules/no_force_push.md +19 -0
- briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
- briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
- briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
- briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
- briar/iac/scaffold/shapes/__init__.py +38 -0
- briar/iac/scaffold/shapes/base.py +17 -0
- briar/iac/scaffold/shapes/one_shot.py +48 -0
- briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
- briar/iac/scaffold/shapes/triage.py +49 -0
- briar/iac/scaffold/sources/__init__.py +26 -0
- briar/iac/scaffold/sources/aws.py +69 -0
- briar/iac/scaffold/sources/base.py +61 -0
- briar/iac/scaffold/sources/bitbucket.py +164 -0
- briar/iac/scaffold/sources/github.py +155 -0
- briar/iac/scaffold/sources/jira.py +147 -0
- briar/iac/scaffold/triggers/__init__.py +23 -0
- briar/iac/scaffold/triggers/base.py +24 -0
- briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
- briar/iac/scaffold/triggers/github_webhook.py +52 -0
- briar/iac/scaffold/triggers/manual.py +16 -0
- briar/iac/scaffold/triggers/schedule_cron.py +37 -0
- briar/log_context.py +73 -0
- briar/logging.py +68 -0
- briar/messaging/__init__.py +66 -0
- briar/messaging/_writer.py +90 -0
- briar/messaging/bitbucket_pr_comment.py +93 -0
- briar/messaging/github_pr_comment.py +90 -0
- briar/messaging/jira_comment.py +63 -0
- briar/messaging/jira_transition.py +71 -0
- briar/messaging/slack_channel.py +73 -0
- briar/messaging/telegram_chat.py +68 -0
- briar/notify/__init__.py +44 -0
- briar/notify/_sink.py +27 -0
- briar/notify/email.py +59 -0
- briar/notify/pagerduty.py +67 -0
- briar/notify/slack.py +49 -0
- briar/notify/telegram.py +48 -0
- briar/pagination.py +37 -0
- briar/settings.py +3 -0
- briar/storage/__init__.py +73 -0
- briar/storage/base.py +137 -0
- briar/storage/file.py +111 -0
- briar/storage/postgres.py +353 -0
- briar_cli-1.1.1.dist-info/METADATA +1031 -0
- briar_cli-1.1.1.dist-info/RECORD +179 -0
- briar_cli-1.1.1.dist-info/WHEEL +4 -0
- briar_cli-1.1.1.dist-info/entry_points.txt +2 -0
briar/commands/agent.py
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
"""`briar agent` — autonomous agent runner.
|
|
2
|
+
|
|
3
|
+
Two ops today (`prfix`, `implement`); future ops register by adding a
|
|
4
|
+
subclass to `AGENT_OPS`. The dispatcher (`CommandAgent.run`) does a
|
|
5
|
+
registry lookup, NOT an if-chain — same Strategy + Registry shape as
|
|
6
|
+
every other plugin family in the codebase.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import logging
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, ClassVar, Dict, Tuple
|
|
19
|
+
|
|
20
|
+
from briar._registry import build_registry
|
|
21
|
+
from briar.agent.runner import AgentRunner
|
|
22
|
+
from briar.commands.base import Command
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─── AgentOp Strategy + Registry ────────────────────────────────────────────
|
|
29
|
+
#
|
|
30
|
+
# Each op owns its own `add_arguments` + `run`. CommandAgent.add_arguments
|
|
31
|
+
# iterates AGENT_OPS to attach subparsers; CommandAgent.run does a single
|
|
32
|
+
# registry lookup. Adding a new op = one subclass + one entry in AGENT_OPS.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AgentOp(ABC):
|
|
36
|
+
"""One agent subcommand. Concrete subclasses (`PrfixOp`,
|
|
37
|
+
`ImplementOp`, …) declare the per-op argparse flags and run logic."""
|
|
38
|
+
|
|
39
|
+
name: ClassVar[str] = ""
|
|
40
|
+
help: ClassVar[str] = ""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def run(self, agent_cmd: "CommandAgent", args: argparse.Namespace) -> int:
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PrfixOp(AgentOp):
|
|
52
|
+
name = "prfix"
|
|
53
|
+
help = "Address open review comments on a PR (pr-fixer archetype)."
|
|
54
|
+
|
|
55
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
56
|
+
parser.add_argument("--company", required=True, help="Company key — must match a runbook YAML")
|
|
57
|
+
parser.add_argument("--owner", required=True, help="GitHub owner of the target repo")
|
|
58
|
+
parser.add_argument("--repo", required=True, help="GitHub repo name")
|
|
59
|
+
parser.add_argument("--pr", type=int, required=True, help="PR number to address")
|
|
60
|
+
parser.add_argument("--branch", required=True, help="PR head branch name")
|
|
61
|
+
parser.add_argument("--store", default="postgres", choices=["file", "postgres"], help="KnowledgeStore backend")
|
|
62
|
+
parser.add_argument("--knowledge", default="./knowledge", help="File-store root (ignored for postgres)")
|
|
63
|
+
parser.add_argument("--model", default="", help="Override Anthropic model (defaults to AgentRunner.DEFAULT_MODEL)")
|
|
64
|
+
parser.add_argument("--max-iter", type=int, default=0, help="Iteration ceiling (defaults to AgentRunner.DEFAULT_MAX_ITERATIONS)")
|
|
65
|
+
parser.add_argument("--git-user-name", default="", help="git config user.name on the worktree. If empty: falls back to the runbook YAML's company.git_identity.name, then to `iklobato`.")
|
|
66
|
+
parser.add_argument("--git-user-email", default="", help="git config user.email on the worktree. If empty: falls back to the runbook YAML's company.git_identity.email, then to `iklobato@gmail.com`.")
|
|
67
|
+
parser.add_argument("--keep-worktree", action="store_true", help="Leave the worktree in /tmp after the run for inspection")
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--dry-run",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="Build + print the system prompt + user message + tool list, skip the LLM call. "
|
|
72
|
+
"Validates the JIT context wiring (pr-review-context) without spending tokens.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--runbook",
|
|
76
|
+
default="",
|
|
77
|
+
help="Optional runbook YAML to read this company's `messages:` block from. "
|
|
78
|
+
"When set, the agent gets a `send_message` tool bound to the configured channels "
|
|
79
|
+
"instead of having to shell out via `gh` / `curl`.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def run(self, agent_cmd: "CommandAgent", args: argparse.Namespace) -> int:
|
|
83
|
+
return agent_cmd._run_prfix(args)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ImplementOp(AgentOp):
|
|
87
|
+
name = "implement"
|
|
88
|
+
help = "Implement one ticket end-to-end (engineer archetype). Clones default branch, fetches ticket-context, runs the agent."
|
|
89
|
+
|
|
90
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
91
|
+
parser.add_argument("--company", required=True, help="Company key — must match a runbook YAML")
|
|
92
|
+
parser.add_argument("--owner", required=True, help="Repository owner (GitHub) or workspace (Bitbucket)")
|
|
93
|
+
parser.add_argument("--repo", required=True, help="Repository name / slug")
|
|
94
|
+
parser.add_argument("--ticket-project", required=True, help="Tracker project key (Jira: PROJ; Linear team: ENG; GH/BB Issues: owner/repo)")
|
|
95
|
+
parser.add_argument("--ticket-key", required=True, help="Ticket identifier (Jira: PROJ-123; GH/BB: #42; Linear: ENG-7)")
|
|
96
|
+
parser.add_argument("--tracker", default="jira", help="Tracker provider for the ticket (default: jira). One of: jira, github-issues, bitbucket-issues, linear.")
|
|
97
|
+
parser.add_argument("--provider", default="github", help="Repository provider (default: github). One of: github, bitbucket.")
|
|
98
|
+
parser.add_argument("--store", default="postgres", choices=["file", "postgres"], help="KnowledgeStore backend")
|
|
99
|
+
parser.add_argument("--knowledge", default="./knowledge", help="File-store root (ignored for postgres)")
|
|
100
|
+
parser.add_argument("--model", default="", help="Override Anthropic model (defaults to AgentRunner.DEFAULT_MODEL)")
|
|
101
|
+
parser.add_argument("--max-iter", type=int, default=0, help="Iteration ceiling (defaults to AgentRunner.DEFAULT_MAX_ITERATIONS)")
|
|
102
|
+
parser.add_argument("--git-user-name", default="", help="git config user.name on the worktree. If empty: falls back to the runbook YAML's company.git_identity.name, then to `iklobato`.")
|
|
103
|
+
parser.add_argument("--git-user-email", default="", help="git config user.email on the worktree. If empty: falls back to the runbook YAML's company.git_identity.email, then to `iklobato@gmail.com`.")
|
|
104
|
+
parser.add_argument("--keep-worktree", action="store_true", help="Leave the worktree in /tmp after the run for inspection")
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--dry-run",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help="Build + print the system prompt + user message + tool list, skip the LLM call. "
|
|
109
|
+
"Validates the JIT context wiring (ticket-context) without spending tokens.",
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--runbook",
|
|
113
|
+
default="",
|
|
114
|
+
help="Optional runbook YAML to read this company's `messages:` block from.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def run(self, agent_cmd: "CommandAgent", args: argparse.Namespace) -> int:
|
|
118
|
+
return agent_cmd._run_implement(args)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
AGENT_OPS: Dict[str, AgentOp] = build_registry(
|
|
122
|
+
(PrfixOp(), ImplementOp()),
|
|
123
|
+
kind="agent op",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ─── RepoCloner Strategy + Registry ─────────────────────────────────────────
|
|
128
|
+
#
|
|
129
|
+
# Per-provider git clone + per-provider PR-creation instructions. Adding a
|
|
130
|
+
# new repo provider (GitLab, Gitea, …) = one RepoCloner subclass + one
|
|
131
|
+
# entry in REPO_CLONERS. Zero edits to _run_implement or _clone_default
|
|
132
|
+
# call sites.
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class RepoCloner(ABC):
|
|
136
|
+
"""Vendor-specific git clone over HTTPS with a token embedded in
|
|
137
|
+
the URL (the standard CI/headless pattern when there's no git
|
|
138
|
+
credential helper). Also owns the PR-creation recipe the agent's
|
|
139
|
+
instruction string includes — different vendors have different
|
|
140
|
+
CLIs / APIs."""
|
|
141
|
+
|
|
142
|
+
kind: ClassVar[str] = ""
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
def resolve_token(self, *, company: str) -> str:
|
|
146
|
+
"""Return the credential string to embed in the clone URL,
|
|
147
|
+
or empty when not configured. Caller logs + bails on empty."""
|
|
148
|
+
|
|
149
|
+
@abstractmethod
|
|
150
|
+
def clone_url(self, owner: str, repo: str) -> str:
|
|
151
|
+
"""Canonical HTTPS clone URL (no auth embedded)."""
|
|
152
|
+
|
|
153
|
+
@abstractmethod
|
|
154
|
+
def authed_clone_url(self, owner: str, repo: str, token: str) -> str:
|
|
155
|
+
"""The URL the actual `git clone` call uses, with the token
|
|
156
|
+
embedded per the provider's auth convention."""
|
|
157
|
+
|
|
158
|
+
@abstractmethod
|
|
159
|
+
def pr_creation_recipe(self, *, owner: str, repo: str, branch: str, company: str) -> str:
|
|
160
|
+
"""Lines 6-7 of the agent's procedure instructions — how to
|
|
161
|
+
open a draft PR with this provider's tooling."""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class GithubRepoCloner(RepoCloner):
|
|
165
|
+
kind = "github"
|
|
166
|
+
|
|
167
|
+
def resolve_token(self, *, company: str) -> str:
|
|
168
|
+
import os
|
|
169
|
+
|
|
170
|
+
return os.environ.get("GITHUB_TOKEN", "").strip()
|
|
171
|
+
|
|
172
|
+
def clone_url(self, owner: str, repo: str) -> str:
|
|
173
|
+
return f"https://github.com/{owner}/{repo}.git"
|
|
174
|
+
|
|
175
|
+
def authed_clone_url(self, owner: str, repo: str, token: str) -> str:
|
|
176
|
+
# GitHub's HTTPS auth convention: `x-access-token` as the username.
|
|
177
|
+
return f"https://x-access-token:{token}@github.com/{owner}/{repo}.git"
|
|
178
|
+
|
|
179
|
+
def pr_creation_recipe(self, *, owner: str, repo: str, branch: str, company: str) -> str:
|
|
180
|
+
return (
|
|
181
|
+
" 6. Open a draft PR via `gh pr create --draft --title '<key>: <short>' "
|
|
182
|
+
"--body '<plan + test plan + risks>'`.\n"
|
|
183
|
+
" 7. End your output with the PR URL on its own line. No fictitious URLs "
|
|
184
|
+
"— if `gh pr create` fails, surface the error.\n"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class BitbucketRepoCloner(RepoCloner):
|
|
189
|
+
kind = "bitbucket"
|
|
190
|
+
|
|
191
|
+
def resolve_token(self, *, company: str) -> str:
|
|
192
|
+
if not company:
|
|
193
|
+
return ""
|
|
194
|
+
from briar.env_vars import CredEnv
|
|
195
|
+
|
|
196
|
+
return (CredEnv.BITBUCKET_APP_PASSWORD.read(company=company) or "").strip()
|
|
197
|
+
|
|
198
|
+
def clone_url(self, owner: str, repo: str) -> str:
|
|
199
|
+
return f"https://bitbucket.org/{owner}/{repo}.git"
|
|
200
|
+
|
|
201
|
+
def authed_clone_url(self, owner: str, repo: str, token: str) -> str:
|
|
202
|
+
# Bitbucket's workspace-token auth convention: `x-token-auth`.
|
|
203
|
+
return f"https://x-token-auth:{token}@bitbucket.org/{owner}/{repo}.git"
|
|
204
|
+
|
|
205
|
+
def pr_creation_recipe(self, *, owner: str, repo: str, branch: str, company: str) -> str:
|
|
206
|
+
env_token = f"BITBUCKET_{company.upper().replace('-', '_')}_APP_PASSWORD"
|
|
207
|
+
return (
|
|
208
|
+
" 6. Open a draft PR via the Bitbucket v2 API. The workspace access token is in env var "
|
|
209
|
+
f"`{env_token}`. Auth: `-u 'x-token-auth:${env_token}'`. Endpoint: "
|
|
210
|
+
f"`POST https://api.bitbucket.org/2.0/repositories/{owner}/{repo}/pullrequests`. "
|
|
211
|
+
f"Body JSON fields: `title`, `description`, `source.branch.name` (= `{branch}`), `draft: true`. "
|
|
212
|
+
"The response's `links.html.href` is the PR URL.\n"
|
|
213
|
+
" 7. End your output with the PR URL on its own line. No fictitious URLs — if the curl fails, "
|
|
214
|
+
"surface the error verbatim.\n"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
REPO_CLONERS: Dict[str, RepoCloner] = build_registry(
|
|
219
|
+
(GithubRepoCloner(), BitbucketRepoCloner()),
|
|
220
|
+
kind="repo cloner",
|
|
221
|
+
name_attr="kind",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _resolve_cloner(provider: str) -> RepoCloner:
|
|
226
|
+
cloner = REPO_CLONERS.get(provider)
|
|
227
|
+
if cloner is None:
|
|
228
|
+
known = ", ".join(sorted(REPO_CLONERS.keys()))
|
|
229
|
+
raise RuntimeError(f"unknown repo provider {provider!r}; known: {known}")
|
|
230
|
+
return cloner
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class CommandAgent(Command):
|
|
234
|
+
name = "agent"
|
|
235
|
+
help = "Run an autonomous agent flow against a target (prfix / conflict-resolve / ci-fix)."
|
|
236
|
+
|
|
237
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
238
|
+
sub = parser.add_subparsers(dest="agent_op", required=True, metavar="OP")
|
|
239
|
+
for op in AGENT_OPS.values():
|
|
240
|
+
op_parser = sub.add_parser(op.name, help=op.help)
|
|
241
|
+
op.add_arguments(op_parser)
|
|
242
|
+
|
|
243
|
+
def run(self, args: argparse.Namespace) -> int:
|
|
244
|
+
op = AGENT_OPS.get(args.agent_op)
|
|
245
|
+
if op is None:
|
|
246
|
+
known = ", ".join(sorted(AGENT_OPS.keys()))
|
|
247
|
+
log.error("unknown agent op: %s (known: %s)", args.agent_op, known)
|
|
248
|
+
return 2
|
|
249
|
+
return op.run(self, args)
|
|
250
|
+
|
|
251
|
+
def _run_prfix(self, args: argparse.Namespace) -> int:
|
|
252
|
+
from briar.storage import make_store
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
store = make_store(args.store, file_root=Path(args.knowledge))
|
|
256
|
+
except Exception: # noqa: BLE001
|
|
257
|
+
log.exception("agent-prfix: failed to open store=%s", args.store)
|
|
258
|
+
return 3
|
|
259
|
+
|
|
260
|
+
target = f"{args.owner}/{args.repo}"
|
|
261
|
+
clone_url = f"https://github.com/{target}.git"
|
|
262
|
+
|
|
263
|
+
worktree = Path(tempfile.mkdtemp(prefix="briar-agent-prfix-"))
|
|
264
|
+
log.info(
|
|
265
|
+
"agent-prfix: target=%s pr=%d branch=%s worktree=%s dry_run=%s",
|
|
266
|
+
target,
|
|
267
|
+
args.pr,
|
|
268
|
+
args.branch,
|
|
269
|
+
worktree,
|
|
270
|
+
args.dry_run,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Skip the clone in dry-run — the worktree path is only needed
|
|
274
|
+
# as a string in the system prompt (renders fine), the agent
|
|
275
|
+
# never executes against the filesystem.
|
|
276
|
+
if not args.dry_run:
|
|
277
|
+
if not self._clone_branch(clone_url, args.branch, worktree):
|
|
278
|
+
log.error("agent-prfix: clone failed; aborting")
|
|
279
|
+
self._cleanup_worktree(worktree, keep=args.keep_worktree)
|
|
280
|
+
return 4
|
|
281
|
+
|
|
282
|
+
git_name, git_email = self._resolve_git_identity(args)
|
|
283
|
+
log.info("agent-prfix: git identity user.name=%s user.email=%s", git_name, git_email)
|
|
284
|
+
if not self._set_git_identity(worktree, git_name, git_email):
|
|
285
|
+
log.error("agent-prfix: git identity setup failed; aborting")
|
|
286
|
+
self._cleanup_worktree(worktree, keep=args.keep_worktree)
|
|
287
|
+
return 5
|
|
288
|
+
|
|
289
|
+
# JIT-fetch the PR's review comments + failing-CI context. Spliced
|
|
290
|
+
# into the agent's system prompt below the archetype's persona.
|
|
291
|
+
# Failure here is non-fatal — the agent still has the worktree
|
|
292
|
+
# and the bash tool; the pr-review-context is enrichment.
|
|
293
|
+
task_sections = self._fetch_pr_context(
|
|
294
|
+
company=args.company,
|
|
295
|
+
owner=args.owner,
|
|
296
|
+
repo=args.repo,
|
|
297
|
+
pr=args.pr,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
messages = self._load_messages_block(args)
|
|
301
|
+
runner = AgentRunner(
|
|
302
|
+
company=args.company,
|
|
303
|
+
task="prfix",
|
|
304
|
+
archetype_name="pr-fixer",
|
|
305
|
+
workdir=worktree,
|
|
306
|
+
knowledge_store=store,
|
|
307
|
+
target=target,
|
|
308
|
+
model=args.model,
|
|
309
|
+
max_iterations=args.max_iter,
|
|
310
|
+
extra_user_instructions=self._pr_specific_instructions(args.owner, args.repo, args.pr, args.branch),
|
|
311
|
+
task_context_sections=task_sections,
|
|
312
|
+
dry_run=args.dry_run,
|
|
313
|
+
messages=messages,
|
|
314
|
+
)
|
|
315
|
+
result = runner.run()
|
|
316
|
+
|
|
317
|
+
log.info(
|
|
318
|
+
"agent-prfix: done iterations=%d stop=%s commits=%d tool_calls=%d %s%s",
|
|
319
|
+
result.iterations,
|
|
320
|
+
result.stop_reason,
|
|
321
|
+
len(result.commits),
|
|
322
|
+
result.tool_calls,
|
|
323
|
+
result.cost_summary(),
|
|
324
|
+
f" error={result.error!r}" if result.error else "",
|
|
325
|
+
)
|
|
326
|
+
if result.final_text:
|
|
327
|
+
print("--- agent final text ---")
|
|
328
|
+
print(result.final_text)
|
|
329
|
+
if result.commits:
|
|
330
|
+
print(f"--- commits: {', '.join(result.commits)} ---")
|
|
331
|
+
self._cleanup_worktree(worktree, keep=args.keep_worktree or bool(result.error))
|
|
332
|
+
return 0 if not result.error else 6
|
|
333
|
+
|
|
334
|
+
def _run_implement(self, args: argparse.Namespace) -> int:
|
|
335
|
+
"""Implement one ticket end-to-end via the engineer archetype.
|
|
336
|
+
|
|
337
|
+
Parallel to `_run_prfix` but anchored on a ticket key instead
|
|
338
|
+
of a PR number. Clones the default branch (the agent creates
|
|
339
|
+
its own feature branch + pushes + opens a PR); fetches the
|
|
340
|
+
full ticket body via the ticket-context task-scoped extractor;
|
|
341
|
+
splices it into the agent's system prompt."""
|
|
342
|
+
from briar.storage import make_store
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
store = make_store(args.store, file_root=Path(args.knowledge))
|
|
346
|
+
except Exception: # noqa: BLE001
|
|
347
|
+
log.exception("agent-implement: failed to open store=%s", args.store)
|
|
348
|
+
return 3
|
|
349
|
+
|
|
350
|
+
target = f"{args.owner}/{args.repo}"
|
|
351
|
+
|
|
352
|
+
worktree = Path(tempfile.mkdtemp(prefix="briar-agent-implement-"))
|
|
353
|
+
log.info(
|
|
354
|
+
"agent-implement: target=%s ticket=%s tracker=%s provider=%s worktree=%s dry_run=%s",
|
|
355
|
+
target,
|
|
356
|
+
args.ticket_key,
|
|
357
|
+
args.tracker,
|
|
358
|
+
args.provider,
|
|
359
|
+
worktree,
|
|
360
|
+
args.dry_run,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if not args.dry_run:
|
|
364
|
+
if not self._clone_default(args.provider, args.owner, args.repo, worktree, company=args.company):
|
|
365
|
+
log.error("agent-implement: clone failed; aborting")
|
|
366
|
+
self._cleanup_worktree(worktree, keep=args.keep_worktree)
|
|
367
|
+
return 4
|
|
368
|
+
|
|
369
|
+
git_name, git_email = self._resolve_git_identity(args)
|
|
370
|
+
log.info("agent-implement: git identity user.name=%s user.email=%s", git_name, git_email)
|
|
371
|
+
if not self._set_git_identity(worktree, git_name, git_email):
|
|
372
|
+
log.error("agent-implement: git identity setup failed; aborting")
|
|
373
|
+
self._cleanup_worktree(worktree, keep=args.keep_worktree)
|
|
374
|
+
return 5
|
|
375
|
+
|
|
376
|
+
# JIT-fetch the ticket's full body + ACs + comments. Spliced
|
|
377
|
+
# into the agent's system prompt below the archetype's persona.
|
|
378
|
+
# Failure here is non-fatal — the agent can still proceed with
|
|
379
|
+
# whatever the runbook-blob's `active-tickets` summary had, but
|
|
380
|
+
# we log a warning since the ticket-context is the engineer
|
|
381
|
+
# archetype's #1 priority input.
|
|
382
|
+
task_sections = self._fetch_ticket_context(
|
|
383
|
+
company=args.company,
|
|
384
|
+
tracker=args.tracker,
|
|
385
|
+
ticket_project=args.ticket_project,
|
|
386
|
+
ticket_key=args.ticket_key,
|
|
387
|
+
)
|
|
388
|
+
if not task_sections:
|
|
389
|
+
log.warning("agent-implement: ticket-context was empty — agent will rely on ticket key alone")
|
|
390
|
+
|
|
391
|
+
runner = AgentRunner(
|
|
392
|
+
company=args.company,
|
|
393
|
+
task="implement",
|
|
394
|
+
archetype_name="engineer",
|
|
395
|
+
workdir=worktree,
|
|
396
|
+
knowledge_store=store,
|
|
397
|
+
target=target,
|
|
398
|
+
model=args.model,
|
|
399
|
+
max_iterations=args.max_iter,
|
|
400
|
+
extra_user_instructions=self._implement_specific_instructions(
|
|
401
|
+
provider=args.provider,
|
|
402
|
+
company=args.company,
|
|
403
|
+
owner=args.owner,
|
|
404
|
+
repo=args.repo,
|
|
405
|
+
ticket_key=args.ticket_key,
|
|
406
|
+
),
|
|
407
|
+
task_context_sections=task_sections,
|
|
408
|
+
dry_run=args.dry_run,
|
|
409
|
+
messages=self._load_messages_block(args),
|
|
410
|
+
)
|
|
411
|
+
result = runner.run()
|
|
412
|
+
|
|
413
|
+
log.info(
|
|
414
|
+
"agent-implement: done iterations=%d stop=%s commits=%d tool_calls=%d %s%s",
|
|
415
|
+
result.iterations,
|
|
416
|
+
result.stop_reason,
|
|
417
|
+
len(result.commits),
|
|
418
|
+
result.tool_calls,
|
|
419
|
+
result.cost_summary(),
|
|
420
|
+
f" error={result.error!r}" if result.error else "",
|
|
421
|
+
)
|
|
422
|
+
if result.final_text:
|
|
423
|
+
print("--- agent final text ---")
|
|
424
|
+
print(result.final_text)
|
|
425
|
+
if result.commits:
|
|
426
|
+
print(f"--- commits: {', '.join(result.commits)} ---")
|
|
427
|
+
self._cleanup_worktree(worktree, keep=args.keep_worktree or bool(result.error))
|
|
428
|
+
return 0 if not result.error else 6
|
|
429
|
+
|
|
430
|
+
@staticmethod
|
|
431
|
+
def _clone_default(provider: str, owner: str, repo: str, dest: Path, *, company: str = "") -> bool:
|
|
432
|
+
"""Clone the default branch (no `--branch` flag) via the
|
|
433
|
+
`RepoCloner` registered for `provider`. The cloner owns the
|
|
434
|
+
per-vendor token resolution + URL conventions; this method
|
|
435
|
+
only knows how to invoke `git clone` + scrub the embedded
|
|
436
|
+
token from the persisted remote afterwards."""
|
|
437
|
+
try:
|
|
438
|
+
cloner = _resolve_cloner(provider)
|
|
439
|
+
except RuntimeError as exc:
|
|
440
|
+
log.error("clone failed: %s", exc)
|
|
441
|
+
return False
|
|
442
|
+
token = cloner.resolve_token(company=company)
|
|
443
|
+
if not token:
|
|
444
|
+
log.error("clone failed: no token for provider=%s company=%r", provider, company)
|
|
445
|
+
return False
|
|
446
|
+
clone_url = cloner.clone_url(owner, repo)
|
|
447
|
+
authed_url = cloner.authed_clone_url(owner, repo, token)
|
|
448
|
+
log.debug("clone-default: provider=%s dest=%s (token redacted)", provider, dest)
|
|
449
|
+
proc = subprocess.run(
|
|
450
|
+
["git", "clone", "--depth", "50", authed_url, str(dest)],
|
|
451
|
+
capture_output=True,
|
|
452
|
+
text=True,
|
|
453
|
+
timeout=180,
|
|
454
|
+
)
|
|
455
|
+
if proc.returncode != 0:
|
|
456
|
+
stderr = proc.stderr.replace(token, "<TOKEN>").strip()[:400]
|
|
457
|
+
log.error("clone failed: rc=%d stderr=%s", proc.returncode, stderr)
|
|
458
|
+
return False
|
|
459
|
+
reset = subprocess.run(
|
|
460
|
+
["git", "-C", str(dest), "remote", "set-url", "origin", clone_url],
|
|
461
|
+
capture_output=True,
|
|
462
|
+
text=True,
|
|
463
|
+
timeout=10,
|
|
464
|
+
)
|
|
465
|
+
if reset.returncode != 0:
|
|
466
|
+
log.warning("clone-default: remote set-url cleanup failed (token may persist in .git/config) provider=%s", provider)
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
@staticmethod
|
|
470
|
+
def _load_messages_block(args: argparse.Namespace):
|
|
471
|
+
"""Read the company's `messages:` block from the optional
|
|
472
|
+
--runbook YAML. Returns an empty dict on any failure (the agent
|
|
473
|
+
runs fine without bound message channels — it falls back to
|
|
474
|
+
the bash escape hatch for `gh` / `curl`)."""
|
|
475
|
+
runbook_path = getattr(args, "runbook", "") or ""
|
|
476
|
+
if not runbook_path:
|
|
477
|
+
return {}
|
|
478
|
+
try:
|
|
479
|
+
from briar.iac.runbook import load_runbook_file
|
|
480
|
+
|
|
481
|
+
rb = load_runbook_file(Path(runbook_path))
|
|
482
|
+
except Exception: # noqa: BLE001
|
|
483
|
+
log.exception("failed to load runbook=%s for messages: block — continuing without send_message tool", runbook_path)
|
|
484
|
+
return {}
|
|
485
|
+
company = rb.companies.get(args.company)
|
|
486
|
+
if company is None:
|
|
487
|
+
log.warning("runbook=%s has no company=%s — agent will run without bound messages", runbook_path, args.company)
|
|
488
|
+
return {}
|
|
489
|
+
return dict(getattr(company, "messages", {}) or {})
|
|
490
|
+
|
|
491
|
+
@staticmethod
|
|
492
|
+
def _resolve_git_identity(args: argparse.Namespace) -> Tuple[str, str]:
|
|
493
|
+
"""Resolve (user_name, user_email) for the worktree's commit identity.
|
|
494
|
+
|
|
495
|
+
Priority (per-field, independent):
|
|
496
|
+
1. CLI flag — ``--git-user-name`` / ``--git-user-email`` (non-empty)
|
|
497
|
+
2. Runbook YAML — ``companies.<name>.git_identity.{name, email}``
|
|
498
|
+
3. Hardcoded legacy default — ``iklobato`` / ``iklobato@gmail.com``
|
|
499
|
+
|
|
500
|
+
Per-field resolution means you can set the name via CLI and let
|
|
501
|
+
the email fall through to YAML (or vice versa). YAML lookup
|
|
502
|
+
runs only when ``--runbook`` was passed; failures during YAML
|
|
503
|
+
load are non-fatal — the resolver logs and falls through."""
|
|
504
|
+
cli_name = (getattr(args, "git_user_name", "") or "").strip()
|
|
505
|
+
cli_email = (getattr(args, "git_user_email", "") or "").strip()
|
|
506
|
+
|
|
507
|
+
yaml_name = ""
|
|
508
|
+
yaml_email = ""
|
|
509
|
+
runbook_path = getattr(args, "runbook", "") or ""
|
|
510
|
+
if runbook_path:
|
|
511
|
+
try:
|
|
512
|
+
from briar.iac.runbook import load_runbook_file
|
|
513
|
+
|
|
514
|
+
rb = load_runbook_file(Path(runbook_path))
|
|
515
|
+
company = rb.companies.get(getattr(args, "company", ""))
|
|
516
|
+
if company is not None:
|
|
517
|
+
gi = getattr(company, "git_identity", None)
|
|
518
|
+
if gi is not None:
|
|
519
|
+
yaml_name = (gi.name or "").strip()
|
|
520
|
+
yaml_email = (gi.email or "").strip()
|
|
521
|
+
except Exception: # noqa: BLE001
|
|
522
|
+
log.exception("failed to load runbook=%s for git_identity — falling back", runbook_path)
|
|
523
|
+
|
|
524
|
+
resolved_name = cli_name or yaml_name or "iklobato"
|
|
525
|
+
resolved_email = cli_email or yaml_email or "iklobato@gmail.com"
|
|
526
|
+
return resolved_name, resolved_email
|
|
527
|
+
|
|
528
|
+
@staticmethod
|
|
529
|
+
def _fetch_ticket_context(*, company: str, tracker: str, ticket_project: str, ticket_key: str):
|
|
530
|
+
"""Run the `ticket-context` task-scoped extractor. Returns a
|
|
531
|
+
list with one ExtractedSection or empty on failure. Symmetric
|
|
532
|
+
to `_fetch_pr_context` but for the engineer flow."""
|
|
533
|
+
import argparse as _ap
|
|
534
|
+
|
|
535
|
+
from briar.extract import TASK_SCOPED_EXTRACTORS
|
|
536
|
+
|
|
537
|
+
extractor = TASK_SCOPED_EXTRACTORS.get("ticket-context")
|
|
538
|
+
if extractor is None:
|
|
539
|
+
return []
|
|
540
|
+
ns = _ap.Namespace(
|
|
541
|
+
company=company,
|
|
542
|
+
tracker=tracker,
|
|
543
|
+
ticket_project=ticket_project,
|
|
544
|
+
ticket_key=ticket_key,
|
|
545
|
+
)
|
|
546
|
+
try:
|
|
547
|
+
section = extractor.fetch(ns)
|
|
548
|
+
except Exception: # noqa: BLE001
|
|
549
|
+
log.exception("ticket-context fetch failed; agent continues without it")
|
|
550
|
+
return []
|
|
551
|
+
if getattr(section, "is_empty", True):
|
|
552
|
+
return []
|
|
553
|
+
log.info("ticket-context: title=%r body_bytes=%d", section.title, len(section.body or ""))
|
|
554
|
+
return [section]
|
|
555
|
+
|
|
556
|
+
@staticmethod
|
|
557
|
+
def _implement_specific_instructions(*, provider: str, company: str, owner: str, repo: str, ticket_key: str) -> str:
|
|
558
|
+
"""Compose the agent's procedure instructions. Lines 1-5 are
|
|
559
|
+
provider-agnostic; lines 6-7 (the PR-creation recipe) come
|
|
560
|
+
from the provider's `RepoCloner.pr_creation_recipe(...)` so
|
|
561
|
+
adding a new vendor doesn't touch this method."""
|
|
562
|
+
branch = f"briar/{ticket_key.lower().replace('#', '').replace(' ', '-')}"
|
|
563
|
+
common = (
|
|
564
|
+
f"The target ticket is {ticket_key} on {owner}/{repo}. The worktree is a fresh clone of the "
|
|
565
|
+
"default branch. Procedure:\n"
|
|
566
|
+
" 1. Read the ticket-context section above for the full body + acceptance criteria. Address EVERY AC.\n"
|
|
567
|
+
f" 2. Create a feature branch: `git checkout -b {branch}`.\n"
|
|
568
|
+
" 3. Make the change. Match codebase-conventions (test runner, linter, formatter, migration tool).\n"
|
|
569
|
+
" 4. Run the test command from codebase-conventions locally; only push when it's green.\n"
|
|
570
|
+
" 5. Push: `git push -u origin HEAD` (NEVER --force).\n"
|
|
571
|
+
)
|
|
572
|
+
try:
|
|
573
|
+
cloner = _resolve_cloner(provider)
|
|
574
|
+
except RuntimeError:
|
|
575
|
+
# Unknown provider — degrade gracefully with the GitHub
|
|
576
|
+
# recipe rather than crashing the instruction build.
|
|
577
|
+
cloner = REPO_CLONERS["github"]
|
|
578
|
+
recipe = cloner.pr_creation_recipe(owner=owner, repo=repo, branch=branch, company=company)
|
|
579
|
+
return common + recipe + (
|
|
580
|
+
"\n"
|
|
581
|
+
"Strict constraints: NEVER --force, --amend, rebase, squash. NEVER commit as a bot identity "
|
|
582
|
+
"(run `git config user.name` to verify it's a human). If an AC is ambiguous, stop and post a "
|
|
583
|
+
"clarifying comment on the ticket — do not guess."
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
@staticmethod
|
|
587
|
+
def _clone_branch(clone_url: str, branch: str, dest: Path) -> bool:
|
|
588
|
+
"""Clone via HTTPS with the GITHUB_TOKEN embedded in the URL.
|
|
589
|
+
|
|
590
|
+
The droplet has no git credential helper configured for
|
|
591
|
+
github.com (and `gh` isn't installed), so plain `git clone
|
|
592
|
+
https://github.com/...` fails with `could not read Username for
|
|
593
|
+
'https://github.com'`. Standard CI workaround: inject the token
|
|
594
|
+
as the username field. The token comes from $GITHUB_TOKEN — same
|
|
595
|
+
env var the rest of briar uses (sourced from /etc/briar/secrets.env
|
|
596
|
+
on the droplet via `set -a; . /etc/briar/secrets.env`).
|
|
597
|
+
|
|
598
|
+
The token is stripped from the resulting remote URL after clone
|
|
599
|
+
so it does not linger in .git/config on disk."""
|
|
600
|
+
import os
|
|
601
|
+
|
|
602
|
+
token = os.environ.get("GITHUB_TOKEN", "").strip()
|
|
603
|
+
if not token:
|
|
604
|
+
log.error("clone failed: GITHUB_TOKEN env var missing")
|
|
605
|
+
return False
|
|
606
|
+
authed_url = clone_url.replace("https://github.com/", f"https://x-access-token:{token}@github.com/")
|
|
607
|
+
log.debug("clone-branch: branch=%s dest=%s (token redacted)", branch, dest)
|
|
608
|
+
proc = subprocess.run(
|
|
609
|
+
["git", "clone", "--depth", "50", "--branch", branch, authed_url, str(dest)],
|
|
610
|
+
capture_output=True,
|
|
611
|
+
text=True,
|
|
612
|
+
timeout=180,
|
|
613
|
+
)
|
|
614
|
+
if proc.returncode != 0:
|
|
615
|
+
# Redact the token from the URL in case it leaked into stderr.
|
|
616
|
+
stderr = proc.stderr.replace(token, "<TOKEN>").strip()[:400]
|
|
617
|
+
log.error("clone failed: rc=%d stderr=%s", proc.returncode, stderr)
|
|
618
|
+
return False
|
|
619
|
+
# Strip the embedded token from the persisted remote so anyone
|
|
620
|
+
# who looks at .git/config later doesn't see it.
|
|
621
|
+
reset = subprocess.run(
|
|
622
|
+
["git", "-C", str(dest), "remote", "set-url", "origin", clone_url],
|
|
623
|
+
capture_output=True,
|
|
624
|
+
text=True,
|
|
625
|
+
timeout=10,
|
|
626
|
+
)
|
|
627
|
+
if reset.returncode != 0:
|
|
628
|
+
log.warning("clone: remote set-url cleanup failed (token may persist in .git/config)")
|
|
629
|
+
return True
|
|
630
|
+
|
|
631
|
+
@staticmethod
|
|
632
|
+
def _set_git_identity(worktree: Path, user_name: str, user_email: str) -> bool:
|
|
633
|
+
for key, value in (("user.name", user_name), ("user.email", user_email), ("commit.gpgsign", "false")):
|
|
634
|
+
proc = subprocess.run(
|
|
635
|
+
["git", "-C", str(worktree), "config", key, value],
|
|
636
|
+
capture_output=True,
|
|
637
|
+
text=True,
|
|
638
|
+
timeout=10,
|
|
639
|
+
)
|
|
640
|
+
if proc.returncode != 0:
|
|
641
|
+
log.error("git config %s failed: %s", key, proc.stderr.strip())
|
|
642
|
+
return False
|
|
643
|
+
return True
|
|
644
|
+
|
|
645
|
+
@staticmethod
|
|
646
|
+
def _fetch_pr_context(*, company: str, owner: str, repo: str, pr: int):
|
|
647
|
+
"""Run the `pr-review-context` task-scoped extractor for this
|
|
648
|
+
PR. Returns a list with one ExtractedSection or empty on failure.
|
|
649
|
+
Defensive — the agent should still run if this fetch fails."""
|
|
650
|
+
import argparse as _ap
|
|
651
|
+
|
|
652
|
+
from briar.extract import TASK_SCOPED_EXTRACTORS
|
|
653
|
+
|
|
654
|
+
extractor = TASK_SCOPED_EXTRACTORS.get("pr-review-context")
|
|
655
|
+
if extractor is None:
|
|
656
|
+
return []
|
|
657
|
+
ns = _ap.Namespace(
|
|
658
|
+
company=company,
|
|
659
|
+
provider="github", # CommandAgent.prfix is GitHub-only today; Bitbucket variant would override.
|
|
660
|
+
pr_target_repo=f"{owner}/{repo}",
|
|
661
|
+
pr_target_number=pr,
|
|
662
|
+
)
|
|
663
|
+
try:
|
|
664
|
+
section = extractor.fetch(ns)
|
|
665
|
+
except Exception: # noqa: BLE001
|
|
666
|
+
log.exception("pr-review-context fetch failed; agent continues without it")
|
|
667
|
+
return []
|
|
668
|
+
if getattr(section, "is_empty", True):
|
|
669
|
+
return []
|
|
670
|
+
log.info("pr-review-context: title=%r body_bytes=%d", section.title, len(section.body or ""))
|
|
671
|
+
return [section]
|
|
672
|
+
|
|
673
|
+
@staticmethod
|
|
674
|
+
def _pr_specific_instructions(owner: str, repo: str, pr: int, branch: str) -> str:
|
|
675
|
+
return (
|
|
676
|
+
f"The target is PR #{pr} on {owner}/{repo}, branch {branch}. The worktree is a fresh clone at "
|
|
677
|
+
"the branch HEAD. Use `gh pr view {pr} --repo {owner}/{repo}` for state, "
|
|
678
|
+
"`gh api repos/{owner}/{repo}/pulls/{pr}/comments` for inline threads, "
|
|
679
|
+
"`gh api repos/{owner}/{repo}/issues/{pr}/comments` for PR-level comments. "
|
|
680
|
+
"Push with `git push origin HEAD:{branch}` (NEVER --force). Reply to threads "
|
|
681
|
+
"via `gh api -X POST repos/{owner}/{repo}/pulls/{pr}/comments/<id>/replies -f body=...`."
|
|
682
|
+
).format(owner=owner, repo=repo, pr=pr, branch=branch)
|
|
683
|
+
|
|
684
|
+
@staticmethod
|
|
685
|
+
def _cleanup_worktree(path: Path, *, keep: bool) -> None:
|
|
686
|
+
if keep:
|
|
687
|
+
log.info("worktree kept at %s for inspection", path)
|
|
688
|
+
return
|
|
689
|
+
try:
|
|
690
|
+
shutil.rmtree(path)
|
|
691
|
+
except OSError:
|
|
692
|
+
log.exception("worktree cleanup failed: %s", path)
|