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,16 @@
|
|
|
1
|
+
"""Manual trigger — no trigger row; invocation is via `briar tasks create`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
from briar.iac.scaffold.triggers.base import TriggerTemplate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TriggerManual(TriggerTemplate):
|
|
12
|
+
kind = "manual"
|
|
13
|
+
description = "No trigger row; invoke via `briar tasks create`"
|
|
14
|
+
|
|
15
|
+
def build_trigger(self, args: argparse.Namespace, key_prefix: str, workflow_key: str) -> Dict[str, Any]:
|
|
16
|
+
return {}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Cron-schedule trigger — fires the workflow on a cron expression."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
from briar.iac.scaffold.triggers.base import TriggerTemplate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TriggerScheduleCron(TriggerTemplate):
|
|
12
|
+
kind = "schedule_cron"
|
|
13
|
+
description = "Fires on a cron schedule (default: hourly)"
|
|
14
|
+
|
|
15
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--schedule",
|
|
18
|
+
default="0 * * * *",
|
|
19
|
+
help="cron expression (default: '0 * * * *' = top of every hour)",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def build_trigger(
|
|
23
|
+
self,
|
|
24
|
+
args: argparse.Namespace,
|
|
25
|
+
key_prefix: str,
|
|
26
|
+
workflow_key: str,
|
|
27
|
+
) -> Dict[str, Any]:
|
|
28
|
+
return {
|
|
29
|
+
"key": f"{key_prefix}-cron",
|
|
30
|
+
"name": f"{key_prefix}-cron",
|
|
31
|
+
"kind": "schedule",
|
|
32
|
+
"workflow_key": workflow_key,
|
|
33
|
+
"schedule_cron": args.schedule,
|
|
34
|
+
"filter_rules": {},
|
|
35
|
+
"payload_to_context_mapping": {},
|
|
36
|
+
"is_enabled": True,
|
|
37
|
+
}
|
briar/log_context.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Context-aware logging — stamps every log record in the active
|
|
2
|
+
context with the (company, task, extractor, ...) breadcrumb.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
|
|
6
|
+
from briar.log_context import log_context
|
|
7
|
+
|
|
8
|
+
with log_context(company="acme", task="prfix"):
|
|
9
|
+
log.info("starting cycle") # → "[acme/prfix] starting cycle"
|
|
10
|
+
with log_context(extractor="active-work"):
|
|
11
|
+
log.info("fetching repos") # → "[acme/prfix/active-work] fetching repos"
|
|
12
|
+
|
|
13
|
+
The implementation uses a `contextvars.ContextVar` so it is async-safe
|
|
14
|
+
and thread-safe, plus a `logging.Filter` that turns the active mapping
|
|
15
|
+
into a `[k=v k=v]` prefix on the `message`. We rewrite the message
|
|
16
|
+
instead of relying on an `extra=` slot so the prefix shows up in every
|
|
17
|
+
formatter without requiring per-format changes."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import contextvars
|
|
23
|
+
import logging
|
|
24
|
+
from typing import Any, Dict, Iterator
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_CTX: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar("briar_log_ctx", default={})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@contextlib.contextmanager
|
|
31
|
+
def log_context(**bindings: Any) -> Iterator[None]:
|
|
32
|
+
"""Push key=value pairs onto the active log context for the
|
|
33
|
+
duration of the `with` block. Nested calls extend (not replace)
|
|
34
|
+
the parent context."""
|
|
35
|
+
current = dict(_CTX.get())
|
|
36
|
+
for key, value in bindings.items():
|
|
37
|
+
current[key] = str(value)
|
|
38
|
+
token = _CTX.set(current)
|
|
39
|
+
try:
|
|
40
|
+
yield
|
|
41
|
+
finally:
|
|
42
|
+
_CTX.reset(token)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def current_context() -> Dict[str, str]:
|
|
46
|
+
"""Read-only snapshot of the active bindings. Useful for callers
|
|
47
|
+
that want to ferry breadcrumbs into structured payloads, not just
|
|
48
|
+
log lines."""
|
|
49
|
+
return dict(_CTX.get())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ContextFilter(logging.Filter):
|
|
53
|
+
"""Filter that prepends `[k1=v1 k2=v2]` to every log record's
|
|
54
|
+
message when the context has bindings. Attached to the root logger
|
|
55
|
+
by `briar.logging.configure`."""
|
|
56
|
+
|
|
57
|
+
_ORDER = ("company", "task", "extractor", "shape", "repo")
|
|
58
|
+
|
|
59
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
60
|
+
ctx = _CTX.get()
|
|
61
|
+
if not ctx:
|
|
62
|
+
return True
|
|
63
|
+
ordered: list = []
|
|
64
|
+
for key in self._ORDER:
|
|
65
|
+
value = ctx.get(key)
|
|
66
|
+
if value:
|
|
67
|
+
ordered.append(f"{key}={value}")
|
|
68
|
+
for key, value in ctx.items():
|
|
69
|
+
if key not in self._ORDER:
|
|
70
|
+
ordered.append(f"{key}={value}")
|
|
71
|
+
prefix = "[" + " ".join(ordered) + "] "
|
|
72
|
+
record.msg = prefix + str(record.msg)
|
|
73
|
+
return True
|
briar/logging.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Logging configuration — one place to set the format, level, and
|
|
2
|
+
output stream so every module can `logging.getLogger(__name__)` and
|
|
3
|
+
get consistent output across the CLI, scheduler, and dashboard.
|
|
4
|
+
|
|
5
|
+
Output shape (line-prefix):
|
|
6
|
+
2026-05-20T15:32:19Z [INFO ] briar.iac.runbook.scheduler: …
|
|
7
|
+
|
|
8
|
+
Always UTC. Always to stdout (so the existing `nohup` redirects on
|
|
9
|
+
the droplet capture into `scheduler.log` / `dashboard.log`). Errors
|
|
10
|
+
caught in broad-except sites should call `logger.exception(...)`,
|
|
11
|
+
which appends the traceback automatically."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_FORMAT = "%(asctime)s [%(levelname)-7s] %(name)s: %(message)s"
|
|
22
|
+
_DATEFMT = "%Y-%m-%dT%H:%M:%SZ"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def configure(verbose: bool = False) -> None:
|
|
26
|
+
"""Configure root logger. INFO by default; `--verbose` → DEBUG.
|
|
27
|
+
|
|
28
|
+
Force-reconfigures even if `logging.basicConfig` ran already, so
|
|
29
|
+
test imports + repeated CLI invocations stay deterministic.
|
|
30
|
+
Also attaches a `ContextFilter` so anything pushed onto
|
|
31
|
+
`briar.log_context.log_context` gets prepended to every record."""
|
|
32
|
+
from briar.log_context import ContextFilter
|
|
33
|
+
|
|
34
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
35
|
+
# `force=True` removes any pre-existing handlers so a test that
|
|
36
|
+
# imported briar before configuring doesn't double-log.
|
|
37
|
+
logging.basicConfig(
|
|
38
|
+
level=level,
|
|
39
|
+
format=_FORMAT,
|
|
40
|
+
datefmt=_DATEFMT,
|
|
41
|
+
stream=sys.stdout,
|
|
42
|
+
force=True,
|
|
43
|
+
)
|
|
44
|
+
# Always log in UTC.
|
|
45
|
+
logging.Formatter.converter = time.gmtime
|
|
46
|
+
# Attach the context filter to every existing handler. New handlers
|
|
47
|
+
# added later (e.g. by tests) will not get it automatically; that is
|
|
48
|
+
# intentional — production never adds handlers after configure().
|
|
49
|
+
context_filter = ContextFilter()
|
|
50
|
+
for handler in logging.getLogger().handlers:
|
|
51
|
+
handler.addFilter(context_filter)
|
|
52
|
+
# Quiet noisy third-party libs regardless of --verbose. The flag is
|
|
53
|
+
# meant to turn up briar's own DEBUG output, not bury us in every
|
|
54
|
+
# TLS handshake. Use BRIAR_LIB_DEBUG=1 to flip these on.
|
|
55
|
+
if not _lib_debug_enabled():
|
|
56
|
+
for noisy in ("httpx", "httpcore", "urllib3", "schedule", "boto3", "botocore"):
|
|
57
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
58
|
+
logging.captureWarnings(True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _lib_debug_enabled() -> bool:
|
|
62
|
+
return os.environ.get("BRIAR_LIB_DEBUG", "").strip().lower() in {"1", "true", "yes"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def env_verbose() -> bool:
|
|
66
|
+
"""`BRIAR_VERBOSE=1` flips DEBUG for daemonised invocations where
|
|
67
|
+
no `--verbose` flag is being passed (the dashboard / serve units)."""
|
|
68
|
+
return os.environ.get("BRIAR_VERBOSE", "").strip().lower() in {"1", "true", "yes"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Message-writer registry — Strategy + Factory.
|
|
2
|
+
|
|
3
|
+
Each writer wraps one vendor's outbound write API. Adding a new
|
|
4
|
+
vendor (Discord, Linear-comment, GitLab-MR-comment, …) = one module
|
|
5
|
+
+ one entry in the `(WriterClass, ...)` tuple here.
|
|
6
|
+
|
|
7
|
+
The factory `make_writer(kind, company, config)` is what `SendMessageTool`
|
|
8
|
+
calls at run time. Each runbook company's ``messages:`` block names
|
|
9
|
+
bindings by handle; the tool resolves handle → kind → factory."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, Tuple, Type
|
|
14
|
+
|
|
15
|
+
from briar._registry import build_registry
|
|
16
|
+
from briar.errors import CliError
|
|
17
|
+
from briar.messaging._writer import MessageBindingResolved, MessageWriter, SendResult
|
|
18
|
+
from briar.messaging.bitbucket_pr_comment import BitbucketPrCommentWriter
|
|
19
|
+
from briar.messaging.github_pr_comment import GithubPrCommentWriter
|
|
20
|
+
from briar.messaging.jira_comment import JiraCommentWriter
|
|
21
|
+
from briar.messaging.jira_transition import JiraTransitionWriter
|
|
22
|
+
from briar.messaging.slack_channel import SlackChannelWriter
|
|
23
|
+
from briar.messaging.telegram_chat import TelegramChatWriter
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
WRITERS: Dict[str, Type[MessageWriter]] = build_registry(
|
|
27
|
+
(
|
|
28
|
+
JiraCommentWriter,
|
|
29
|
+
JiraTransitionWriter,
|
|
30
|
+
SlackChannelWriter,
|
|
31
|
+
TelegramChatWriter,
|
|
32
|
+
GithubPrCommentWriter,
|
|
33
|
+
BitbucketPrCommentWriter,
|
|
34
|
+
),
|
|
35
|
+
kind="message writer",
|
|
36
|
+
name_attr="kind",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MessageWriterRegistry:
|
|
41
|
+
"""Factory + introspection."""
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def kinds(cls) -> Tuple[str, ...]:
|
|
45
|
+
return tuple(WRITERS.keys())
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def make(cls, kind: str, *, company: str = "", config: Dict[str, Any] = None) -> MessageWriter:
|
|
49
|
+
writer_cls = WRITERS.get(kind)
|
|
50
|
+
if writer_cls is None:
|
|
51
|
+
known = ", ".join(sorted(WRITERS.keys()))
|
|
52
|
+
raise CliError(f"unknown message writer {kind!r}; known: {known}")
|
|
53
|
+
return writer_cls(company=company, config=config or {})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
make_writer = MessageWriterRegistry.make
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"WRITERS",
|
|
61
|
+
"MessageWriter",
|
|
62
|
+
"MessageWriterRegistry",
|
|
63
|
+
"MessageBindingResolved",
|
|
64
|
+
"SendResult",
|
|
65
|
+
"make_writer",
|
|
66
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""`MessageWriter` — vendor-neutral write surface.
|
|
2
|
+
|
|
3
|
+
Symmetric to `TrackerProvider` / `RepositoryProvider` but for outbound
|
|
4
|
+
writes instead of reads. Each adapter wraps one vendor's write API
|
|
5
|
+
(Jira comment, Slack channel post, Telegram chat message, …).
|
|
6
|
+
|
|
7
|
+
Strategy + Registry. Adapters live in `messaging/` siblings; the
|
|
8
|
+
`WRITERS` registry is built via `build_registry()` so a duplicate
|
|
9
|
+
`kind` collision raises at import time.
|
|
10
|
+
|
|
11
|
+
Two consumers today:
|
|
12
|
+
- `briar agent` invokes writers via the `SendMessageTool` (the LLM
|
|
13
|
+
picks a configured channel by name; the tool resolves the channel
|
|
14
|
+
to a writer + calls `send`).
|
|
15
|
+
- Future scheduler hooks (deferred) — extract-success notifications,
|
|
16
|
+
status broadcasts.
|
|
17
|
+
|
|
18
|
+
Each runbook company can declare named writer bindings under
|
|
19
|
+
``messages:`` (see `iac/runbook/models.py:MessageBinding`); the
|
|
20
|
+
agent picks them up via the company-scoped config injected at run
|
|
21
|
+
time."""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, ClassVar, Dict, List
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SendResult:
|
|
36
|
+
"""Outcome of one write. `ok=False` is logged but does NOT raise —
|
|
37
|
+
same fire-and-forget semantics as `NotificationSink.send`.
|
|
38
|
+
Caller branches on `ok` for retries / chained behaviour."""
|
|
39
|
+
|
|
40
|
+
ok: bool
|
|
41
|
+
detail: str = ""
|
|
42
|
+
ref: str = "" # provider-side message id when known (Jira comment id, Slack ts, …)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MessageWriter(ABC):
|
|
46
|
+
"""Strategy contract for one vendor's outbound write."""
|
|
47
|
+
|
|
48
|
+
kind: ClassVar[str] = ""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def is_available(self) -> bool:
|
|
52
|
+
"""True iff the writer has the credentials it needs (env vars
|
|
53
|
+
+ any binding-level config) to perform a real send."""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def send(self, *, target: str, body: str, **extras: Any) -> SendResult:
|
|
57
|
+
"""Send `body` to `target`. `target` is vendor-specific:
|
|
58
|
+
|
|
59
|
+
- Jira-comment: ``PROJ-123`` (ticket key)
|
|
60
|
+
- Jira-transition: ``PROJ-123`` (ticket key) + ``extras["status"]=...``
|
|
61
|
+
- Slack-channel: the channel is per-binding config (the
|
|
62
|
+
target arg can override with a different
|
|
63
|
+
channel ID at send time)
|
|
64
|
+
- Telegram-chat: the chat is per-binding config (same)
|
|
65
|
+
- GH/BB PR comment: ``owner/repo#42`` + optional inline
|
|
66
|
+
``extras["file_path"]`` + ``extras["line"]``
|
|
67
|
+
|
|
68
|
+
Implementations should not raise on transient network errors —
|
|
69
|
+
return ``SendResult(ok=False, detail="...")`` and let the
|
|
70
|
+
caller decide on retry."""
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def required_env_vars(cls, company: str = "") -> List[str]:
|
|
74
|
+
"""Same contract as `RepositoryProvider.required_env_vars` —
|
|
75
|
+
env-var names this writer needs. Empty default (writers that
|
|
76
|
+
configure entirely via the runbook binding's `config` dict
|
|
77
|
+
don't need env vars)."""
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class MessageBindingResolved:
|
|
83
|
+
"""A runbook ``messages:`` entry post-resolution. The runbook YAML
|
|
84
|
+
holds `kind` + freeform `config`; this is the typed version that
|
|
85
|
+
the agent tool + scheduler hooks see."""
|
|
86
|
+
|
|
87
|
+
handle: str # the YAML key (e.g. "ticket_comment", "ops_chat")
|
|
88
|
+
kind: str # registered writer kind
|
|
89
|
+
company: str
|
|
90
|
+
config: Dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Bitbucket Cloud PR-comment writer.
|
|
2
|
+
|
|
3
|
+
Top-level OR inline (with `extras["file_path"]`). `target` is
|
|
4
|
+
``workspace/repo#42``. Uses the same atlassian-python-api Cloud
|
|
5
|
+
client + per-company `BITBUCKET_<COMPANY>_*` creds as
|
|
6
|
+
`BitbucketProvider`."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, List, Tuple
|
|
12
|
+
|
|
13
|
+
from briar.decorators import swallow_errors
|
|
14
|
+
from briar.env_vars import CredEnv
|
|
15
|
+
from briar.messaging._writer import MessageWriter, SendResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BitbucketPrCommentWriter(MessageWriter):
|
|
22
|
+
kind = "bitbucket-pr-comment"
|
|
23
|
+
BASE = "https://api.bitbucket.org/"
|
|
24
|
+
|
|
25
|
+
def __init__(self, *, company: str = "", config: Dict[str, Any] = None) -> None:
|
|
26
|
+
self._company = company
|
|
27
|
+
self._config = config or {}
|
|
28
|
+
self._username = CredEnv.BITBUCKET_USERNAME.read(company=company) if company else ""
|
|
29
|
+
self._app_password = CredEnv.BITBUCKET_APP_PASSWORD.read(company=company) if company else ""
|
|
30
|
+
self._workspace_slug = CredEnv.BITBUCKET_WORKSPACE.read(company=company) if company else ""
|
|
31
|
+
self._client = None
|
|
32
|
+
|
|
33
|
+
def _cloud(self):
|
|
34
|
+
if self._client is None:
|
|
35
|
+
from atlassian.bitbucket.cloud import Cloud
|
|
36
|
+
|
|
37
|
+
if self._username == "x-token-auth":
|
|
38
|
+
self._client = Cloud(url=self.BASE, token=self._app_password)
|
|
39
|
+
else:
|
|
40
|
+
self._client = Cloud(url=self.BASE, username=self._username, password=self._app_password)
|
|
41
|
+
return self._client
|
|
42
|
+
|
|
43
|
+
def is_available(self) -> bool:
|
|
44
|
+
return bool(self._username and self._app_password and self._workspace_slug)
|
|
45
|
+
|
|
46
|
+
@swallow_errors(default=SendResult(ok=False, detail="exception"), message="bitbucket-pr-comment send")
|
|
47
|
+
def send(self, *, target: str, body: str, **extras: Any) -> SendResult:
|
|
48
|
+
repo_addr, number = self._parse_target(target, extras)
|
|
49
|
+
if not repo_addr or not number:
|
|
50
|
+
return SendResult(ok=False, detail=f"bitbucket-pr-comment requires target=workspace/repo#N; got {target!r}")
|
|
51
|
+
workspace_slug, _, repo_slug = repo_addr.partition("/")
|
|
52
|
+
if not workspace_slug or not repo_slug:
|
|
53
|
+
workspace_slug = self._workspace_slug
|
|
54
|
+
repo_slug = repo_addr
|
|
55
|
+
bb_repo = self._cloud().workspaces.get(workspace_slug).repositories.get(repo_slug)
|
|
56
|
+
|
|
57
|
+
# PR comment payload — inline if file_path + line are set
|
|
58
|
+
payload: Dict[str, Any] = {"content": {"raw": body}}
|
|
59
|
+
file_path = extras.get("file_path")
|
|
60
|
+
if file_path:
|
|
61
|
+
payload["inline"] = {
|
|
62
|
+
"path": file_path,
|
|
63
|
+
"to": int(extras.get("line") or 1),
|
|
64
|
+
}
|
|
65
|
+
resp = bb_repo.post(f"pullrequests/{number}/comments", data=payload)
|
|
66
|
+
comment_id = str((resp or {}).get("id") or "") if isinstance(resp, dict) else ""
|
|
67
|
+
return SendResult(ok=True, ref=comment_id)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _parse_target(target: str, extras: Dict[str, Any]) -> Tuple[str, int]:
|
|
71
|
+
if "#" in target:
|
|
72
|
+
addr, _, n = target.rpartition("#")
|
|
73
|
+
try:
|
|
74
|
+
return addr, int(n)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return "", 0
|
|
77
|
+
n_extras = extras.get("pr")
|
|
78
|
+
if target and n_extras is not None:
|
|
79
|
+
try:
|
|
80
|
+
return target, int(n_extras)
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
return "", 0
|
|
83
|
+
return "", 0
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def required_env_vars(cls, company: str = "") -> List[str]:
|
|
87
|
+
if not company:
|
|
88
|
+
return []
|
|
89
|
+
return [
|
|
90
|
+
CredEnv.BITBUCKET_USERNAME.for_company(company),
|
|
91
|
+
CredEnv.BITBUCKET_APP_PASSWORD.for_company(company),
|
|
92
|
+
CredEnv.BITBUCKET_WORKSPACE.for_company(company),
|
|
93
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""GitHub PR-comment writer.
|
|
2
|
+
|
|
3
|
+
Posts either:
|
|
4
|
+
- a TOP-LEVEL issue/PR comment (default), or
|
|
5
|
+
- an INLINE review-thread reply when ``extras["file_path"]`` is set.
|
|
6
|
+
|
|
7
|
+
``target`` is ``owner/repo#42`` (the #42 is the PR number). The PR
|
|
8
|
+
number can also be passed as ``extras["pr"]`` if the target is a
|
|
9
|
+
bare repo slug.
|
|
10
|
+
|
|
11
|
+
Backed by the same `GithubApi` PyGithub facade the reads use."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any, Dict, List, Tuple
|
|
17
|
+
|
|
18
|
+
from briar.decorators import swallow_errors
|
|
19
|
+
from briar.extract._gh import GithubApi
|
|
20
|
+
from briar.messaging._writer import MessageWriter, SendResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GithubPrCommentWriter(MessageWriter):
|
|
27
|
+
kind = "github-pr-comment"
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, company: str = "", config: Dict[str, Any] = None) -> None:
|
|
30
|
+
# GITHUB_TOKEN is workspace-wide; company is inert.
|
|
31
|
+
self._company = company
|
|
32
|
+
self._config = config or {}
|
|
33
|
+
|
|
34
|
+
def is_available(self) -> bool:
|
|
35
|
+
return bool(GithubApi.auth_token())
|
|
36
|
+
|
|
37
|
+
@swallow_errors(default=SendResult(ok=False, detail="exception"), message="github-pr-comment send")
|
|
38
|
+
def send(self, *, target: str, body: str, **extras: Any) -> SendResult:
|
|
39
|
+
repo, number = self._parse_target(target, extras)
|
|
40
|
+
if not repo or not number:
|
|
41
|
+
return SendResult(ok=False, detail=f"github-pr-comment requires target=owner/repo#N; got {target!r}")
|
|
42
|
+
|
|
43
|
+
file_path = extras.get("file_path")
|
|
44
|
+
if file_path:
|
|
45
|
+
# Inline review-thread reply requires the PR's head SHA +
|
|
46
|
+
# a file/line anchor. The replies endpoint uses commit_id.
|
|
47
|
+
pr = GithubApi.get_json(f"/repos/{repo}/pulls/{number}")
|
|
48
|
+
commit_id = ((pr or {}).get("head") or {}).get("sha") or ""
|
|
49
|
+
if not commit_id:
|
|
50
|
+
return SendResult(ok=False, detail="github-pr-comment inline: could not resolve head SHA")
|
|
51
|
+
line = int(extras.get("line") or 0)
|
|
52
|
+
payload = {
|
|
53
|
+
"body": body,
|
|
54
|
+
"commit_id": commit_id,
|
|
55
|
+
"path": file_path,
|
|
56
|
+
"line": line or 1,
|
|
57
|
+
"side": extras.get("side", "RIGHT"),
|
|
58
|
+
}
|
|
59
|
+
client = GithubApi.client()
|
|
60
|
+
headers, resp = client._Github__requester.requestJsonAndCheck("POST", f"/repos/{repo}/pulls/{number}/comments", input=payload)
|
|
61
|
+
comment_id = str((resp or {}).get("id") or "") if isinstance(resp, dict) else ""
|
|
62
|
+
return SendResult(ok=True, ref=comment_id)
|
|
63
|
+
|
|
64
|
+
# Top-level issue comment endpoint (the same one GitHub uses
|
|
65
|
+
# for non-inline PR comments).
|
|
66
|
+
client = GithubApi.client()
|
|
67
|
+
headers, resp = client._Github__requester.requestJsonAndCheck("POST", f"/repos/{repo}/issues/{number}/comments", input={"body": body})
|
|
68
|
+
comment_id = str((resp or {}).get("id") or "") if isinstance(resp, dict) else ""
|
|
69
|
+
return SendResult(ok=True, ref=comment_id)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _parse_target(target: str, extras: Dict[str, Any]) -> Tuple[str, int]:
|
|
73
|
+
"""Accept `owner/repo#42` OR bare `owner/repo` + `extras["pr"]`."""
|
|
74
|
+
if "#" in target:
|
|
75
|
+
repo, _, n = target.rpartition("#")
|
|
76
|
+
try:
|
|
77
|
+
return repo, int(n)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return "", 0
|
|
80
|
+
n_extras = extras.get("pr")
|
|
81
|
+
if target and n_extras is not None:
|
|
82
|
+
try:
|
|
83
|
+
return target, int(n_extras)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return "", 0
|
|
86
|
+
return "", 0
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def required_env_vars(cls, company: str = "") -> List[str]:
|
|
90
|
+
return ["GITHUB_TOKEN"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Jira ticket-comment writer.
|
|
2
|
+
|
|
3
|
+
Adds a comment to a Jira ticket. Backed by the same
|
|
4
|
+
``atlassian-python-api`` Jira client that `JiraTracker` uses for
|
|
5
|
+
reads, so creds are the per-company JIRA_<COMPANY>_{URL,EMAIL,TOKEN}
|
|
6
|
+
already documented in `CredEnv`."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
|
|
13
|
+
from briar.decorators import swallow_errors
|
|
14
|
+
from briar.env_vars import CredEnv
|
|
15
|
+
from briar.messaging._writer import MessageWriter, SendResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JiraCommentWriter(MessageWriter):
|
|
22
|
+
kind = "jira-comment"
|
|
23
|
+
|
|
24
|
+
def __init__(self, *, company: str = "", config: Dict[str, Any] = None) -> None:
|
|
25
|
+
self._company = company
|
|
26
|
+
self._config = config or {}
|
|
27
|
+
self._url = CredEnv.JIRA_URL.read(company=company) if company else ""
|
|
28
|
+
self._email = CredEnv.JIRA_EMAIL.read(company=company) if company else ""
|
|
29
|
+
self._token = CredEnv.JIRA_TOKEN.read(company=company) if company else ""
|
|
30
|
+
self._client = None
|
|
31
|
+
|
|
32
|
+
def _jira(self):
|
|
33
|
+
if self._client is None:
|
|
34
|
+
from atlassian import Jira
|
|
35
|
+
|
|
36
|
+
self._client = Jira(url=self._url, username=self._email, password=self._token, cloud=True)
|
|
37
|
+
return self._client
|
|
38
|
+
|
|
39
|
+
def is_available(self) -> bool:
|
|
40
|
+
return bool(self._url and self._email and self._token)
|
|
41
|
+
|
|
42
|
+
@swallow_errors(default=SendResult(ok=False, detail="exception"), message="jira-comment send")
|
|
43
|
+
def send(self, *, target: str, body: str, **extras: Any) -> SendResult:
|
|
44
|
+
if not self.is_available():
|
|
45
|
+
return SendResult(ok=False, detail="jira creds missing")
|
|
46
|
+
if not target:
|
|
47
|
+
return SendResult(ok=False, detail="jira-comment requires target=<TICKET-KEY>")
|
|
48
|
+
# atlassian-python-api: client.issue_add_comment(issue_key, comment)
|
|
49
|
+
resp = self._jira().issue_add_comment(target, body)
|
|
50
|
+
if not isinstance(resp, dict):
|
|
51
|
+
return SendResult(ok=False, detail=f"jira returned non-dict: {resp!r}")
|
|
52
|
+
comment_id = str(resp.get("id") or "")
|
|
53
|
+
return SendResult(ok=True, ref=comment_id)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def required_env_vars(cls, company: str = "") -> List[str]:
|
|
57
|
+
if not company:
|
|
58
|
+
return []
|
|
59
|
+
return [
|
|
60
|
+
CredEnv.JIRA_URL.for_company(company),
|
|
61
|
+
CredEnv.JIRA_EMAIL.for_company(company),
|
|
62
|
+
CredEnv.JIRA_TOKEN.for_company(company),
|
|
63
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Jira ticket-transition writer.
|
|
2
|
+
|
|
3
|
+
Transitions a Jira ticket to a target status. The status name comes
|
|
4
|
+
either from ``extras["status"]`` at send time or from the binding's
|
|
5
|
+
``config: {status: "Done"}`` default.
|
|
6
|
+
|
|
7
|
+
`body` is recorded as an optional resolution comment (Jira's
|
|
8
|
+
transitions can carry a comment field)."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
from briar.decorators import swallow_errors
|
|
16
|
+
from briar.env_vars import CredEnv
|
|
17
|
+
from briar.messaging._writer import MessageWriter, SendResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JiraTransitionWriter(MessageWriter):
|
|
24
|
+
kind = "jira-transition"
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, company: str = "", config: Dict[str, Any] = None) -> None:
|
|
27
|
+
self._company = company
|
|
28
|
+
self._config = config or {}
|
|
29
|
+
self._default_status = str(self._config.get("status", ""))
|
|
30
|
+
self._url = CredEnv.JIRA_URL.read(company=company) if company else ""
|
|
31
|
+
self._email = CredEnv.JIRA_EMAIL.read(company=company) if company else ""
|
|
32
|
+
self._token = CredEnv.JIRA_TOKEN.read(company=company) if company else ""
|
|
33
|
+
self._client = None
|
|
34
|
+
|
|
35
|
+
def _jira(self):
|
|
36
|
+
if self._client is None:
|
|
37
|
+
from atlassian import Jira
|
|
38
|
+
|
|
39
|
+
self._client = Jira(url=self._url, username=self._email, password=self._token, cloud=True)
|
|
40
|
+
return self._client
|
|
41
|
+
|
|
42
|
+
def is_available(self) -> bool:
|
|
43
|
+
return bool(self._url and self._email and self._token)
|
|
44
|
+
|
|
45
|
+
@swallow_errors(default=SendResult(ok=False, detail="exception"), message="jira-transition send")
|
|
46
|
+
def send(self, *, target: str, body: str, **extras: Any) -> SendResult:
|
|
47
|
+
if not self.is_available():
|
|
48
|
+
return SendResult(ok=False, detail="jira creds missing")
|
|
49
|
+
if not target:
|
|
50
|
+
return SendResult(ok=False, detail="jira-transition requires target=<TICKET-KEY>")
|
|
51
|
+
status = extras.get("status") or self._default_status
|
|
52
|
+
if not status:
|
|
53
|
+
return SendResult(ok=False, detail="jira-transition requires extras.status or binding config.status")
|
|
54
|
+
# The library exposes `set_issue_status` which wraps the
|
|
55
|
+
# /transitions endpoint + the transition-id resolution.
|
|
56
|
+
# `body` is appended as a comment via `comment=` when supplied.
|
|
57
|
+
result = self._jira().set_issue_status(target, status, comment=body or None)
|
|
58
|
+
if result is None:
|
|
59
|
+
return SendResult(ok=True, ref=f"{target}→{status}")
|
|
60
|
+
# Atlassian's response shape varies — treat any non-None as ok.
|
|
61
|
+
return SendResult(ok=True, ref=str(result)[:200])
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def required_env_vars(cls, company: str = "") -> List[str]:
|
|
65
|
+
if not company:
|
|
66
|
+
return []
|
|
67
|
+
return [
|
|
68
|
+
CredEnv.JIRA_URL.for_company(company),
|
|
69
|
+
CredEnv.JIRA_EMAIL.for_company(company),
|
|
70
|
+
CredEnv.JIRA_TOKEN.for_company(company),
|
|
71
|
+
]
|