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.
- {ai_cr-1.0.1.dist-info → ai_cr-2.0.0.dist-info}/LICENSE +21 -21
- ai_cr-2.0.0.dist-info/METADATA +215 -0
- ai_cr-2.0.0.dist-info/RECORD +26 -0
- {ai_cr-1.0.1.dist-info → ai_cr-2.0.0.dist-info}/WHEEL +1 -1
- ai_cr-2.0.0.dist-info/entry_points.txt +3 -0
- {ai_code_review → gito}/__main__.py +4 -4
- {ai_code_review → gito}/bootstrap.py +66 -62
- {ai_code_review → gito}/cli.py +217 -238
- gito/commands/__init__.py +1 -0
- gito/commands/fix.py +157 -0
- gito/commands/gh_post_review_comment.py +63 -0
- gito/commands/gh_react_to_comment.py +194 -0
- {ai_code_review → gito}/commands/repl.py +7 -2
- ai_code_review/.ai-code-review.toml → gito/config.toml +453 -311
- gito/constants.py +12 -0
- {ai_code_review → gito}/core.py +288 -191
- gito/gh_api.py +35 -0
- gito/issue_trackers.py +49 -0
- gito/pipeline.py +82 -0
- gito/pipeline_steps/__init__.py +0 -0
- gito/pipeline_steps/jira.py +57 -0
- gito/pipeline_steps/linear.py +84 -0
- gito/project_config.py +73 -0
- {ai_code_review → gito}/report_struct.py +134 -108
- gito/utils.py +226 -0
- ai_code_review/constants.py +0 -7
- ai_code_review/project_config.py +0 -99
- ai_code_review/utils.py +0 -116
- ai_cr-1.0.1.dist-info/METADATA +0 -197
- ai_cr-1.0.1.dist-info/RECORD +0 -16
- ai_cr-1.0.1.dist-info/entry_points.txt +0 -3
- {ai_code_review → gito}/__init__.py +0 -0
@@ -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
|
-
|
8
|
-
|
9
|
-
from
|
10
|
-
from .
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
with open(file_name
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
def
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
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='')
|