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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Mine the merged-PR history of one or more repos.
|
|
2
|
+
|
|
3
|
+
Surfaces the patterns + reviewer cadence agents should respect when
|
|
4
|
+
proposing changes. Modeled on
|
|
5
|
+
`claude-standalone/scans/pr_archaeology.py` condensed to a live agent
|
|
6
|
+
context blob (heavy duplicate-PR clustering is deferred to v2).
|
|
7
|
+
|
|
8
|
+
Provider-agnostic: this extractor talks to a `RepositoryProvider`,
|
|
9
|
+
not to GitHub directly. Setting ``--provider bitbucket`` in the
|
|
10
|
+
runbook routes the same logic onto Bitbucket Cloud once
|
|
11
|
+
`BitbucketProvider.list_pulls` is implemented."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from statistics import median
|
|
19
|
+
from typing import Any, Dict, List
|
|
20
|
+
|
|
21
|
+
from briar.extract._provider import PullRequest
|
|
22
|
+
from briar.extract._user_filter import (
|
|
23
|
+
add_user_filter_arguments,
|
|
24
|
+
apply_user_filter_objs,
|
|
25
|
+
)
|
|
26
|
+
from briar.extract.base import EMPTY_SECTION, ExtractedSection, RepoBackedExtractor
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExtractPrArchaeology(RepoBackedExtractor):
|
|
30
|
+
UNPARSABLE_HOURS = -1.0
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def _hours_between(cls, start_iso: str, end_iso: str) -> float:
|
|
34
|
+
"""Hours between two ISO-8601 timestamps. Returns `-1.0` on a
|
|
35
|
+
parse error — callers filter that out before averaging."""
|
|
36
|
+
try:
|
|
37
|
+
s = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
|
|
38
|
+
e = datetime.fromisoformat(end_iso.replace("Z", "+00:00"))
|
|
39
|
+
except ValueError:
|
|
40
|
+
return cls.UNPARSABLE_HOURS
|
|
41
|
+
return (e - s).total_seconds() / 3600.0
|
|
42
|
+
|
|
43
|
+
name = "pr-archaeology"
|
|
44
|
+
description = "merged-PR patterns, review focus, reviewer profiles"
|
|
45
|
+
requires_github = True # legacy flag — kept for back-compat; new gate is requires_repository_provider
|
|
46
|
+
|
|
47
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
48
|
+
super().add_arguments(parser)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--pr-repo",
|
|
51
|
+
action="append",
|
|
52
|
+
default=[],
|
|
53
|
+
help="Repository slug to mine (e.g. owner/repo). Repeatable.",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--pr-max",
|
|
57
|
+
type=int,
|
|
58
|
+
default=100,
|
|
59
|
+
help="Max merged PRs per repo (default: 100)",
|
|
60
|
+
)
|
|
61
|
+
add_user_filter_arguments(parser, prefix="pr")
|
|
62
|
+
|
|
63
|
+
def is_available(self, args: argparse.Namespace) -> bool:
|
|
64
|
+
if not args.pr_repo:
|
|
65
|
+
return False
|
|
66
|
+
try:
|
|
67
|
+
provider = self._provider(args)
|
|
68
|
+
except Exception: # noqa: BLE001
|
|
69
|
+
return False
|
|
70
|
+
return provider.is_available()
|
|
71
|
+
|
|
72
|
+
def extract(self, args: argparse.Namespace) -> ExtractedSection:
|
|
73
|
+
provider = self._provider(args)
|
|
74
|
+
per_repo: List[ExtractedSection] = []
|
|
75
|
+
for repo in args.pr_repo:
|
|
76
|
+
section = self._mine_repo(repo, args.pr_max, args, provider)
|
|
77
|
+
if not section.is_empty:
|
|
78
|
+
per_repo.append(section)
|
|
79
|
+
if not per_repo:
|
|
80
|
+
return EMPTY_SECTION
|
|
81
|
+
return ExtractedSection(
|
|
82
|
+
title=f"PR archaeology — {len(per_repo)} repo(s)",
|
|
83
|
+
body=(
|
|
84
|
+
"Patterns from the most recent merged PRs. Agents should "
|
|
85
|
+
"match the established conventions (review focus, file "
|
|
86
|
+
"paths touched most, reviewer cadence)."
|
|
87
|
+
),
|
|
88
|
+
subsections=per_repo,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _mine_repo(
|
|
92
|
+
self,
|
|
93
|
+
repo: str,
|
|
94
|
+
max_prs: int,
|
|
95
|
+
args: argparse.Namespace,
|
|
96
|
+
provider,
|
|
97
|
+
) -> ExtractedSection:
|
|
98
|
+
merged: List[PullRequest] = provider.list_pulls(repo, state="merged", max_count=max_prs)
|
|
99
|
+
merged = apply_user_filter_objs(merged, args, prefix="pr")
|
|
100
|
+
if not merged:
|
|
101
|
+
return EMPTY_SECTION
|
|
102
|
+
|
|
103
|
+
cycle_hours = [h for h in (self._hours_between(p.created_at, p.merged_at) for p in merged) if h >= 0]
|
|
104
|
+
reviewers: Counter = Counter()
|
|
105
|
+
authors: Counter = Counter()
|
|
106
|
+
for p in merged:
|
|
107
|
+
authors[p.author or "?"] += 1
|
|
108
|
+
for r in p.requested_reviewers:
|
|
109
|
+
reviewers[r or "?"] += 1
|
|
110
|
+
|
|
111
|
+
data: Dict[str, Any] = {
|
|
112
|
+
"repo": repo,
|
|
113
|
+
"merged_pr_count": len(merged),
|
|
114
|
+
"median_cycle_hours": (round(median(cycle_hours), 2) if cycle_hours else None),
|
|
115
|
+
"top_authors": authors.most_common(5),
|
|
116
|
+
"top_reviewers": reviewers.most_common(5),
|
|
117
|
+
}
|
|
118
|
+
body_lines = [f"- merged PR sample: **{data['merged_pr_count']}**"]
|
|
119
|
+
if data["median_cycle_hours"] is not None:
|
|
120
|
+
body_lines.append(f"- median time-to-merge: **{data['median_cycle_hours']}h**")
|
|
121
|
+
if data["top_authors"]:
|
|
122
|
+
top = ", ".join(f"{u}({n})" for u, n in data["top_authors"])
|
|
123
|
+
body_lines.append(f"- top authors: {top}")
|
|
124
|
+
if data["top_reviewers"]:
|
|
125
|
+
top = ", ".join(f"{u}({n})" for u, n in data["top_reviewers"])
|
|
126
|
+
body_lines.append(f"- requested reviewers: {top}")
|
|
127
|
+
return ExtractedSection(
|
|
128
|
+
title=repo,
|
|
129
|
+
body="\n".join(body_lines),
|
|
130
|
+
data=data,
|
|
131
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Task-scoped: fetch full review context for ONE PR.
|
|
2
|
+
|
|
3
|
+
Invoked by `briar agent` when the operator passes a specific PR
|
|
4
|
+
number. Output is spliced into that single agent run's system prompt
|
|
5
|
+
so the pr-fixer archetype can see:
|
|
6
|
+
|
|
7
|
+
- the PR's metadata (title, branches, draft status)
|
|
8
|
+
- every comment thread (resolved + unresolved, inline + top-level)
|
|
9
|
+
- failing CI steps with a log tail per failure
|
|
10
|
+
|
|
11
|
+
That's what the agent actually needs to address review feedback —
|
|
12
|
+
the scheduled `active-work` extractor only surfaces metadata."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import logging
|
|
18
|
+
from typing import List
|
|
19
|
+
|
|
20
|
+
from briar.extract._provider import CiFailure, ReviewComment
|
|
21
|
+
from briar.extract.base import EMPTY_SECTION, ExtractedSection, TaskScopedRepoExtractor
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FetchPrReviewContext(TaskScopedRepoExtractor):
|
|
28
|
+
name = "pr-review-context"
|
|
29
|
+
description = "Full review context (comments + CI failures) for ONE specific PR"
|
|
30
|
+
|
|
31
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
32
|
+
super().add_arguments(parser)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--pr-target-repo",
|
|
35
|
+
required=True,
|
|
36
|
+
help="Repository slug (`owner/repo` for GitHub, `workspace/slug` for Bitbucket)",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--pr-target-number",
|
|
40
|
+
type=int,
|
|
41
|
+
required=True,
|
|
42
|
+
help="PR number to fetch context for",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def fetch(self, args: argparse.Namespace) -> ExtractedSection:
|
|
46
|
+
provider = self._provider(args)
|
|
47
|
+
repo = args.pr_target_repo
|
|
48
|
+
number = args.pr_target_number
|
|
49
|
+
|
|
50
|
+
pr = provider.get_pull(repo, number)
|
|
51
|
+
# Adapter's `@swallow_errors(default=None)` returns None on any
|
|
52
|
+
# provider-side failure. The boundary that translates None →
|
|
53
|
+
# EMPTY_SECTION lives at this caller, not inside the decorator.
|
|
54
|
+
if pr is None or (not pr.title and not pr.head_ref):
|
|
55
|
+
log.warning("pr-review-context: PR %s#%d not found", repo, number)
|
|
56
|
+
return EMPTY_SECTION
|
|
57
|
+
|
|
58
|
+
comments: List[ReviewComment] = provider.list_pr_comments(repo, number)
|
|
59
|
+
failures: List[CiFailure] = provider.list_ci_failures(repo, number)
|
|
60
|
+
|
|
61
|
+
body_parts: List[str] = [
|
|
62
|
+
f"**PR**: {repo}#{number}",
|
|
63
|
+
f"**Title**: {pr.title}",
|
|
64
|
+
f"**Author**: {pr.author or '(unknown)'}",
|
|
65
|
+
f"**Branch**: {pr.head_ref} → {pr.base_ref}",
|
|
66
|
+
]
|
|
67
|
+
if pr.is_draft:
|
|
68
|
+
body_parts.append("**Status**: DRAFT")
|
|
69
|
+
if pr.requested_reviewers:
|
|
70
|
+
body_parts.append(f"**Reviewers**: {', '.join(pr.requested_reviewers)}")
|
|
71
|
+
|
|
72
|
+
# CI failures first — usually higher priority than comments.
|
|
73
|
+
if failures:
|
|
74
|
+
body_parts.append("")
|
|
75
|
+
body_parts.append(f"### Failing CI ({len(failures)})")
|
|
76
|
+
body_parts.append("")
|
|
77
|
+
for f in failures:
|
|
78
|
+
body_parts.append(f"**{f.workflow} → {f.job} → {f.step}**")
|
|
79
|
+
if f.url:
|
|
80
|
+
body_parts.append(f"_{f.url}_")
|
|
81
|
+
if f.log_tail:
|
|
82
|
+
body_parts.append("```")
|
|
83
|
+
body_parts.append(f.log_tail)
|
|
84
|
+
body_parts.append("```")
|
|
85
|
+
body_parts.append("")
|
|
86
|
+
|
|
87
|
+
if comments:
|
|
88
|
+
inline = [c for c in comments if c.file_path]
|
|
89
|
+
top_level = [c for c in comments if not c.file_path]
|
|
90
|
+
|
|
91
|
+
if inline:
|
|
92
|
+
body_parts.append(f"### Inline review comments ({len(inline)})")
|
|
93
|
+
body_parts.append("")
|
|
94
|
+
for c in inline[:30]:
|
|
95
|
+
body_parts.append(f"**{c.author}** on `{c.file_path}:{c.line}`:")
|
|
96
|
+
body_parts.append(c.body)
|
|
97
|
+
body_parts.append("")
|
|
98
|
+
if len(inline) > 30:
|
|
99
|
+
body_parts.append(f"_…and {len(inline) - 30} more inline comments_")
|
|
100
|
+
|
|
101
|
+
if top_level:
|
|
102
|
+
body_parts.append(f"### PR-level comments ({len(top_level)})")
|
|
103
|
+
body_parts.append("")
|
|
104
|
+
for c in top_level[:15]:
|
|
105
|
+
body_parts.append(f"**{c.author}** ({c.created_at}):")
|
|
106
|
+
body_parts.append(c.body)
|
|
107
|
+
body_parts.append("")
|
|
108
|
+
|
|
109
|
+
if not failures and not comments:
|
|
110
|
+
body_parts.append("")
|
|
111
|
+
body_parts.append("_No failing CI and no review comments — PR may already be ready to merge._")
|
|
112
|
+
|
|
113
|
+
return ExtractedSection(
|
|
114
|
+
title=f"PR review context — {repo}#{number}: {pr.title[:60]}",
|
|
115
|
+
body="\n".join(body_parts),
|
|
116
|
+
data={
|
|
117
|
+
"pr_number": pr.number,
|
|
118
|
+
"title": pr.title,
|
|
119
|
+
"author": pr.author,
|
|
120
|
+
"comment_count": len(comments),
|
|
121
|
+
"failing_ci_count": len(failures),
|
|
122
|
+
},
|
|
123
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Mine each top-reviewer's review behaviour per repo.
|
|
2
|
+
|
|
3
|
+
Surfaces patterns the engineer/pr-fixer archetypes should match:
|
|
4
|
+
which reviewers leave the most comments, what files they touch, the
|
|
5
|
+
typical request volume per PR. Lets an agent calibrate "how much
|
|
6
|
+
review depth does this reviewer expect" instead of guessing.
|
|
7
|
+
|
|
8
|
+
Provider-agnostic: same RepositoryProvider contract as
|
|
9
|
+
pr-archaeology. Falls back gracefully on providers that don't expose
|
|
10
|
+
review comments via the `list_pr_comments` verb."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
from collections import Counter, defaultdict
|
|
16
|
+
from typing import Any, Dict, List
|
|
17
|
+
|
|
18
|
+
from briar.extract._provider import PullRequest, ReviewComment
|
|
19
|
+
from briar.extract.base import EMPTY_SECTION, ExtractedSection, RepoBackedExtractor
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExtractReviewerProfile(RepoBackedExtractor):
|
|
23
|
+
name = "reviewer-profile"
|
|
24
|
+
description = "per-reviewer comment cadence, file hotspots, common asks"
|
|
25
|
+
requires_github = True # legacy flag
|
|
26
|
+
|
|
27
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
28
|
+
super().add_arguments(parser)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--reviewer-repo",
|
|
31
|
+
action="append",
|
|
32
|
+
default=[],
|
|
33
|
+
help="Repository slug to profile reviewers for. Repeatable.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--reviewer-pr-sample",
|
|
37
|
+
type=int,
|
|
38
|
+
default=20,
|
|
39
|
+
help="How many recent merged PRs to sample per repo (default: 20)",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--reviewer-top-n",
|
|
43
|
+
type=int,
|
|
44
|
+
default=5,
|
|
45
|
+
help="How many top reviewers to profile (default: 5)",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def is_available(self, args: argparse.Namespace) -> bool:
|
|
49
|
+
if not args.reviewer_repo:
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
provider = self._provider(args)
|
|
53
|
+
except Exception: # noqa: BLE001
|
|
54
|
+
return False
|
|
55
|
+
return provider.is_available()
|
|
56
|
+
|
|
57
|
+
def extract(self, args: argparse.Namespace) -> ExtractedSection:
|
|
58
|
+
provider = self._provider(args)
|
|
59
|
+
per_repo: List[ExtractedSection] = []
|
|
60
|
+
for repo in args.reviewer_repo:
|
|
61
|
+
section = self._profile_repo(repo, args, provider)
|
|
62
|
+
if not section.is_empty:
|
|
63
|
+
per_repo.append(section)
|
|
64
|
+
if not per_repo:
|
|
65
|
+
return EMPTY_SECTION
|
|
66
|
+
return ExtractedSection(
|
|
67
|
+
title=f"Reviewer profiles — {len(per_repo)} repo(s)",
|
|
68
|
+
body=(
|
|
69
|
+
"Per-reviewer behaviour mined from recent merged PRs. "
|
|
70
|
+
"Agents should match the bar of the most-likely reviewer "
|
|
71
|
+
"for the touched files — not the project median."
|
|
72
|
+
),
|
|
73
|
+
subsections=per_repo,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _profile_repo(self, repo: str, args: argparse.Namespace, provider) -> ExtractedSection:
|
|
77
|
+
sample = provider.list_pulls(repo, state="merged", max_count=args.reviewer_pr_sample)
|
|
78
|
+
if not sample:
|
|
79
|
+
return EMPTY_SECTION
|
|
80
|
+
|
|
81
|
+
# Aggregate: per-reviewer, total comments + which files they
|
|
82
|
+
# commented on + sample of their actual comment bodies.
|
|
83
|
+
per_reviewer_comments: Dict[str, int] = Counter()
|
|
84
|
+
per_reviewer_files: Dict[str, Counter] = defaultdict(Counter)
|
|
85
|
+
per_reviewer_samples: Dict[str, List[str]] = defaultdict(list)
|
|
86
|
+
prs_reviewed: Dict[str, int] = Counter()
|
|
87
|
+
|
|
88
|
+
for pr in sample:
|
|
89
|
+
comments: List[ReviewComment] = provider.list_pr_comments(repo, pr.number)
|
|
90
|
+
reviewers_on_this_pr: set = set()
|
|
91
|
+
for c in comments:
|
|
92
|
+
if not c.author or c.author == pr.author:
|
|
93
|
+
continue # skip self-comments
|
|
94
|
+
per_reviewer_comments[c.author] += 1
|
|
95
|
+
if c.file_path:
|
|
96
|
+
per_reviewer_files[c.author][c.file_path] += 1
|
|
97
|
+
# Keep a short sample of actual comment text per reviewer.
|
|
98
|
+
if len(per_reviewer_samples[c.author]) < 3 and len(c.body) > 20:
|
|
99
|
+
per_reviewer_samples[c.author].append(c.body[:200])
|
|
100
|
+
reviewers_on_this_pr.add(c.author)
|
|
101
|
+
for r in reviewers_on_this_pr:
|
|
102
|
+
prs_reviewed[r] += 1
|
|
103
|
+
|
|
104
|
+
if not per_reviewer_comments:
|
|
105
|
+
return EMPTY_SECTION
|
|
106
|
+
|
|
107
|
+
top_reviewers = per_reviewer_comments.most_common(args.reviewer_top_n)
|
|
108
|
+
body_parts: List[str] = [
|
|
109
|
+
f"Sample: {len(sample)} merged PRs",
|
|
110
|
+
f"Active reviewers: {len(per_reviewer_comments)}",
|
|
111
|
+
"",
|
|
112
|
+
]
|
|
113
|
+
data_rows: List[Dict[str, Any]] = []
|
|
114
|
+
for reviewer, total_comments in top_reviewers:
|
|
115
|
+
prs = prs_reviewed.get(reviewer, 0)
|
|
116
|
+
avg_per_pr = round(total_comments / prs, 1) if prs else 0.0
|
|
117
|
+
top_files = [path for path, _ in per_reviewer_files[reviewer].most_common(3)]
|
|
118
|
+
body_parts.append(f"### {reviewer}")
|
|
119
|
+
body_parts.append(f"- PRs reviewed: **{prs}** / comments left: **{total_comments}** (avg **{avg_per_pr}**/PR)")
|
|
120
|
+
if top_files:
|
|
121
|
+
body_parts.append(f"- Hot files: {', '.join(top_files)}")
|
|
122
|
+
samples = per_reviewer_samples.get(reviewer, [])
|
|
123
|
+
if samples:
|
|
124
|
+
body_parts.append(f"- Sample asks (truncated):")
|
|
125
|
+
for s in samples:
|
|
126
|
+
body_parts.append(f" - _{s}_")
|
|
127
|
+
body_parts.append("")
|
|
128
|
+
data_rows.append(
|
|
129
|
+
{
|
|
130
|
+
"reviewer": reviewer,
|
|
131
|
+
"prs_reviewed": prs,
|
|
132
|
+
"comments": total_comments,
|
|
133
|
+
"avg_comments_per_pr": avg_per_pr,
|
|
134
|
+
"top_files": top_files,
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
return ExtractedSection(
|
|
138
|
+
title=repo,
|
|
139
|
+
body="\n".join(body_parts),
|
|
140
|
+
data={"reviewers": data_rows, "pr_sample_size": len(sample)},
|
|
141
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Mine the closed-ticket history of one or more projects.
|
|
2
|
+
|
|
3
|
+
Symmetric to `pr-archaeology`. Surfaces: median time-to-close, top
|
|
4
|
+
reporters/assignees, label distribution. Helps agents match the
|
|
5
|
+
project's established triage cadence + categorisation."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from statistics import median
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
from briar.extract._tracker import Ticket
|
|
16
|
+
from briar.extract.base import EMPTY_SECTION, ExtractedSection, TrackerBackedExtractor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExtractTicketArchaeology(TrackerBackedExtractor):
|
|
20
|
+
UNPARSABLE_HOURS = -1.0
|
|
21
|
+
|
|
22
|
+
name = "ticket-archaeology"
|
|
23
|
+
description = "closed-ticket patterns, assignee + label cadence"
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def _hours_between(cls, start_iso: str, end_iso: str) -> float:
|
|
27
|
+
try:
|
|
28
|
+
s = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
|
|
29
|
+
e = datetime.fromisoformat(end_iso.replace("Z", "+00:00"))
|
|
30
|
+
except ValueError:
|
|
31
|
+
return cls.UNPARSABLE_HOURS
|
|
32
|
+
return (e - s).total_seconds() / 3600.0
|
|
33
|
+
|
|
34
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
35
|
+
super().add_arguments(parser)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--ticket-archaeology-project",
|
|
38
|
+
action="append",
|
|
39
|
+
default=[],
|
|
40
|
+
help="Tracker project key to mine. Repeatable.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--ticket-max",
|
|
44
|
+
type=int,
|
|
45
|
+
default=100,
|
|
46
|
+
help="Max closed tickets per project (default: 100)",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def is_available(self, args: argparse.Namespace) -> bool:
|
|
50
|
+
if not args.ticket_archaeology_project:
|
|
51
|
+
return False
|
|
52
|
+
try:
|
|
53
|
+
tracker = self._tracker(args)
|
|
54
|
+
except Exception: # noqa: BLE001
|
|
55
|
+
return False
|
|
56
|
+
return tracker.is_available()
|
|
57
|
+
|
|
58
|
+
def extract(self, args: argparse.Namespace) -> ExtractedSection:
|
|
59
|
+
tracker = self._tracker(args)
|
|
60
|
+
per_project: List[ExtractedSection] = []
|
|
61
|
+
for project in args.ticket_archaeology_project:
|
|
62
|
+
section = self._mine_project(project, args.ticket_max, tracker)
|
|
63
|
+
if not section.is_empty:
|
|
64
|
+
per_project.append(section)
|
|
65
|
+
if not per_project:
|
|
66
|
+
return EMPTY_SECTION
|
|
67
|
+
return ExtractedSection(
|
|
68
|
+
title=f"Ticket archaeology — {len(per_project)} project(s)",
|
|
69
|
+
body=("Patterns from the most recent closed tickets. Agents " "should match the project's triage + labelling conventions."),
|
|
70
|
+
subsections=per_project,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _mine_project(self, project: str, max_tickets: int, tracker) -> ExtractedSection:
|
|
74
|
+
tickets: List[Ticket] = tracker.list_tickets(project, state="closed", max_count=max_tickets)
|
|
75
|
+
if not tickets:
|
|
76
|
+
return EMPTY_SECTION
|
|
77
|
+
|
|
78
|
+
cycle_hours = [h for h in (self._hours_between(t.created_at, t.updated_at) for t in tickets) if h >= 0]
|
|
79
|
+
reporters: Counter = Counter()
|
|
80
|
+
assignees: Counter = Counter()
|
|
81
|
+
labels: Counter = Counter()
|
|
82
|
+
kinds: Counter = Counter()
|
|
83
|
+
for t in tickets:
|
|
84
|
+
reporters[t.reporter or "?"] += 1
|
|
85
|
+
assignees[t.assignee or "?"] += 1
|
|
86
|
+
kinds[t.kind or "?"] += 1
|
|
87
|
+
for lbl in t.labels:
|
|
88
|
+
labels[lbl] += 1
|
|
89
|
+
|
|
90
|
+
data: Dict[str, Any] = {
|
|
91
|
+
"project": project,
|
|
92
|
+
"closed_ticket_count": len(tickets),
|
|
93
|
+
"median_close_hours": (round(median(cycle_hours), 2) if cycle_hours else None),
|
|
94
|
+
"top_reporters": reporters.most_common(5),
|
|
95
|
+
"top_assignees": assignees.most_common(5),
|
|
96
|
+
"top_labels": labels.most_common(5),
|
|
97
|
+
"kinds": kinds.most_common(5),
|
|
98
|
+
}
|
|
99
|
+
body_lines = [f"- closed ticket sample: **{data['closed_ticket_count']}**"]
|
|
100
|
+
if data["median_close_hours"] is not None:
|
|
101
|
+
body_lines.append(f"- median time-to-close: **{data['median_close_hours']}h**")
|
|
102
|
+
for label, items in (
|
|
103
|
+
("top reporters", data["top_reporters"]),
|
|
104
|
+
("top assignees", data["top_assignees"]),
|
|
105
|
+
("top labels", data["top_labels"]),
|
|
106
|
+
("kinds", data["kinds"]),
|
|
107
|
+
):
|
|
108
|
+
if items:
|
|
109
|
+
joined = ", ".join(f"{u}({n})" for u, n in items)
|
|
110
|
+
body_lines.append(f"- {label}: {joined}")
|
|
111
|
+
return ExtractedSection(
|
|
112
|
+
title=project,
|
|
113
|
+
body="\n".join(body_lines),
|
|
114
|
+
data=data,
|
|
115
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Task-scoped: fetch full body, ACs, and comments for ONE ticket.
|
|
2
|
+
|
|
3
|
+
Invoked by `briar agent` when the operator passes a specific ticket
|
|
4
|
+
key. Output is spliced into that single agent run's system prompt —
|
|
5
|
+
it does NOT go into the per-company knowledge blob.
|
|
6
|
+
|
|
7
|
+
The agent uses this section to anchor its plan against the actual
|
|
8
|
+
ticket description rather than just the title + assignee summary
|
|
9
|
+
that the scheduled `active-tickets` extractor surfaces."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import logging
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
from briar.extract._tracker import Comment
|
|
18
|
+
from briar.extract.base import EMPTY_SECTION, ExtractedSection, TaskScopedTrackerExtractor
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FetchTicketContext(TaskScopedTrackerExtractor):
|
|
25
|
+
name = "ticket-context"
|
|
26
|
+
description = "Full body + ACs + comments for ONE specific ticket"
|
|
27
|
+
|
|
28
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
29
|
+
super().add_arguments(parser)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--ticket-project",
|
|
32
|
+
required=True,
|
|
33
|
+
help="Tracker project key (Jira: PROJ; Linear team: ENG; "
|
|
34
|
+
"GH Issues: owner/repo; BB Issues: workspace/repo)",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--ticket-key",
|
|
38
|
+
required=True,
|
|
39
|
+
help="Ticket identifier (Jira: PROJ-123; GH/BB: #42; Linear: ENG-7)",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def fetch(self, args: argparse.Namespace) -> ExtractedSection:
|
|
43
|
+
tracker = self._tracker(args)
|
|
44
|
+
ticket = tracker.get_ticket(args.ticket_project, args.ticket_key)
|
|
45
|
+
# `swallow_errors(default=None)` on the provider verb means the
|
|
46
|
+
# adapter returns None on any failure (auth, network, missing
|
|
47
|
+
# ticket). The decorator is the right contract for the adapter —
|
|
48
|
+
# the boundary that converts None → EMPTY_SECTION lives here.
|
|
49
|
+
if ticket is None or (not ticket.title and not ticket.description):
|
|
50
|
+
log.warning("ticket-context: %s not found or empty", args.ticket_key)
|
|
51
|
+
return EMPTY_SECTION
|
|
52
|
+
|
|
53
|
+
comments: List[Comment] = tracker.list_comments(args.ticket_project, args.ticket_key)
|
|
54
|
+
transitions = tracker.list_status_transitions(args.ticket_project, args.ticket_key)
|
|
55
|
+
|
|
56
|
+
body_parts: List[str] = [
|
|
57
|
+
f"**Key**: {ticket.key}",
|
|
58
|
+
f"**Status**: {ticket.status}",
|
|
59
|
+
]
|
|
60
|
+
if ticket.kind:
|
|
61
|
+
body_parts.append(f"**Type**: {ticket.kind}")
|
|
62
|
+
if ticket.priority:
|
|
63
|
+
body_parts.append(f"**Priority**: {ticket.priority}")
|
|
64
|
+
body_parts.append(f"**Reporter**: {ticket.reporter or '(unset)'}")
|
|
65
|
+
body_parts.append(f"**Assignee**: {ticket.assignee or '(unset)'}")
|
|
66
|
+
if ticket.labels:
|
|
67
|
+
body_parts.append(f"**Labels**: {', '.join(ticket.labels)}")
|
|
68
|
+
if transitions:
|
|
69
|
+
body_parts.append(f"**Status history**: {' → '.join(transitions)}")
|
|
70
|
+
body_parts.append("")
|
|
71
|
+
body_parts.append("### Description")
|
|
72
|
+
body_parts.append("")
|
|
73
|
+
body_parts.append(ticket.description or "_(no description)_")
|
|
74
|
+
if comments:
|
|
75
|
+
body_parts.append("")
|
|
76
|
+
body_parts.append(f"### Comments ({len(comments)})")
|
|
77
|
+
body_parts.append("")
|
|
78
|
+
for c in comments[:20]:
|
|
79
|
+
body_parts.append(f"**{c.author}** ({c.created_at}):")
|
|
80
|
+
body_parts.append(c.body)
|
|
81
|
+
body_parts.append("")
|
|
82
|
+
if len(comments) > 20:
|
|
83
|
+
body_parts.append(f"_…and {len(comments) - 20} more (older); fetch with --max-comments to see all_")
|
|
84
|
+
|
|
85
|
+
return ExtractedSection(
|
|
86
|
+
title=f"Ticket context — {ticket.key}: {ticket.title}",
|
|
87
|
+
body="\n".join(body_parts),
|
|
88
|
+
data={
|
|
89
|
+
"key": ticket.key,
|
|
90
|
+
"title": ticket.title,
|
|
91
|
+
"status": ticket.status,
|
|
92
|
+
"labels": ticket.labels,
|
|
93
|
+
"comment_count": len(comments),
|
|
94
|
+
},
|
|
95
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Output formatters (Strategy + Registry).
|
|
2
|
+
|
|
3
|
+
Adding a new format = one `Formatter` subclass + one entry in
|
|
4
|
+
`FormatterRegistry.FORMATTERS`. Nothing else changes."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
from briar.formatting.base import Formatter
|
|
11
|
+
from briar.formatting.csv import FormatCsv
|
|
12
|
+
from briar.formatting.json import FormatJson
|
|
13
|
+
from briar.formatting.quiet import FormatQuiet
|
|
14
|
+
from briar.formatting.table import FormatTable
|
|
15
|
+
from briar.formatting.yaml import FormatYaml
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FormatterRegistry:
|
|
19
|
+
"""Strategy lookup + entry points. Static methods only — there is
|
|
20
|
+
no instance state worth carrying."""
|
|
21
|
+
|
|
22
|
+
FORMATTERS: Dict[str, Formatter] = {
|
|
23
|
+
"table": FormatTable(),
|
|
24
|
+
"json": FormatJson(),
|
|
25
|
+
"yaml": FormatYaml(),
|
|
26
|
+
"csv": FormatCsv(),
|
|
27
|
+
"quiet": FormatQuiet(),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def names(cls) -> List[str]:
|
|
32
|
+
return list(cls.FORMATTERS.keys())
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def get(cls, format_name: str) -> Formatter:
|
|
36
|
+
return cls.FORMATTERS.get(format_name) or cls.FORMATTERS["table"]
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def render(
|
|
40
|
+
cls,
|
|
41
|
+
payload: Any,
|
|
42
|
+
format_name: str,
|
|
43
|
+
columns: List[str] = [],
|
|
44
|
+
) -> None:
|
|
45
|
+
cls.get(format_name).render(payload, columns)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def render_object(cls, payload: Any, format_name: str) -> None:
|
|
49
|
+
"""Single-record path: `table` falls through to JSON because a
|
|
50
|
+
one-row table is just JSON with line noise."""
|
|
51
|
+
effective = "json" if format_name == "table" else format_name
|
|
52
|
+
cls.render(payload, effective)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Module-level callables — keep the existing import surface stable.
|
|
56
|
+
FORMATTERS = FormatterRegistry.FORMATTERS
|
|
57
|
+
render = FormatterRegistry.render
|
|
58
|
+
render_object = FormatterRegistry.render_object
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"Formatter",
|
|
63
|
+
"FormatterRegistry",
|
|
64
|
+
"FORMATTERS",
|
|
65
|
+
"render",
|
|
66
|
+
"render_object",
|
|
67
|
+
]
|