diffsense 2.2.12__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.
- adapters/__init__.py +0 -0
- adapters/base.py +27 -0
- adapters/github_adapter.py +164 -0
- adapters/gitlab_adapter.py +207 -0
- adapters/local_adapter.py +136 -0
- banner.py +71 -0
- cli.py +606 -0
- config/__init__.py +1 -0
- config/rules.yaml +371 -0
- core/__init__.py +235 -0
- core/ast_detector.py +853 -0
- core/change.py +46 -0
- core/composer.py +93 -0
- core/evaluator.py +15 -0
- core/ignore_manager.py +71 -0
- core/knowledge.py +77 -0
- core/parser.py +181 -0
- core/parser_manager.py +104 -0
- core/quality_manager.py +117 -0
- core/renderer.py +197 -0
- core/rule_base.py +98 -0
- core/rule_runtime.py +103 -0
- core/rules.py +718 -0
- core/run_config.py +85 -0
- core/semantic_diff.py +359 -0
- core/signal_model.py +21 -0
- core/signals_registry.py +62 -0
- diffsense-2.2.12.dist-info/METADATA +18 -0
- diffsense-2.2.12.dist-info/RECORD +58 -0
- diffsense-2.2.12.dist-info/WHEEL +5 -0
- diffsense-2.2.12.dist-info/entry_points.txt +3 -0
- diffsense-2.2.12.dist-info/licenses/LICENSE +176 -0
- diffsense-2.2.12.dist-info/top_level.txt +11 -0
- diffsense_mcp/__init__.py +1 -0
- diffsense_mcp/launcher.py +28 -0
- diffsense_mcp/server.py +687 -0
- governance/lifecycle.py +54 -0
- main.py +318 -0
- rules/__init__.py +246 -0
- rules/api_compatibility.py +372 -0
- rules/collection_handling.py +349 -0
- rules/concurrency.py +194 -0
- rules/concurrency_adapter.py +250 -0
- rules/cross_language_adapter.py +444 -0
- rules/exception_handling.py +320 -0
- rules/go_rules.py +401 -0
- rules/null_safety.py +301 -0
- rules/resource_management.py +222 -0
- rules/yaml_adapter.py +195 -0
- run_audit.py +478 -0
- sdk/cpp_adapter.py +238 -0
- sdk/go_adapter.py +199 -0
- sdk/java_adapter.py +199 -0
- sdk/javascript_adapter.py +229 -0
- sdk/language_adapter.py +313 -0
- sdk/python_adapter.py +195 -0
- sdk/rule.py +63 -0
- sdk/signal.py +14 -0
adapters/__init__.py
ADDED
|
File without changes
|
adapters/base.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
class PlatformAdapter(ABC):
|
|
4
|
+
@abstractmethod
|
|
5
|
+
def fetch_diff(self) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Fetch unified diff content from the platform.
|
|
8
|
+
"""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def post_comment(self, content: str):
|
|
13
|
+
"""
|
|
14
|
+
Post a comment to the MR/PR.
|
|
15
|
+
Should handle update logic if applicable (e.g. edit existing comment).
|
|
16
|
+
"""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
def is_approved(self) -> bool:
|
|
20
|
+
"""
|
|
21
|
+
Check if the MR/PR is approved by a reviewer.
|
|
22
|
+
Default implementation returns False.
|
|
23
|
+
"""
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
def post_inline_comments(self, comments):
|
|
27
|
+
return None
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from github import Github, GithubException
|
|
3
|
+
import requests
|
|
4
|
+
from .base import PlatformAdapter
|
|
5
|
+
|
|
6
|
+
class GitHubAdapter(PlatformAdapter):
|
|
7
|
+
def __init__(self, token: str, repo_name: str, pr_number: int):
|
|
8
|
+
self.gh = Github(token)
|
|
9
|
+
self.repo = self.gh.get_repo(repo_name)
|
|
10
|
+
self.pr = self.repo.get_pull(pr_number)
|
|
11
|
+
self.comment_tag = "<!-- diffsense_audit_report -->"
|
|
12
|
+
|
|
13
|
+
def fetch_diff(self) -> str:
|
|
14
|
+
# PyGithub's get_files() doesn't give raw unified diff easily for the whole PR.
|
|
15
|
+
# It's better to fetch the diff url directly.
|
|
16
|
+
# But wait, self.pr.diff_url gives the url, we need to download it.
|
|
17
|
+
# However, accessing the URL requires auth if the repo is private.
|
|
18
|
+
# We can use the token in headers.
|
|
19
|
+
|
|
20
|
+
headers = {
|
|
21
|
+
'Authorization': f'token {self.gh.get_user().login if False else os.environ.get("GITHUB_TOKEN")}',
|
|
22
|
+
'Accept': 'application/vnd.github.v3.diff'
|
|
23
|
+
}
|
|
24
|
+
# Actually PyGithub handles auth, but for raw request we need to handle it.
|
|
25
|
+
# Let's use requests.
|
|
26
|
+
# Note: os.environ.get("GITHUB_TOKEN") is usually passed via constructor,
|
|
27
|
+
# but here we rely on the passed token.
|
|
28
|
+
|
|
29
|
+
# Re-construct headers properly
|
|
30
|
+
# We need to use the token passed in init.
|
|
31
|
+
# But wait, Github object doesn't expose raw token easily?
|
|
32
|
+
# Actually it does, but let's just use the one passed to init.
|
|
33
|
+
# Wait, self.gh is authenticated.
|
|
34
|
+
|
|
35
|
+
# self.pr.diff_url is public accessible? No, for private repos it needs auth.
|
|
36
|
+
# Let's use requests with the token.
|
|
37
|
+
|
|
38
|
+
# BUT, there's a simpler way:
|
|
39
|
+
# response = requests.get(self.pr.diff_url, headers={'Authorization': f'token {token}'})
|
|
40
|
+
# I need to store the token.
|
|
41
|
+
pass
|
|
42
|
+
# Let's refactor init to store token or handle this better.
|
|
43
|
+
# Actually, self.pr has not 'diff' attribute directly?
|
|
44
|
+
# PyGithub requests:
|
|
45
|
+
# content = self.repo._requester.requestJsonAndCheck("GET", self.pr.url, headers={"Accept": "application/vnd.github.v3.diff"})
|
|
46
|
+
# This is internal API usage.
|
|
47
|
+
|
|
48
|
+
# Safer way: requests.
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def fetch_diff_safe(self, token: str) -> str:
|
|
52
|
+
headers = {
|
|
53
|
+
'Authorization': f'token {token}',
|
|
54
|
+
'Accept': 'application/vnd.github.v3.diff'
|
|
55
|
+
}
|
|
56
|
+
response = requests.get(self.pr.url, headers=headers)
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
return response.text
|
|
59
|
+
|
|
60
|
+
def post_comment(self, content: str):
|
|
61
|
+
# Check for existing comment
|
|
62
|
+
comments = self.pr.get_issue_comments()
|
|
63
|
+
existing_comment = None
|
|
64
|
+
for comment in comments:
|
|
65
|
+
if self.comment_tag in comment.body:
|
|
66
|
+
existing_comment = comment
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
body = f"{self.comment_tag}\n{content}"
|
|
70
|
+
|
|
71
|
+
if existing_comment:
|
|
72
|
+
existing_comment.edit(body)
|
|
73
|
+
print(f"Updated existing comment {existing_comment.id}")
|
|
74
|
+
else:
|
|
75
|
+
self.pr.create_issue_comment(body)
|
|
76
|
+
print("Created new comment")
|
|
77
|
+
|
|
78
|
+
# Redefine class to include token storage and proper fetch
|
|
79
|
+
class GitHubAdapter(PlatformAdapter):
|
|
80
|
+
def __init__(self, token: str, repo_name: str, pr_number: int):
|
|
81
|
+
self.token = token
|
|
82
|
+
self.gh = Github(token)
|
|
83
|
+
self.repo = self.gh.get_repo(repo_name)
|
|
84
|
+
self.pr = self.repo.get_pull(pr_number)
|
|
85
|
+
self.comment_tag = "<!-- diffsense_audit_report -->"
|
|
86
|
+
|
|
87
|
+
def fetch_diff(self) -> str:
|
|
88
|
+
headers = {
|
|
89
|
+
'Authorization': f'token {self.token}',
|
|
90
|
+
'Accept': 'application/vnd.github.v3.diff'
|
|
91
|
+
}
|
|
92
|
+
# self.pr.url gives the API url (e.g. https://api.github.com/repos/...)
|
|
93
|
+
# Requesting it with diff header gives the diff.
|
|
94
|
+
response = requests.get(self.pr.url, headers=headers)
|
|
95
|
+
response.raise_for_status()
|
|
96
|
+
return response.text
|
|
97
|
+
|
|
98
|
+
def post_comment(self, content: str):
|
|
99
|
+
comments = self.pr.get_issue_comments()
|
|
100
|
+
existing_comment = None
|
|
101
|
+
for comment in comments:
|
|
102
|
+
if self.comment_tag in comment.body:
|
|
103
|
+
existing_comment = comment
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
final_body = f"{content}\n\n{self.comment_tag}"
|
|
107
|
+
|
|
108
|
+
if existing_comment:
|
|
109
|
+
existing_comment.edit(final_body)
|
|
110
|
+
print(f"Updated GitHub comment {existing_comment.id}")
|
|
111
|
+
else:
|
|
112
|
+
self.pr.create_issue_comment(final_body)
|
|
113
|
+
print("Created GitHub comment")
|
|
114
|
+
|
|
115
|
+
def post_inline_comments(self, comments):
|
|
116
|
+
if not comments:
|
|
117
|
+
return
|
|
118
|
+
commit = self.pr.head.sha
|
|
119
|
+
for c in comments:
|
|
120
|
+
path = c.get("path")
|
|
121
|
+
position = c.get("position")
|
|
122
|
+
body = c.get("body")
|
|
123
|
+
if not path or not position or not body:
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
self.pr.create_review_comment(body, commit, path, position)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
print(f"Inline comment failed: {e}")
|
|
129
|
+
|
|
130
|
+
def is_approved(self) -> bool:
|
|
131
|
+
reviews = self.pr.get_reviews()
|
|
132
|
+
reviewer_states = {}
|
|
133
|
+
for review in reviews:
|
|
134
|
+
# Dismissed reviews are not active, but get_reviews might return them?
|
|
135
|
+
# State can be APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING.
|
|
136
|
+
# We only care about the latest state per user.
|
|
137
|
+
reviewer_states[review.user.login] = review.state
|
|
138
|
+
|
|
139
|
+
has_approval = False
|
|
140
|
+
has_changes_requested = False
|
|
141
|
+
|
|
142
|
+
for state in reviewer_states.values():
|
|
143
|
+
if state == 'APPROVED':
|
|
144
|
+
has_approval = True
|
|
145
|
+
elif state == 'CHANGES_REQUESTED':
|
|
146
|
+
has_changes_requested = True
|
|
147
|
+
|
|
148
|
+
# If any changes requested, it's not approved.
|
|
149
|
+
# If approved by at least one and no changes requested, it's approved.
|
|
150
|
+
return has_approval and not has_changes_requested
|
|
151
|
+
|
|
152
|
+
def has_ack_reaction(self) -> bool:
|
|
153
|
+
"""
|
|
154
|
+
Check if the bot's comment has a Thumbs Up (👍) reaction.
|
|
155
|
+
"""
|
|
156
|
+
comments = self.pr.get_issue_comments()
|
|
157
|
+
for comment in comments:
|
|
158
|
+
if self.comment_tag in comment.body:
|
|
159
|
+
# Check reactions
|
|
160
|
+
reactions = comment.get_reactions()
|
|
161
|
+
for reaction in reactions:
|
|
162
|
+
if reaction.content == "+1": # +1 corresponds to 👍
|
|
163
|
+
return True
|
|
164
|
+
return False
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import gitlab
|
|
2
|
+
import requests
|
|
3
|
+
from .base import PlatformAdapter
|
|
4
|
+
|
|
5
|
+
class GitLabAdapter(PlatformAdapter):
|
|
6
|
+
def __init__(self, url: str, token: str, project_id: str, mr_iid: int):
|
|
7
|
+
self.gl = gitlab.Gitlab(url, private_token=token)
|
|
8
|
+
try:
|
|
9
|
+
self.project = self.gl.projects.get(project_id)
|
|
10
|
+
self.mr = self.project.mergerequests.get(mr_iid)
|
|
11
|
+
except gitlab.exceptions.GitlabGetError as e:
|
|
12
|
+
if e.response_code == 404:
|
|
13
|
+
print(f"❌ Error: Could not find Project {project_id} or MR {mr_iid} on {url}.")
|
|
14
|
+
print(" - Check if DIFFSENSE_TOKEN has 'api' scope.")
|
|
15
|
+
print(" - Ensure the token user is a member of the project.")
|
|
16
|
+
print(" - Verify the GitLab URL is correct (defaults to gitlab.com if not specified).")
|
|
17
|
+
raise e
|
|
18
|
+
self.comment_tag = "<!-- diffsense_audit_report -->"
|
|
19
|
+
self.inline_comment_tag = "<!-- diffsense_inline_report -->"
|
|
20
|
+
self.token = token # store for manual request if needed
|
|
21
|
+
|
|
22
|
+
def fetch_diff(self) -> str:
|
|
23
|
+
# GitLab API returns diffs in list of dicts via /changes
|
|
24
|
+
# or we can get unified diff via .diff endpoint.
|
|
25
|
+
# However, for large MRs, the .diff endpoint might be paginated or truncated?
|
|
26
|
+
# Let's try to use the project.mergerequests.changes() method which gives structured diffs
|
|
27
|
+
# and reconstruct unified diff if needed, OR just use the raw diff endpoint.
|
|
28
|
+
|
|
29
|
+
# Issue: If the raw diff endpoint returns something unexpected or empty.
|
|
30
|
+
# Let's try to use the changes API as a fallback or primary source if raw fails.
|
|
31
|
+
|
|
32
|
+
base_url = self.gl.url.rstrip('/')
|
|
33
|
+
diff_url = f"{base_url}/api/v4/projects/{self.project.id}/merge_requests/{self.mr.iid}.diff"
|
|
34
|
+
|
|
35
|
+
headers = {
|
|
36
|
+
'PRIVATE-TOKEN': self.token
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
response = requests.get(diff_url, headers=headers)
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
content = response.text
|
|
43
|
+
|
|
44
|
+
# Validation: Check if it's JSON (API error or wrong endpoint behavior)
|
|
45
|
+
if content.strip().startswith('{') and '"id":' in content:
|
|
46
|
+
print("Warning: .diff endpoint returned JSON. Falling back to changes API.")
|
|
47
|
+
return self._fetch_diff_fallback()
|
|
48
|
+
|
|
49
|
+
if not content.strip():
|
|
50
|
+
print("Warning: Raw diff is empty. Trying fallback to changes API.")
|
|
51
|
+
return self._fetch_diff_fallback()
|
|
52
|
+
return content
|
|
53
|
+
except Exception as e:
|
|
54
|
+
print(f"Warning: Failed to fetch raw diff: {e}. Trying fallback.")
|
|
55
|
+
return self._fetch_diff_fallback()
|
|
56
|
+
|
|
57
|
+
def _fetch_diff_fallback(self) -> str:
|
|
58
|
+
# Fallback: Use python-gitlab changes() API and reconstruct unified-like diff
|
|
59
|
+
# This is robust because it uses the official API structure
|
|
60
|
+
mr_changes = self.mr.changes()
|
|
61
|
+
diffs = mr_changes.get('changes', [])
|
|
62
|
+
|
|
63
|
+
unified_diff = []
|
|
64
|
+
for d in diffs:
|
|
65
|
+
old_path = d.get('old_path')
|
|
66
|
+
new_path = d.get('new_path')
|
|
67
|
+
diff_text = d.get('diff', '')
|
|
68
|
+
|
|
69
|
+
unified_diff.append(f"diff --git a/{old_path} b/{new_path}")
|
|
70
|
+
if d.get('new_file'):
|
|
71
|
+
unified_diff.append(f"--- /dev/null")
|
|
72
|
+
unified_diff.append(f"+++ b/{new_path}")
|
|
73
|
+
elif d.get('deleted_file'):
|
|
74
|
+
unified_diff.append(f"--- a/{old_path}")
|
|
75
|
+
unified_diff.append(f"+++ /dev/null")
|
|
76
|
+
elif d.get('renamed_file'):
|
|
77
|
+
unified_diff.append(f"--- a/{old_path}")
|
|
78
|
+
unified_diff.append(f"+++ b/{new_path}")
|
|
79
|
+
else:
|
|
80
|
+
unified_diff.append(f"--- a/{old_path}")
|
|
81
|
+
unified_diff.append(f"+++ b/{new_path}")
|
|
82
|
+
|
|
83
|
+
unified_diff.append(diff_text)
|
|
84
|
+
|
|
85
|
+
return "\n".join(unified_diff)
|
|
86
|
+
|
|
87
|
+
def post_comment(self, content: str):
|
|
88
|
+
# Check for existing comment
|
|
89
|
+
notes = self.mr.notes.list(all=True)
|
|
90
|
+
existing_note = None
|
|
91
|
+
for note in notes:
|
|
92
|
+
if self.comment_tag in note.body:
|
|
93
|
+
existing_note = note
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
# Ensure content is properly formatted with the tag
|
|
97
|
+
final_body = f"{content}\n\n{self.comment_tag}"
|
|
98
|
+
|
|
99
|
+
if existing_note:
|
|
100
|
+
# Update existing comment
|
|
101
|
+
existing_note.body = final_body
|
|
102
|
+
existing_note.save()
|
|
103
|
+
print(f"Updated GitLab note {existing_note.id}")
|
|
104
|
+
else:
|
|
105
|
+
# Create new comment
|
|
106
|
+
self.mr.notes.create({'body': final_body})
|
|
107
|
+
print("Created GitLab note")
|
|
108
|
+
|
|
109
|
+
def post_inline_comments(self, comments):
|
|
110
|
+
if not comments:
|
|
111
|
+
return
|
|
112
|
+
# IMPORTANT:
|
|
113
|
+
# Do not call post_comment() here. That would overwrite the main
|
|
114
|
+
# markdown audit report (regression bug), causing plain text fallback.
|
|
115
|
+
lines = ["## Inline Findings", ""]
|
|
116
|
+
for c in comments:
|
|
117
|
+
path = c.get("path", "")
|
|
118
|
+
line = c.get("line", "")
|
|
119
|
+
body = c.get("body", "")
|
|
120
|
+
lines.append(f"- `{path}:{line}` {body}")
|
|
121
|
+
content = "\n".join(lines)
|
|
122
|
+
final_body = f"{content}\n\n{self.inline_comment_tag}"
|
|
123
|
+
|
|
124
|
+
notes = self.mr.notes.list(all=True)
|
|
125
|
+
existing_note = None
|
|
126
|
+
for note in notes:
|
|
127
|
+
if self.inline_comment_tag in note.body:
|
|
128
|
+
existing_note = note
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
if existing_note:
|
|
132
|
+
existing_note.body = final_body
|
|
133
|
+
existing_note.save()
|
|
134
|
+
print(f"Updated GitLab inline note {existing_note.id}")
|
|
135
|
+
else:
|
|
136
|
+
self.mr.notes.create({"body": final_body})
|
|
137
|
+
print("Created GitLab inline note")
|
|
138
|
+
|
|
139
|
+
def is_approved(self) -> bool:
|
|
140
|
+
"""
|
|
141
|
+
Check if MR is approved using GitLab's Approvals API.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
# Need to fetch approvals explicitly
|
|
145
|
+
approvals = self.mr.approvals.get()
|
|
146
|
+
# Logic: If approved_by list is not empty, consider it approved?
|
|
147
|
+
# Or check approvals_left <= 0?
|
|
148
|
+
# Dubbo/OpenSource usually relies on 'approved' state.
|
|
149
|
+
|
|
150
|
+
# Strategy 1: Check if any approval exists
|
|
151
|
+
# Note: approvals.approved_by is a list of users
|
|
152
|
+
if hasattr(approvals, 'approved_by') and approvals.approved_by and len(approvals.approved_by) > 0:
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
# Strategy 2: Check approvals_left (if configured)
|
|
156
|
+
# Note: approvals_left might not exist if no rules are set
|
|
157
|
+
if hasattr(approvals, 'approvals_left') and approvals.approvals_left == 0:
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
# Strategy 3: Check 'approved' attribute directly (some GitLab versions)
|
|
161
|
+
if hasattr(approvals, 'approved') and approvals.approved:
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
return False
|
|
165
|
+
except Exception as e:
|
|
166
|
+
print(f"Warning: Failed to fetch GitLab approvals: {e}")
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
def has_ack_reaction(self) -> bool:
|
|
170
|
+
"""
|
|
171
|
+
Check if the bot's report comment has a 'thumbsup' or 'rocket' reaction.
|
|
172
|
+
This allows 'Click-to-Ack' flow without formal approval.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
notes = self.mr.notes.list(all=True)
|
|
176
|
+
target_note = None
|
|
177
|
+
for note in notes:
|
|
178
|
+
if self.comment_tag in note.body:
|
|
179
|
+
target_note = note
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
if not target_note:
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
# Fetch award emojis for this note
|
|
186
|
+
# python-gitlab note object usually has 'awardemojis' manager?
|
|
187
|
+
# Or we need to fetch specifically.
|
|
188
|
+
|
|
189
|
+
# Try efficient way first
|
|
190
|
+
# The list() might not include award_emoji info directly.
|
|
191
|
+
|
|
192
|
+
# Using specific API call for the note
|
|
193
|
+
# endpoint: GET /projects/:id/merge_requests/:mr_iid/notes/:note_id/award_emoji
|
|
194
|
+
|
|
195
|
+
# Note: python-gitlab objects are lazy. accessing .awardemojis might work if supported.
|
|
196
|
+
# Let's try standard way
|
|
197
|
+
|
|
198
|
+
awards = target_note.awardemojis.list()
|
|
199
|
+
for award in awards:
|
|
200
|
+
if award.name in ['thumbsup', 'rocket', '+1']:
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
except Exception as e:
|
|
206
|
+
print(f"Warning: Failed to check reaction: {e}")
|
|
207
|
+
return False
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional, List, Dict, Any
|
|
4
|
+
from .base import PlatformAdapter
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LocalFileAdapter(PlatformAdapter):
|
|
8
|
+
"""
|
|
9
|
+
本地文件适配器,用于 AI Agent 和本地调试场景。
|
|
10
|
+
|
|
11
|
+
从本地文件读取 diff,将输出写入本地文件。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
diff_file_path: Optional[str] = None,
|
|
17
|
+
output_dir: str = ".",
|
|
18
|
+
report_filename: str = "diffsense-report.json",
|
|
19
|
+
comments_filename: str = "diffsense-comments.json",
|
|
20
|
+
html_filename: str = "diffsense-report.html"
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
Args:
|
|
24
|
+
diff_file_path: diff 文件路径。如果为 None,则调用者需要在 fetch_diff 中提供内容
|
|
25
|
+
output_dir: 输出目录
|
|
26
|
+
report_filename: 审计报告文件名
|
|
27
|
+
comments_filename: 内联评论文件名
|
|
28
|
+
html_filename: HTML 报告文件名
|
|
29
|
+
"""
|
|
30
|
+
self.diff_file_path = diff_file_path
|
|
31
|
+
self.output_dir = output_dir
|
|
32
|
+
self.report_filename = report_filename
|
|
33
|
+
self.comments_filename = comments_filename
|
|
34
|
+
self.html_filename = html_filename
|
|
35
|
+
self._last_diff_content: Optional[str] = None
|
|
36
|
+
self._last_comments: Optional[List[Dict[str, Any]]] = None
|
|
37
|
+
|
|
38
|
+
def set_diff_content(self, content: str):
|
|
39
|
+
"""直接设置 diff 内容(用于流式场景)"""
|
|
40
|
+
self._last_diff_content = content
|
|
41
|
+
|
|
42
|
+
def fetch_diff(self) -> str:
|
|
43
|
+
"""从文件读取 diff 内容"""
|
|
44
|
+
if self._last_diff_content is not None:
|
|
45
|
+
return self._last_diff_content
|
|
46
|
+
|
|
47
|
+
if self.diff_file_path is None:
|
|
48
|
+
raise ValueError("diff_file_path not set and no diff content provided")
|
|
49
|
+
|
|
50
|
+
with open(self.diff_file_path, 'r', encoding='utf-8') as f:
|
|
51
|
+
return f.read()
|
|
52
|
+
|
|
53
|
+
def post_comment(self, content: str):
|
|
54
|
+
"""
|
|
55
|
+
将报告内容写入本地文件。
|
|
56
|
+
支持 JSON 和 Markdown 格式。
|
|
57
|
+
"""
|
|
58
|
+
output_path = os.path.join(self.output_dir, self.report_filename)
|
|
59
|
+
|
|
60
|
+
# Try to parse as JSON first
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(content)
|
|
63
|
+
with open(output_path.replace('.json', '-comment.json'), 'w', encoding='utf-8') as f:
|
|
64
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
65
|
+
except (json.JSONDecodeError, TypeError):
|
|
66
|
+
# Fall back to markdown
|
|
67
|
+
with open(output_path.replace('.json', '-comment.md'), 'w', encoding='utf-8') as f:
|
|
68
|
+
f.write(content)
|
|
69
|
+
|
|
70
|
+
def save_report(self, report_data: Dict[str, Any]):
|
|
71
|
+
"""
|
|
72
|
+
直接保存结构化报告数据(推荐使用)
|
|
73
|
+
"""
|
|
74
|
+
output_path = os.path.join(self.output_dir, self.report_filename)
|
|
75
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
76
|
+
json.dump(report_data, f, ensure_ascii=False, indent=2)
|
|
77
|
+
|
|
78
|
+
def save_html_report(self, html_content: str):
|
|
79
|
+
"""保存 HTML 报告"""
|
|
80
|
+
output_path = os.path.join(self.output_dir, self.html_filename)
|
|
81
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
82
|
+
f.write(html_content)
|
|
83
|
+
|
|
84
|
+
def post_inline_comments(self, comments: List[Dict[str, Any]]):
|
|
85
|
+
"""保存内联评论到文件"""
|
|
86
|
+
output_path = os.path.join(self.output_dir, self.comments_filename)
|
|
87
|
+
self._last_comments = comments
|
|
88
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
89
|
+
json.dump(comments, f, ensure_ascii=False, indent=2)
|
|
90
|
+
|
|
91
|
+
def is_approved(self) -> bool:
|
|
92
|
+
"""本地模式默认返回 False(无审批流程)"""
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
def get_output_paths(self) -> Dict[str, str]:
|
|
96
|
+
"""返回所有输出文件路径"""
|
|
97
|
+
return {
|
|
98
|
+
"report": os.path.join(self.output_dir, self.report_filename),
|
|
99
|
+
"comments": os.path.join(self.output_dir, self.comments_filename),
|
|
100
|
+
"html": os.path.join(self.output_dir, self.html_filename),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class StreamingLocalAdapter(LocalFileAdapter):
|
|
105
|
+
"""
|
|
106
|
+
流式本地适配器,支持处理未保存的 diff 内容。
|
|
107
|
+
适用于 AI Agent 场景,用户无需先保存 diff 文件。
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, output_dir: str = ".", **kwargs):
|
|
111
|
+
super().__init__(diff_file_path=None, output_dir=output_dir, **kwargs)
|
|
112
|
+
|
|
113
|
+
def analyze_and_save(
|
|
114
|
+
self,
|
|
115
|
+
diff_content: str,
|
|
116
|
+
report_data: Dict[str, Any],
|
|
117
|
+
inline_comments: Optional[List[Dict[str, Any]]] = None,
|
|
118
|
+
html_report: Optional[str] = None
|
|
119
|
+
):
|
|
120
|
+
"""
|
|
121
|
+
一站式分析结果保存。
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
diff_content: 原始 diff 内容
|
|
125
|
+
report_data: 结构化审计报告
|
|
126
|
+
inline_comments: 内联评论列表
|
|
127
|
+
html_report: HTML 报告内容
|
|
128
|
+
"""
|
|
129
|
+
self._last_diff_content = diff_content
|
|
130
|
+
self.save_report(report_data)
|
|
131
|
+
|
|
132
|
+
if inline_comments:
|
|
133
|
+
self.post_inline_comments(inline_comments)
|
|
134
|
+
|
|
135
|
+
if html_report:
|
|
136
|
+
self.save_html_report(html_report)
|
banner.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DiffSense 启动 Banner(类似 Spring Boot 的 banner.txt)
|
|
3
|
+
在 CI 运行 audit 时在日志开头打印 Logo,便于识别流水线。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _get_version() -> str:
|
|
8
|
+
try:
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
return version("diffsense")
|
|
11
|
+
except Exception:
|
|
12
|
+
return "2.2.6"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_build_info() -> dict:
|
|
16
|
+
"""获取构建信息,用于详细的版本输出。"""
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
|
|
20
|
+
build_info = {
|
|
21
|
+
"version": _get_version(),
|
|
22
|
+
"commit": "unknown",
|
|
23
|
+
"build_date": "unknown"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Try to get git commit hash
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
timeout=5
|
|
33
|
+
)
|
|
34
|
+
if result.returncode == 0:
|
|
35
|
+
build_info["commit"] = result.stdout.strip()
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
# Try to get build date
|
|
40
|
+
try:
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
build_info["build_date"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
return build_info
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ASCII Art: DiffSense(等宽字体,适配 CI 日志)
|
|
50
|
+
BANNER = r"""
|
|
51
|
+
____ _ _ _____ _____ ____ _____
|
|
52
|
+
| _ \(_) __| | ___| ___/ ___| ____|
|
|
53
|
+
| | | | |/ _` | |_ | |_ \___ \ _|
|
|
54
|
+
| |_| | | (_| | _| | _| ___) | |___
|
|
55
|
+
|____/|_|\__,_|_| |_| |____/|_____|
|
|
56
|
+
|
|
57
|
+
:: DiffSense - MR/PR Risk Audit for CI/CD ::
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def print_banner() -> None:
|
|
62
|
+
"""在 stdout 打印 DiffSense Logo 与版本,供 CI 流水线识别。"""
|
|
63
|
+
version = _get_version()
|
|
64
|
+
build_info = _get_build_info()
|
|
65
|
+
|
|
66
|
+
# 只去掉首尾换行,保留每行前导空格以保持 ASCII 对齐
|
|
67
|
+
print(BANNER.strip("\n"))
|
|
68
|
+
print(f" :: Version: v{version}")
|
|
69
|
+
print(f" :: Commit: {build_info['commit']}")
|
|
70
|
+
print(f" :: Built: {build_info['build_date']}")
|
|
71
|
+
print()
|