Skip to content

Commit 3c22da3

Browse files
committed
add ci jobs3
1 parent f5859f8 commit 3c22da3

4 files changed

Lines changed: 355 additions & 0 deletions

File tree

.github/workflows/ai-assistant.yml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: AI Assistant
2+
3+
on:
4+
pull_request:
5+
types:
6+
- opened
7+
- synchronize
8+
- reopened
9+
- ready_for_review
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
pull-requests: write
15+
issues: write
16+
17+
jobs:
18+
ai-pr-assistant:
19+
name: AI PR Assistant
20+
runs-on: ubuntu-latest
21+
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
22+
steps:
23+
- uses: actions/checkout@v4
24+
25+
- name: Generate AI PR note
26+
env:
27+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28+
GITHUB_REPOSITORY: ${{ github.repository }}
29+
GITHUB_API_URL: ${{ github.api_url }}
30+
PR_NUMBER: ${{ github.event.pull_request.number }}
31+
OUTPUT_PATH: ${{ runner.temp }}/ai-pr-comment.md
32+
GITHUB_MODELS_MODEL: openai/gpt-4o-mini
33+
run: |
34+
python3 scripts/ai_pr_assistant.py
35+
36+
- name: Upsert PR comment
37+
if: github.event_name == 'pull_request'
38+
continue-on-error: true
39+
uses: actions/github-script@v7
40+
env:
41+
COMMENT_PATH: ${{ runner.temp }}/ai-pr-comment.md
42+
with:
43+
script: |
44+
const fs = require("fs");
45+
const marker = "<!-- ai-pr-assistant -->";
46+
const body = fs.readFileSync(process.env.COMMENT_PATH, "utf8");
47+
const { owner, repo } = context.repo;
48+
const issue_number = context.payload.pull_request.number;
49+
50+
const comments = await github.paginate(
51+
github.rest.issues.listComments,
52+
{ owner, repo, issue_number, per_page: 100 }
53+
);
54+
55+
const existing = comments.find(
56+
(c) => c.body && c.body.includes(marker)
57+
);
58+
59+
if (existing) {
60+
await github.rest.issues.updateComment({
61+
owner,
62+
repo,
63+
comment_id: existing.id,
64+
body
65+
});
66+
} else {
67+
await github.rest.issues.createComment({
68+
owner,
69+
repo,
70+
issue_number,
71+
body
72+
});
73+
}

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,29 @@ jobs:
4747
chmod +x scripts/check-duplicate-problems.sh
4848
./scripts/check-duplicate-problems.sh
4949
50+
swift-build-check:
51+
name: Swift Build Check
52+
runs-on: macos-latest
53+
steps:
54+
- uses: actions/checkout@v4
55+
- name: Compile project
56+
shell: bash
57+
run: |
58+
set -euo pipefail
59+
project="Algorithm Solutions In Swift.xcodeproj"
60+
list_output="$(xcodebuild -list -project "$project")"
61+
echo "$list_output"
62+
63+
if echo "$list_output" | sed -n '/Schemes:/,$p' | grep -Fq "Algorithm Solutions In Swift"; then
64+
xcodebuild \
65+
-project "$project" \
66+
-scheme "Algorithm Solutions In Swift" \
67+
-destination "platform=macOS" \
68+
build
69+
else
70+
xcodebuild -project "$project" -target "Algorithm Solutions In Swift" build
71+
fi
72+
5073
swift-format-lint-check:
5174
name: Swift Format And Lint Check
5275
runs-on: macos-latest
13.5 KB
Binary file not shown.

scripts/ai_pr_assistant.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
#!/usr/bin/env python3
2+
import json
3+
import os
4+
import re
5+
import textwrap
6+
import urllib.error
7+
import urllib.parse
8+
import urllib.request
9+
from pathlib import Path
10+
11+
12+
MARKER = "<!-- ai-pr-assistant -->"
13+
14+
15+
def env(name: str, default: str = "") -> str:
16+
value = os.getenv(name, default)
17+
return value.strip()
18+
19+
20+
GITHUB_API_URL = env("GITHUB_API_URL", "https://api.github.com")
21+
REPO = env("GITHUB_REPOSITORY")
22+
PR_NUMBER = env("PR_NUMBER")
23+
TOKEN = env("GITHUB_TOKEN")
24+
OUTPUT_PATH = env("OUTPUT_PATH")
25+
MODELS_ENDPOINT = env(
26+
"GITHUB_MODELS_ENDPOINT",
27+
"https://models.inference.ai.azure.com/chat/completions",
28+
)
29+
MODEL_NAME = env("GITHUB_MODELS_MODEL", "openai/gpt-4o-mini")
30+
31+
32+
def gh_get(path: str):
33+
req = urllib.request.Request(
34+
f"{GITHUB_API_URL}{path}",
35+
headers={
36+
"Authorization": f"Bearer {TOKEN}",
37+
"Accept": "application/vnd.github+json",
38+
"User-Agent": "ai-pr-assistant",
39+
},
40+
)
41+
with urllib.request.urlopen(req, timeout=30) as response:
42+
return json.loads(response.read().decode("utf-8"))
43+
44+
45+
def list_pr_files():
46+
files = []
47+
page = 1
48+
while True:
49+
data = gh_get(
50+
f"/repos/{REPO}/pulls/{PR_NUMBER}/files?per_page=100&page={page}"
51+
)
52+
if not data:
53+
break
54+
files.extend(data)
55+
page += 1
56+
return files
57+
58+
59+
def infer_topic(problem_name: str) -> str:
60+
lowered = problem_name.lower()
61+
if "linked list" in lowered or "list cycle" in lowered:
62+
return "Linked List"
63+
if "tree" in lowered or "bst" in lowered:
64+
return "Tree"
65+
if any(x in lowered for x in ["graph", "bfs", "dfs", "topological"]):
66+
return "Graph"
67+
if any(x in lowered for x in ["matrix", "grid", "island", "spiral"]):
68+
return "Matrix"
69+
if any(x in lowered for x in ["string", "palindrome", "anagram", "substring", "word"]):
70+
return "String"
71+
if "sort" in lowered:
72+
return "Sorting"
73+
if "search" in lowered:
74+
return "Searching"
75+
if any(x in lowered for x in ["array", "subarray", "sum", "two number", "three number"]):
76+
return "Array"
77+
if "stack" in lowered or "queue" in lowered:
78+
return "Stack / Queue"
79+
return "_"
80+
81+
82+
def changed_problem_dirs(files):
83+
problems = set()
84+
pattern = re.compile(r"^(HackerRank|LeetCode|AlgoExpert|GeekForGeeks|Pramp)/([^/]+)/")
85+
for entry in files:
86+
filename = entry.get("filename", "")
87+
match = pattern.match(filename)
88+
if match:
89+
problems.add((match.group(1), match.group(2)))
90+
return sorted(problems)
91+
92+
93+
def load_metadata_entries(repo_root: Path):
94+
metadata_path = repo_root / "scripts" / "readme-metadata.tsv"
95+
existing = set()
96+
if not metadata_path.exists():
97+
return existing
98+
99+
for line in metadata_path.read_text(encoding="utf-8").splitlines():
100+
if not line or line.startswith("#"):
101+
continue
102+
parts = line.split("\t")
103+
if len(parts) >= 2:
104+
existing.add((parts[0], parts[1]))
105+
return existing
106+
107+
108+
def build_metadata_suggestions(files, repo_root: Path):
109+
changed = changed_problem_dirs(files)
110+
existing = load_metadata_entries(repo_root)
111+
112+
missing = [(cat, prob) for (cat, prob) in changed if (cat, prob) not in existing]
113+
if not missing:
114+
return "No metadata additions needed for changed problem folders."
115+
116+
lines = []
117+
for category, problem in missing:
118+
topic = infer_topic(problem)
119+
lines.append(f"{category}\t{problem}\t_\t{topic}\t_")
120+
return "Add these lines to `scripts/readme-metadata.tsv`:\n\n```tsv\n" + "\n".join(lines) + "\n```"
121+
122+
123+
def build_fallback_summary(pr, files):
124+
top_files = sorted(files, key=lambda item: item.get("changes", 0), reverse=True)[:10]
125+
file_lines = []
126+
for entry in top_files:
127+
file_lines.append(
128+
f"- `{entry.get('filename', '')}` (+{entry.get('additions', 0)} / -{entry.get('deletions', 0)})"
129+
)
130+
131+
return textwrap.dedent(
132+
f"""
133+
Could not reach AI model endpoint, so here is a deterministic summary.
134+
135+
- PR title: {pr.get('title', '')}
136+
- Files changed: {len(files)}
137+
- Additions: {pr.get('additions', 0)}, Deletions: {pr.get('deletions', 0)}
138+
- Suggested manual checks:
139+
- correctness on edge cases
140+
- complexity alignment with README notes
141+
- naming consistency across similar problems
142+
143+
Top changed files:
144+
{chr(10).join(file_lines) if file_lines else "- (no files listed)"}
145+
"""
146+
).strip()
147+
148+
149+
def model_summary(pr, files):
150+
# Keep payload compact and focused to avoid token/rate limits.
151+
file_context = []
152+
for entry in files[:25]:
153+
patch = entry.get("patch", "")
154+
if patch:
155+
patch = patch[:800]
156+
file_context.append(
157+
{
158+
"file": entry.get("filename", ""),
159+
"status": entry.get("status", ""),
160+
"additions": entry.get("additions", 0),
161+
"deletions": entry.get("deletions", 0),
162+
"patch_excerpt": patch,
163+
}
164+
)
165+
166+
prompt = {
167+
"pr_title": pr.get("title", ""),
168+
"pr_body": pr.get("body", "") or "",
169+
"changed_files": file_context,
170+
}
171+
172+
body = {
173+
"model": MODEL_NAME,
174+
"messages": [
175+
{
176+
"role": "system",
177+
"content": (
178+
"You are a Swift code reviewer. Be concise and practical. "
179+
"Return markdown with sections: 'Summary', 'Potential Risks', "
180+
"'Suggested Checks'."
181+
),
182+
},
183+
{
184+
"role": "user",
185+
"content": json.dumps(prompt),
186+
},
187+
],
188+
"temperature": 0.2,
189+
"max_tokens": 700,
190+
}
191+
192+
req = urllib.request.Request(
193+
MODELS_ENDPOINT,
194+
data=json.dumps(body).encode("utf-8"),
195+
headers={
196+
"Authorization": f"Bearer {TOKEN}",
197+
"Content-Type": "application/json",
198+
},
199+
method="POST",
200+
)
201+
202+
with urllib.request.urlopen(req, timeout=45) as response:
203+
payload = json.loads(response.read().decode("utf-8"))
204+
205+
choices = payload.get("choices", [])
206+
if not choices:
207+
return None
208+
message = choices[0].get("message", {})
209+
content = message.get("content", "")
210+
return content.strip() if content else None
211+
212+
213+
def build_comment():
214+
if not REPO or not PR_NUMBER or not TOKEN:
215+
return (
216+
f"{MARKER}\n## AI PR Assistant\n"
217+
"Missing required environment values (`GITHUB_REPOSITORY`, `PR_NUMBER`, `GITHUB_TOKEN`)."
218+
)
219+
220+
pr = gh_get(f"/repos/{REPO}/pulls/{PR_NUMBER}")
221+
files = list_pr_files()
222+
repo_root = Path(__file__).resolve().parents[1]
223+
224+
ai_section = ""
225+
error_note = ""
226+
try:
227+
ai_section = model_summary(pr, files) or ""
228+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc:
229+
error_note = f"Model endpoint unavailable (`{exc}`); using fallback summary."
230+
except Exception as exc: # pragma: no cover - defensive.
231+
error_note = f"Model call failed (`{exc}`); using fallback summary."
232+
233+
if not ai_section:
234+
ai_section = build_fallback_summary(pr, files)
235+
236+
metadata_section = build_metadata_suggestions(files, repo_root)
237+
238+
parts = [
239+
MARKER,
240+
"## AI PR Assistant",
241+
]
242+
if error_note:
243+
parts.append(f"_Note: {error_note}_")
244+
parts.append(ai_section)
245+
parts.append("## Metadata Suggestions")
246+
parts.append(metadata_section)
247+
return "\n\n".join(parts).strip() + "\n"
248+
249+
250+
def main():
251+
comment = build_comment()
252+
output = Path(OUTPUT_PATH) if OUTPUT_PATH else Path(".github/ai-pr-comment.md")
253+
output.parent.mkdir(parents=True, exist_ok=True)
254+
output.write_text(comment, encoding="utf-8")
255+
print(f"Wrote AI PR comment to {output}")
256+
257+
258+
if __name__ == "__main__":
259+
main()

0 commit comments

Comments
 (0)