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.
Files changed (179) hide show
  1. briar/__init__.py +8 -0
  2. briar/__main__.py +11 -0
  3. briar/_registry.py +36 -0
  4. briar/agent/__init__.py +16 -0
  5. briar/agent/_llm.py +90 -0
  6. briar/agent/_llms/__init__.py +40 -0
  7. briar/agent/_llms/anthropic_llm.py +185 -0
  8. briar/agent/_llms/bedrock.py +154 -0
  9. briar/agent/_llms/gemini.py +152 -0
  10. briar/agent/_llms/openai_llm.py +129 -0
  11. briar/agent/runner.py +364 -0
  12. briar/agent/tools.py +355 -0
  13. briar/auth/__init__.py +42 -0
  14. briar/auth/_acquirer.py +121 -0
  15. briar/auth/_acquirers/__init__.py +62 -0
  16. briar/auth/_acquirers/aws_sso.py +185 -0
  17. briar/auth/_acquirers/aws_static.py +54 -0
  18. briar/auth/_acquirers/bitbucket.py +60 -0
  19. briar/auth/_acquirers/github_device.py +129 -0
  20. briar/auth/_acquirers/github_pat.py +44 -0
  21. briar/auth/_acquirers/infisical.py +80 -0
  22. briar/auth/_acquirers/jira_session.py +102 -0
  23. briar/auth/_acquirers/jira_token.py +61 -0
  24. briar/auth/_acquirers/linear.py +46 -0
  25. briar/auth/_prompt.py +155 -0
  26. briar/cli.py +149 -0
  27. briar/commands/__init__.py +48 -0
  28. briar/commands/agent.py +692 -0
  29. briar/commands/auth.py +281 -0
  30. briar/commands/base.py +46 -0
  31. briar/commands/context.py +143 -0
  32. briar/commands/dashboard.py +61 -0
  33. briar/commands/extract.py +79 -0
  34. briar/commands/iac.py +44 -0
  35. briar/commands/runbook.py +110 -0
  36. briar/commands/secrets.py +165 -0
  37. briar/commands/version.py +17 -0
  38. briar/credentials/__init__.py +48 -0
  39. briar/credentials/_bootstrap.py +93 -0
  40. briar/credentials/_bootstraps/__init__.py +80 -0
  41. briar/credentials/_bootstraps/infisical.py +115 -0
  42. briar/credentials/_store.py +63 -0
  43. briar/credentials/aws_secrets.py +102 -0
  44. briar/credentials/envfile.py +171 -0
  45. briar/credentials/infisical.py +183 -0
  46. briar/credentials/ssm.py +83 -0
  47. briar/credentials/vault.py +109 -0
  48. briar/dashboard/__init__.py +13 -0
  49. briar/dashboard/collectors.py +1354 -0
  50. briar/dashboard/server.py +154 -0
  51. briar/dashboard/templates/index.html +678 -0
  52. briar/decorators.py +48 -0
  53. briar/env_vars.py +92 -0
  54. briar/error_policy.py +273 -0
  55. briar/errors.py +48 -0
  56. briar/extract/__init__.py +52 -0
  57. briar/extract/_cloud.py +157 -0
  58. briar/extract/_clouds/__init__.py +39 -0
  59. briar/extract/_clouds/aws.py +190 -0
  60. briar/extract/_clouds/azure.py +152 -0
  61. briar/extract/_clouds/gcp.py +135 -0
  62. briar/extract/_gh.py +159 -0
  63. briar/extract/_provider.py +218 -0
  64. briar/extract/_providers/__init__.py +60 -0
  65. briar/extract/_providers/bitbucket.py +276 -0
  66. briar/extract/_providers/github.py +277 -0
  67. briar/extract/_tracker.py +124 -0
  68. briar/extract/_trackers/__init__.py +44 -0
  69. briar/extract/_trackers/_jira_auth.py +258 -0
  70. briar/extract/_trackers/bitbucket.py +131 -0
  71. briar/extract/_trackers/github_issues.py +139 -0
  72. briar/extract/_trackers/jira.py +191 -0
  73. briar/extract/_trackers/linear.py +172 -0
  74. briar/extract/_user_filter.py +150 -0
  75. briar/extract/active_tickets.py +71 -0
  76. briar/extract/active_work.py +87 -0
  77. briar/extract/aws_infra.py +79 -0
  78. briar/extract/aws_services/__init__.py +31 -0
  79. briar/extract/aws_services/base.py +25 -0
  80. briar/extract/aws_services/ecs.py +43 -0
  81. briar/extract/aws_services/lambda_.py +38 -0
  82. briar/extract/aws_services/logs.py +39 -0
  83. briar/extract/aws_services/rds.py +35 -0
  84. briar/extract/aws_services/sqs.py +25 -0
  85. briar/extract/base.py +290 -0
  86. briar/extract/code_hotspots.py +134 -0
  87. briar/extract/codebase_conventions.py +72 -0
  88. briar/extract/composer.py +85 -0
  89. briar/extract/github_deployments.py +106 -0
  90. briar/extract/language_detectors/__init__.py +24 -0
  91. briar/extract/language_detectors/base.py +29 -0
  92. briar/extract/language_detectors/go.py +26 -0
  93. briar/extract/language_detectors/node.py +42 -0
  94. briar/extract/language_detectors/python.py +41 -0
  95. briar/extract/pr_archaeology.py +131 -0
  96. briar/extract/pr_review_context.py +123 -0
  97. briar/extract/reviewer_profile.py +141 -0
  98. briar/extract/ticket_archaeology.py +115 -0
  99. briar/extract/ticket_context.py +95 -0
  100. briar/formatting/__init__.py +67 -0
  101. briar/formatting/base.py +25 -0
  102. briar/formatting/csv.py +34 -0
  103. briar/formatting/json.py +19 -0
  104. briar/formatting/quiet.py +29 -0
  105. briar/formatting/table.py +97 -0
  106. briar/formatting/yaml.py +35 -0
  107. briar/iac/__init__.py +18 -0
  108. briar/iac/config_file.py +114 -0
  109. briar/iac/models.py +232 -0
  110. briar/iac/reference_map.py +33 -0
  111. briar/iac/runbook/__init__.py +32 -0
  112. briar/iac/runbook/executor.py +365 -0
  113. briar/iac/runbook/models.py +156 -0
  114. briar/iac/runbook/scheduler.py +187 -0
  115. briar/iac/scaffold/__init__.py +25 -0
  116. briar/iac/scaffold/_composer.py +308 -0
  117. briar/iac/scaffold/_knowledge.py +119 -0
  118. briar/iac/scaffold/archetypes/__init__.py +26 -0
  119. briar/iac/scaffold/archetypes/base.py +85 -0
  120. briar/iac/scaffold/archetypes/engineer.py +64 -0
  121. briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
  122. briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
  123. briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
  124. briar/iac/scaffold/archetypes/triager.py +50 -0
  125. briar/iac/scaffold/base.py +19 -0
  126. briar/iac/scaffold/implementation.py +59 -0
  127. briar/iac/scaffold/pr_fixes.py +52 -0
  128. briar/iac/scaffold/rules/__init__.py +64 -0
  129. briar/iac/scaffold/rules/base.py +121 -0
  130. briar/iac/scaffold/rules/commit_as_human.md +22 -0
  131. briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
  132. briar/iac/scaffold/rules/no_force_push.md +19 -0
  133. briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
  134. briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
  135. briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
  136. briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
  137. briar/iac/scaffold/shapes/__init__.py +38 -0
  138. briar/iac/scaffold/shapes/base.py +17 -0
  139. briar/iac/scaffold/shapes/one_shot.py +48 -0
  140. briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
  141. briar/iac/scaffold/shapes/triage.py +49 -0
  142. briar/iac/scaffold/sources/__init__.py +26 -0
  143. briar/iac/scaffold/sources/aws.py +69 -0
  144. briar/iac/scaffold/sources/base.py +61 -0
  145. briar/iac/scaffold/sources/bitbucket.py +164 -0
  146. briar/iac/scaffold/sources/github.py +155 -0
  147. briar/iac/scaffold/sources/jira.py +147 -0
  148. briar/iac/scaffold/triggers/__init__.py +23 -0
  149. briar/iac/scaffold/triggers/base.py +24 -0
  150. briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
  151. briar/iac/scaffold/triggers/github_webhook.py +52 -0
  152. briar/iac/scaffold/triggers/manual.py +16 -0
  153. briar/iac/scaffold/triggers/schedule_cron.py +37 -0
  154. briar/log_context.py +73 -0
  155. briar/logging.py +68 -0
  156. briar/messaging/__init__.py +66 -0
  157. briar/messaging/_writer.py +90 -0
  158. briar/messaging/bitbucket_pr_comment.py +93 -0
  159. briar/messaging/github_pr_comment.py +90 -0
  160. briar/messaging/jira_comment.py +63 -0
  161. briar/messaging/jira_transition.py +71 -0
  162. briar/messaging/slack_channel.py +73 -0
  163. briar/messaging/telegram_chat.py +68 -0
  164. briar/notify/__init__.py +44 -0
  165. briar/notify/_sink.py +27 -0
  166. briar/notify/email.py +59 -0
  167. briar/notify/pagerduty.py +67 -0
  168. briar/notify/slack.py +49 -0
  169. briar/notify/telegram.py +48 -0
  170. briar/pagination.py +37 -0
  171. briar/settings.py +3 -0
  172. briar/storage/__init__.py +73 -0
  173. briar/storage/base.py +137 -0
  174. briar/storage/file.py +111 -0
  175. briar/storage/postgres.py +353 -0
  176. briar_cli-1.1.1.dist-info/METADATA +1031 -0
  177. briar_cli-1.1.1.dist-info/RECORD +179 -0
  178. briar_cli-1.1.1.dist-info/WHEEL +4 -0
  179. 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
+ ]