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,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
+ ]