ai-cr 1.0.1__py3-none-any.whl → 2.0.0__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.
@@ -0,0 +1,57 @@
1
+ import logging
2
+ import os
3
+
4
+ import git
5
+ from jira import JIRA
6
+
7
+ from gito.issue_trackers import IssueTrackerIssue, resolve_issue_key
8
+
9
+
10
+ def fetch_issue(issue_key, jira_url, username, api_token) -> IssueTrackerIssue | None:
11
+ try:
12
+ jira = JIRA(jira_url, basic_auth=(username, api_token))
13
+ issue = jira.issue(issue_key)
14
+ return IssueTrackerIssue(
15
+ title=issue.fields.summary,
16
+ description=issue.fields.description or "",
17
+ url=f"{jira_url.rstrip('/')}/browse/{issue_key}"
18
+ )
19
+ except Exception as e:
20
+ logging.error(f"Failed to fetch Jira issue {issue_key}: {e}")
21
+ return None
22
+
23
+
24
+ def fetch_associated_issue(
25
+ repo: git.Repo,
26
+ jira_url=None,
27
+ jira_username=None,
28
+ jira_api_token=None,
29
+ **kwargs
30
+ ):
31
+ """
32
+ Pipeline step to fetch a Jira issue based on the current branch name.
33
+ """
34
+ jira_url = jira_url or os.getenv("JIRA_URL")
35
+ jira_username = (
36
+ jira_username
37
+ or os.getenv("JIRA_USERNAME")
38
+ or os.getenv("JIRA_USER")
39
+ or os.getenv("JIRA_EMAIL")
40
+ )
41
+ jira_token = (
42
+ jira_api_token
43
+ or os.getenv("JIRA_API_TOKEN")
44
+ or os.getenv("JIRA_API_KEY")
45
+ or os.getenv("JIRA_TOKEN")
46
+ )
47
+ try:
48
+ assert jira_url, "JIRA_URL is not set"
49
+ assert jira_username, "JIRA_USERNAME is not set"
50
+ assert jira_token, "JIRA_API_TOKEN is not set"
51
+ except AssertionError as e:
52
+ logging.error(f"Jira configuration error: {e}")
53
+ return None
54
+ issue_key = resolve_issue_key(repo)
55
+ return dict(
56
+ associated_issue=fetch_issue(issue_key, jira_url, jira_username, jira_token)
57
+ ) if issue_key else None
@@ -0,0 +1,84 @@
1
+ import logging
2
+ import os
3
+ import requests
4
+
5
+ import git
6
+
7
+ from gito.issue_trackers import IssueTrackerIssue, resolve_issue_key
8
+
9
+
10
+ def fetch_issue(issue_key, api_key) -> IssueTrackerIssue | None:
11
+ """
12
+ Fetch a Linear issue using GraphQL API.
13
+ """
14
+ try:
15
+ url = "https://api.linear.app/graphql"
16
+ headers = {
17
+ "Authorization": f"{api_key}",
18
+ "Content-Type": "application/json"
19
+ }
20
+
21
+ query = """
22
+ query Issues($teamKey: String!, $issueNumber: Float) {
23
+ issues(filter: {team: {key: {eq: $teamKey}}, number: {eq: $issueNumber}}) {
24
+ nodes {
25
+ id
26
+ identifier
27
+ title
28
+ description
29
+ url
30
+ }
31
+ }
32
+ }
33
+ """
34
+ team_key, issue_number = issue_key.split("-")
35
+ response = requests.post(
36
+ url,
37
+ json={
38
+ "query": query,
39
+ "variables": {'teamKey': team_key, 'issueNumber': int(issue_number)}
40
+ },
41
+ headers=headers
42
+ )
43
+ response.raise_for_status()
44
+ data = response.json()
45
+
46
+ if "errors" in data:
47
+ logging.error(f"Linear API error: {data['errors']}")
48
+ return None
49
+
50
+ nodes = data.get("data", {}).get("issues", {}).get("nodes", [])
51
+ if not nodes:
52
+ logging.error(f"Linear issue {issue_key} not found")
53
+ return None
54
+
55
+ issue = nodes[0]
56
+ return IssueTrackerIssue(
57
+ title=issue["title"],
58
+ description=issue.get("description") or "",
59
+ url=issue["url"]
60
+ )
61
+
62
+ except requests.HTTPError as e:
63
+ logging.error(f"Failed to fetch Linear issue {issue_key}: {e}")
64
+ logging.error(f"Response body: {response.text}")
65
+ return None
66
+
67
+
68
+ def fetch_associated_issue(
69
+ repo: git.Repo,
70
+ api_key=None,
71
+ **kwargs
72
+ ):
73
+ """
74
+ Pipeline step to fetch a Linear issue based on the current branch name.
75
+ """
76
+ api_key = api_key or os.getenv("LINEAR_API_KEY")
77
+ if not api_key:
78
+ logging.error("LINEAR_API_KEY environment variable is not set")
79
+ return
80
+
81
+ issue_key = resolve_issue_key(repo)
82
+ return dict(
83
+ associated_issue=fetch_issue(issue_key, api_key)
84
+ ) if issue_key else None
gito/project_config.py ADDED
@@ -0,0 +1,73 @@
1
+ import logging
2
+ import tomllib
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import microcore as mc
7
+ from gito.utils import detect_github_env
8
+ from microcore import ui
9
+ from git import Repo
10
+
11
+ from .constants import PROJECT_CONFIG_BUNDLED_DEFAULTS_FILE, PROJECT_CONFIG_FILE_PATH
12
+ from .pipeline import PipelineStep
13
+
14
+
15
+ @dataclass
16
+ class ProjectConfig:
17
+ prompt: str = ""
18
+ summary_prompt: str = ""
19
+ answer_prompt: str = ""
20
+ report_template_md: str = ""
21
+ """Markdown report template"""
22
+ report_template_cli: str = ""
23
+ """Report template for CLI output"""
24
+ post_process: str = ""
25
+ retries: int = 3
26
+ """LLM retries for one request"""
27
+ max_code_tokens: int = 32000
28
+ prompt_vars: dict = field(default_factory=dict)
29
+ mention_triggers: list[str] = field(default_factory=list)
30
+ answer_github_comments: bool = field(default=True)
31
+ """
32
+ Defines the keyword or mention tag that triggers bot actions
33
+ when referenced in code review comments.
34
+ """
35
+ pipeline_steps: dict[str, dict | PipelineStep] = field(default_factory=dict)
36
+
37
+ def __post_init__(self):
38
+ self.pipeline_steps = {
39
+ k: PipelineStep(**v) if isinstance(v, dict) else v
40
+ for k, v in self.pipeline_steps.items()
41
+ }
42
+
43
+ @staticmethod
44
+ def _read_bundled_defaults() -> dict:
45
+ with open(PROJECT_CONFIG_BUNDLED_DEFAULTS_FILE, "rb") as f:
46
+ config = tomllib.load(f)
47
+ return config
48
+
49
+ @staticmethod
50
+ def load_for_repo(repo: Repo):
51
+ return ProjectConfig.load(Path(repo.working_tree_dir) / PROJECT_CONFIG_FILE_PATH)
52
+
53
+ @staticmethod
54
+ def load(config_path: str | Path | None = None) -> "ProjectConfig":
55
+ config = ProjectConfig._read_bundled_defaults()
56
+ github_env = detect_github_env()
57
+ config["prompt_vars"] |= github_env | dict(github_env=github_env)
58
+
59
+ config_path = Path(config_path or PROJECT_CONFIG_FILE_PATH)
60
+ if config_path.exists():
61
+ logging.info(
62
+ f"Loading project-specific configuration from {mc.utils.file_link(config_path)}...")
63
+ default_prompt_vars = config["prompt_vars"]
64
+ with open(config_path, "rb") as f:
65
+ config.update(tomllib.load(f))
66
+ # overriding prompt_vars config section will not empty default values
67
+ config["prompt_vars"] = default_prompt_vars | config["prompt_vars"]
68
+ else:
69
+ logging.info(
70
+ f"No project config found at {ui.blue(config_path)}, using defaults"
71
+ )
72
+
73
+ return ProjectConfig(**config)
@@ -1,108 +1,134 @@
1
- import json
2
- import logging
3
- from dataclasses import dataclass, field, asdict
4
- from datetime import datetime
5
- from enum import StrEnum
6
-
7
- import microcore as mc
8
-
9
- from .constants import JSON_REPORT_FILE_NAME
10
- from .project_config import ProjectConfig
11
- from .utils import syntax_hint
12
-
13
-
14
- @dataclass
15
- class Issue:
16
- @dataclass
17
- class AffectedCode:
18
- start_line: int = field()
19
- end_line: int | None = field(default=None)
20
- file: str = field(default="")
21
- proposal: str = field(default="")
22
- affected_code: str = field(default="")
23
-
24
- @property
25
- def syntax_hint(self) -> str:
26
- return syntax_hint(self.file)
27
-
28
- id: str = field()
29
- title: str = field()
30
- details: str = field(default="")
31
- severity: int | None = field(default=None)
32
- confidence: int | None = field(default=None)
33
- tags: list[str] = field(default_factory=list)
34
- file: str = field(default="")
35
- affected_lines: list[AffectedCode] = field(default_factory=list)
36
-
37
- def __post_init__(self):
38
- self.affected_lines = [
39
- Issue.AffectedCode(**dict(file=self.file) | i)
40
- for i in self.affected_lines
41
- ]
42
-
43
- def github_code_link(self, github_env: dict) -> str:
44
- url = (
45
- f"https://github.com/{github_env['github_repo']}"
46
- f"/blob/{github_env['github_pr_sha_or_branch']}"
47
- f"/{self.file}"
48
- )
49
- if self.affected_lines:
50
- url += f"#L{self.affected_lines[0].start_line}"
51
- if self.affected_lines[0].end_line:
52
- url += f"-L{self.affected_lines[0].end_line}"
53
- return url
54
-
55
-
56
- @dataclass
57
- class Report:
58
- class Format(StrEnum):
59
- MARKDOWN = "md"
60
-
61
- issues: dict = field(default_factory=dict)
62
- summary: str = field(default="")
63
- number_of_processed_files: int = field(default=0)
64
- total_issues: int = field(init=False)
65
- created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
66
- model: str = field(default_factory=lambda: mc.config().MODEL)
67
-
68
- @property
69
- def plain_issues(self):
70
- return [
71
- issue
72
- for file, issues in self.issues.items()
73
- for issue in issues
74
- ]
75
-
76
- def __post_init__(self):
77
- issue_id: int = 0
78
- for file in self.issues.keys():
79
- self.issues[file] = [
80
- Issue(
81
- **{
82
- "id": (issue_id := issue_id + 1),
83
- "file": file,
84
- } | issue
85
- )
86
- for issue in self.issues[file]
87
- ]
88
- self.total_issues = issue_id
89
-
90
- def save(self, file_name: str = ""):
91
- file_name = file_name or JSON_REPORT_FILE_NAME
92
- with open(file_name, "w") as f:
93
- json.dump(asdict(self), f, indent=4)
94
- logging.info(f"Report saved to {mc.utils.file_link(file_name)}")
95
-
96
- @staticmethod
97
- def load(file_name: str = ""):
98
- with open(file_name or JSON_REPORT_FILE_NAME, "r") as f:
99
- data = json.load(f)
100
- data.pop("total_issues", None)
101
- return Report(**data)
102
-
103
- def render(
104
- self, cfg: ProjectConfig = None, format: Format = Format.MARKDOWN
105
- ) -> str:
106
- cfg = cfg or ProjectConfig.load()
107
- template = getattr(cfg, f"report_template_{format}")
108
- return mc.prompt(template, report=self, **cfg.prompt_vars)
1
+ import json
2
+ import logging
3
+ from dataclasses import dataclass, field, asdict
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+
8
+ import microcore as mc
9
+ from colorama import Fore, Style, Back
10
+ from microcore.utils import file_link
11
+ import textwrap
12
+
13
+ from .constants import JSON_REPORT_FILE_NAME, HTML_TEXT_ICON
14
+ from .project_config import ProjectConfig
15
+ from .utils import syntax_hint, block_wrap_lr, max_line_len
16
+
17
+
18
+ @dataclass
19
+ class Issue:
20
+ @dataclass
21
+ class AffectedCode:
22
+ start_line: int = field()
23
+ end_line: int | None = field(default=None)
24
+ file: str = field(default="")
25
+ proposal: str = field(default="")
26
+ affected_code: str = field(default="")
27
+
28
+ @property
29
+ def syntax_hint(self) -> str:
30
+ return syntax_hint(self.file)
31
+
32
+ id: str = field()
33
+ title: str = field()
34
+ details: str = field(default="")
35
+ severity: int | None = field(default=None)
36
+ confidence: int | None = field(default=None)
37
+ tags: list[str] = field(default_factory=list)
38
+ file: str = field(default="")
39
+ affected_lines: list[AffectedCode] = field(default_factory=list)
40
+
41
+ def __post_init__(self):
42
+ self.affected_lines = [
43
+ Issue.AffectedCode(**dict(file=self.file) | i)
44
+ for i in self.affected_lines
45
+ ]
46
+
47
+ def github_code_link(self, github_env: dict) -> str:
48
+ url = (
49
+ f"https://github.com/{github_env['github_repo']}"
50
+ f"/blob/{github_env['github_pr_sha_or_branch']}"
51
+ f"/{self.file}"
52
+ )
53
+ if self.affected_lines:
54
+ url += f"#L{self.affected_lines[0].start_line}"
55
+ if self.affected_lines[0].end_line:
56
+ url += f"-L{self.affected_lines[0].end_line}"
57
+ return url
58
+
59
+
60
+ @dataclass
61
+ class Report:
62
+ class Format(StrEnum):
63
+ MARKDOWN = "md"
64
+ CLI = "cli"
65
+
66
+ issues: dict[str, list[Issue]] = field(default_factory=dict)
67
+ summary: str = field(default="")
68
+ number_of_processed_files: int = field(default=0)
69
+ total_issues: int = field(init=False)
70
+ created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
71
+ model: str = field(default_factory=lambda: mc.config().MODEL)
72
+ pipeline_out: dict = field(default_factory=dict)
73
+
74
+ @property
75
+ def plain_issues(self):
76
+ return [
77
+ issue
78
+ for file, issues in self.issues.items()
79
+ for issue in issues
80
+ ]
81
+
82
+ def __post_init__(self):
83
+ issue_id: int = 0
84
+ for file in self.issues.keys():
85
+ self.issues[file] = [
86
+ Issue(
87
+ **{
88
+ "id": (issue_id := issue_id + 1),
89
+ "file": file,
90
+ } | issue
91
+ )
92
+ for issue in self.issues[file]
93
+ ]
94
+ self.total_issues = issue_id
95
+
96
+ def save(self, file_name: str = ""):
97
+ file_name = file_name or JSON_REPORT_FILE_NAME
98
+ with open(file_name, "w") as f:
99
+ json.dump(asdict(self), f, indent=4)
100
+ logging.info(f"Report saved to {mc.utils.file_link(file_name)}")
101
+
102
+ @staticmethod
103
+ def load(file_name: str | Path = ""):
104
+ with open(file_name or JSON_REPORT_FILE_NAME, "r") as f:
105
+ data = json.load(f)
106
+ data.pop("total_issues", None)
107
+ return Report(**data)
108
+
109
+ def render(
110
+ self,
111
+ config: ProjectConfig = None,
112
+ report_format: Format = Format.MARKDOWN,
113
+ ) -> str:
114
+ config = config or ProjectConfig.load()
115
+ template = getattr(config, f"report_template_{report_format}")
116
+ return mc.prompt(
117
+ template,
118
+ report=self,
119
+ ui=mc.ui,
120
+ Fore=Fore,
121
+ Style=Style,
122
+ Back=Back,
123
+ file_link=file_link,
124
+ textwrap=textwrap,
125
+ block_wrap_lr=block_wrap_lr,
126
+ max_line_len=max_line_len,
127
+ HTML_TEXT_ICON=HTML_TEXT_ICON,
128
+ **config.prompt_vars
129
+ )
130
+
131
+ def to_cli(self, report_format=Format.CLI):
132
+ output = self.render(report_format=report_format)
133
+ print("")
134
+ print(output)
gito/utils.py ADDED
@@ -0,0 +1,226 @@
1
+ import re
2
+ import sys
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ import git
8
+ from git import Repo
9
+ from microcore import ui
10
+
11
+
12
+ _EXT_TO_HINT: dict[str, str] = {
13
+ # scripting & languages
14
+ ".py": "python",
15
+ ".js": "javascript",
16
+ ".ts": "typescript",
17
+ ".java": "java",
18
+ ".c": "c",
19
+ ".cpp": "cpp",
20
+ ".cc": "cpp",
21
+ ".cxx": "cpp",
22
+ ".h": "cpp",
23
+ ".hpp": "cpp",
24
+ ".cs": "csharp",
25
+ ".rb": "ruby",
26
+ ".go": "go",
27
+ ".rs": "rust",
28
+ ".swift": "swift",
29
+ ".kt": "kotlin",
30
+ ".scala": "scala",
31
+ ".dart": "dart",
32
+ ".php": "php",
33
+ ".pl": "perl",
34
+ ".pm": "perl",
35
+ ".lua": "lua",
36
+ # web & markup
37
+ ".html": "html",
38
+ ".htm": "html",
39
+ ".css": "css",
40
+ ".scss": "scss",
41
+ ".less": "less",
42
+ ".json": "json",
43
+ ".xml": "xml",
44
+ ".yaml": "yaml",
45
+ ".yml": "yaml",
46
+ ".toml": "toml",
47
+ ".ini": "ini",
48
+ ".csv": "csv",
49
+ ".md": "markdown",
50
+ ".rst": "rest",
51
+ # shell & config
52
+ ".sh": "bash",
53
+ ".bash": "bash",
54
+ ".zsh": "bash",
55
+ ".fish": "bash",
56
+ ".ps1": "powershell",
57
+ ".dockerfile": "dockerfile",
58
+ # build & CI
59
+ ".makefile": "makefile",
60
+ ".mk": "makefile",
61
+ "CMakeLists.txt": "cmake",
62
+ "Dockerfile": "dockerfile",
63
+ ".gradle": "groovy",
64
+ ".travis.yml": "yaml",
65
+ # data & queries
66
+ ".sql": "sql",
67
+ ".graphql": "graphql",
68
+ ".proto": "protobuf",
69
+ ".yara": "yara",
70
+ }
71
+
72
+
73
+ def syntax_hint(file_path: str | Path) -> str:
74
+ """
75
+ Returns a syntax highlighting hint based on the file's extension or name.
76
+
77
+ This can be used to annotate code blocks for rendering with syntax highlighting,
78
+ e.g., using Markdown-style code blocks: ```<syntax_hint>\n<code>\n```.
79
+
80
+ Args:
81
+ file_path (str | Path): Path to the file.
82
+
83
+ Returns:
84
+ str: A syntax identifier suitable for code highlighting (e.g., 'python', 'json').
85
+ """
86
+ p = Path(file_path)
87
+ ext = p.suffix.lower()
88
+ if not ext:
89
+ name = p.name.lower()
90
+ if name == "dockerfile":
91
+ return "dockerfile"
92
+ return ""
93
+ return _EXT_TO_HINT.get(ext, ext.lstrip("."))
94
+
95
+
96
+ def is_running_in_github_action():
97
+ return os.getenv("GITHUB_ACTIONS") == "true"
98
+
99
+
100
+ def no_subcommand(app: typer.Typer) -> bool:
101
+ """
102
+ Checks if the current script is being invoked as a command in a target Typer application.
103
+ """
104
+ return not (
105
+ (first_arg := next((a for a in sys.argv[1:] if not a.startswith('-')), None))
106
+ and first_arg in (
107
+ cmd.name or cmd.callback.__name__.replace('_', '-')
108
+ for cmd in app.registered_commands
109
+ )
110
+ or '--help' in sys.argv
111
+ )
112
+
113
+
114
+ def parse_refs_pair(refs: str) -> tuple[str | None, str | None]:
115
+ SEPARATOR = '..'
116
+ if not refs:
117
+ return None, None
118
+ if SEPARATOR not in refs:
119
+ return refs, None
120
+ what, against = refs.split(SEPARATOR, 1)
121
+ return what or None, against or None
122
+
123
+
124
+ def max_line_len(text: str) -> int:
125
+ return max((len(line) for line in text.splitlines()), default=0)
126
+
127
+
128
+ def block_wrap_lr(
129
+ text: str,
130
+ left: str = "",
131
+ right: str = "",
132
+ max_rwrap: int = 60,
133
+ min_wrap: int = 0,
134
+ ) -> str:
135
+ ml = max(max_line_len(text), min_wrap)
136
+ lines = text.splitlines()
137
+ wrapped_lines = []
138
+ for line in lines:
139
+ ln = left+line
140
+ if ml <= max_rwrap:
141
+ ln += ' ' * (ml - len(line)) + right
142
+ wrapped_lines.append(ln)
143
+ return "\n".join(wrapped_lines)
144
+
145
+
146
+ def extract_gh_owner_repo(repo: git.Repo) -> tuple[str, str]:
147
+ """
148
+ Extracts the GitHub owner and repository name.
149
+
150
+ Returns:
151
+ tuple[str, str]: A tuple containing the owner and repository name.
152
+ """
153
+ remote_url = repo.remotes.origin.url
154
+ if remote_url.startswith('git@github.com:'):
155
+ # SSH format: git@github.com:owner/repo.git
156
+ repo_path = remote_url.split(':')[1].replace('.git', '')
157
+ elif remote_url.startswith('https://github.com/'):
158
+ # HTTPS format: https://github.com/owner/repo.git
159
+ repo_path = remote_url.replace('https://github.com/', '').replace('.git', '')
160
+ else:
161
+ raise ValueError("Unsupported remote URL format")
162
+ owner, repo_name = repo_path.split('/')
163
+ return owner, repo_name
164
+
165
+
166
+ def detect_github_env() -> dict:
167
+ """
168
+ Try to detect GitHub repository/PR info from environment variables (for GitHub Actions).
169
+ Returns a dict with github_repo, github_pr_sha, github_pr_number, github_ref, etc.
170
+ """
171
+ repo = os.environ.get("GITHUB_REPOSITORY", "")
172
+ pr_sha = os.environ.get("GITHUB_SHA", "")
173
+ pr_number = os.environ.get("GITHUB_REF", "")
174
+ branch = ""
175
+ ref = os.environ.get("GITHUB_REF", "")
176
+ # Try to resolve PR head SHA if available.
177
+ # On PRs, GITHUB_HEAD_REF/BASE_REF contain branch names.
178
+ if "GITHUB_HEAD_REF" in os.environ:
179
+ branch = os.environ["GITHUB_HEAD_REF"]
180
+ elif ref.startswith("refs/heads/"):
181
+ branch = ref[len("refs/heads/"):]
182
+ elif ref.startswith("refs/pull/"):
183
+ # for pull_request events
184
+ branch = ref
185
+
186
+ d = {
187
+ "github_repo": repo,
188
+ "github_pr_sha": pr_sha,
189
+ "github_pr_number": pr_number,
190
+ "github_branch": branch,
191
+ "github_ref": ref,
192
+ }
193
+ # Fallback for local usage: try to get from git
194
+ if not repo:
195
+ git_repo = None
196
+ try:
197
+ git_repo = Repo(".", search_parent_directories=True)
198
+ origin = git_repo.remotes.origin.url
199
+ # e.g. git@github.com:Nayjest/ai-code-review.git -> Nayjest/ai-code-review
200
+ match = re.search(r"[:/]([\w\-]+)/([\w\-\.]+?)(\.git)?$", origin)
201
+ if match:
202
+ d["github_repo"] = f"{match.group(1)}/{match.group(2)}"
203
+ d["github_pr_sha"] = git_repo.head.commit.hexsha
204
+ d["github_branch"] = (
205
+ git_repo.active_branch.name if hasattr(git_repo, "active_branch") else ""
206
+ )
207
+ except Exception:
208
+ pass
209
+ finally:
210
+ if git_repo:
211
+ try:
212
+ git_repo.close()
213
+ except Exception:
214
+ pass
215
+ # If branch is not a commit SHA, prefer branch for links
216
+ if d["github_branch"]:
217
+ d["github_pr_sha_or_branch"] = d["github_branch"]
218
+ elif d["github_pr_sha"]:
219
+ d["github_pr_sha_or_branch"] = d["github_pr_sha"]
220
+ else:
221
+ d["github_pr_sha_or_branch"] = "main"
222
+ return d
223
+
224
+
225
+ def stream_to_cli(text):
226
+ print(ui.blue(text), end='')