blob: c493f4092bead34de65559d2c70d38a79fd09549 [file] [log] [blame]
Philip Zeyliger9e3c8672025-05-27 17:09:14 +00001#!/usr/bin/env python3
2import json
3import os
4import subprocess
5import sys
6import urllib.request
7from datetime import datetime, timezone
8
9def validate_environment():
10 """Validate required environment variables."""
Philip Zeyliger9e3c8672025-05-27 17:09:14 +000011 if not os.environ.get('GITHUB_SHA'):
12 print("Error: GITHUB_SHA environment variable is required")
13 sys.exit(1)
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -070014
Philip Zeyliger9e3c8672025-05-27 17:09:14 +000015 if not os.environ.get('GITHUB_REPOSITORY'):
16 print("Error: GITHUB_REPOSITORY environment variable is required")
17 sys.exit(1)
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -070018
Philip Zeyliger9e3c8672025-05-27 17:09:14 +000019 if not os.environ.get('DISCORD_WEBHOOK_FOR_COMMITS'):
20 print("Error: DISCORD_WEBHOOK_FOR_COMMITS environment variable is required")
21 sys.exit(1)
22
Philip Zeyliger327c1962025-07-14 16:08:51 -070023def get_commit_range():
24 """Get the range of commits from the GitHub event payload."""
25 event_path = os.environ.get('GITHUB_EVENT_PATH')
26 if not event_path:
27 print("Warning: GITHUB_EVENT_PATH not available, falling back to single commit")
28 return None, None
29
30 try:
31 with open(event_path, 'r') as f:
32 event_data = json.load(f)
33
34 before = event_data.get('before')
35 after = event_data.get('after')
36
37 # GitHub sends '0000000000000000000000000000000000000000' for new branches
38 if before and before != '0000000000000000000000000000000000000000':
39 return before, after
40 else:
41 return None, None
42 except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
43 print(f"Warning: Could not parse GitHub event payload: {e}")
44 return None, None
45
46def get_commit_info(commit_sha=None):
Philip Zeyliger9e3c8672025-05-27 17:09:14 +000047 """Extract commit information using git commands."""
Philip Zeyliger327c1962025-07-14 16:08:51 -070048 commit_ref = commit_sha or 'HEAD'
Philip Zeyliger9e3c8672025-05-27 17:09:14 +000049 try:
50 # Get commit message (subject line)
51 commit_message = subprocess.check_output(
Philip Zeyliger327c1962025-07-14 16:08:51 -070052 ['git', 'log', '-1', '--pretty=format:%s', commit_ref],
Philip Zeyliger9e3c8672025-05-27 17:09:14 +000053 text=True, stderr=subprocess.DEVNULL
54 ).strip()
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -070055
56 # Get commit body (description)
57 commit_body = subprocess.check_output(
Philip Zeyliger327c1962025-07-14 16:08:51 -070058 ['git', 'log', '-1', '--pretty=format:%b', commit_ref],
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -070059 text=True, stderr=subprocess.DEVNULL
60 ).strip()
61
Philip Zeyliger9e3c8672025-05-27 17:09:14 +000062 # Get commit author
63 commit_author = subprocess.check_output(
Philip Zeyliger327c1962025-07-14 16:08:51 -070064 ['git', 'log', '-1', '--pretty=format:%an', commit_ref],
Philip Zeyliger9e3c8672025-05-27 17:09:14 +000065 text=True, stderr=subprocess.DEVNULL
66 ).strip()
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -070067
68 return commit_message, commit_body, commit_author
Philip Zeyliger9e3c8672025-05-27 17:09:14 +000069 except subprocess.CalledProcessError as e:
70 print(f"Failed to get commit information: {e}")
71 sys.exit(1)
72
Philip Zeyliger327c1962025-07-14 16:08:51 -070073def get_commits_in_range(before, after):
74 """Get all commits in the range before..after."""
75 try:
76 # Get commit SHAs in the range
77 commit_shas = subprocess.check_output(
78 ['git', 'rev-list', '--reverse', f'{before}..{after}'],
79 text=True, stderr=subprocess.DEVNULL
80 ).strip().split('\n')
81
82 # Filter out empty strings
83 commit_shas = [sha for sha in commit_shas if sha]
84
85 commits = []
86 for sha in commit_shas:
87 message, body, author = get_commit_info(sha)
88 commits.append({
89 'sha': sha,
90 'short_sha': sha[:8],
91 'message': message,
92 'body': body,
93 'author': author
94 })
95
96 return commits
97 except subprocess.CalledProcessError as e:
98 print(f"Failed to get commits in range: {e}")
99 sys.exit(1)
100
Philip Zeyligerc3ecabb2025-06-06 22:29:03 +0000101def truncate_text(text, max_length):
102 """Truncate text to fit within Discord's limits."""
103 if len(text) <= max_length:
104 return text
105 # Find a good place to cut off, preferably at a sentence or paragraph boundary
106 truncated = text[:max_length - 3] # Leave room for "..."
107
108 # Try to cut at paragraph boundary
109 last_double_newline = truncated.rfind('\n\n')
110 if last_double_newline > max_length // 2: # Only if we're not cutting too much
111 return truncated[:last_double_newline] + "\n\n..."
112
113 # Try to cut at sentence boundary
114 last_period = truncated.rfind('. ')
115 if last_period > max_length // 2: # Only if we're not cutting too much
116 return truncated[:last_period + 1] + " ..."
117
118 # Otherwise just truncate with ellipsis
119 return truncated + "..."
120
Philip Zeyliger327c1962025-07-14 16:08:51 -0700121def format_commit_for_discord(commit, repo_name):
122 """Format a single commit for Discord display."""
123 commit_url = f"https://github.com/{repo_name}/commit/{commit['sha']}"
124 return f"[`{commit['short_sha']}`]({commit_url}) {commit['message']} - {commit['author']}"
125
126def create_discord_payload_for_commits(commits, repo_name):
127 """Create Discord payload for multiple commits."""
128 timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')[:-3] + 'Z'
129
130 if len(commits) == 1:
131 # Single commit - use the original detailed format
132 commit = commits[0]
133 commit_url = f"https://github.com/{repo_name}/commit/{commit['sha']}"
134 title = truncate_text(commit['message'], 256)
135 description = truncate_text(commit['body'], 2000)
136
137 return {
138 "embeds": [
139 {
140 "title": title,
141 "description": description,
142 "color": 5814783,
143 "fields": [
144 {
145 "name": "Author",
146 "value": commit['author'],
147 "inline": True
148 },
149 {
150 "name": "Commit",
151 "value": f"[{commit['short_sha']}]({commit_url})",
152 "inline": True
153 },
154 ],
155 "timestamp": timestamp
156 }
157 ]
158 }
159 else:
160 # Multiple commits - use a compact format
161 commit_lines = []
162 for commit in commits:
163 commit_lines.append(format_commit_for_discord(commit, repo_name))
164
165 description = "\n".join(commit_lines)
166
167 # Truncate if too long
168 if len(description) > 2000:
169 # Try to fit as many commits as possible
170 truncated_lines = []
171 current_length = 0
172 for line in commit_lines:
173 if current_length + len(line) + 1 > 1900: # Leave room for "...and X more"
174 remaining = len(commit_lines) - len(truncated_lines)
175 truncated_lines.append(f"...and {remaining} more commits")
176 break
177 truncated_lines.append(line)
178 current_length += len(line) + 1
179 description = "\n".join(truncated_lines)
180
181 # Get unique authors
182 authors = list(set(commit['author'] for commit in commits))
183 author_text = ", ".join(authors) if len(authors) <= 3 else f"{authors[0]} and {len(authors) - 1} others"
184
185 return {
186 "embeds": [
187 {
188 "title": f"{len(commits)} commits pushed to main",
189 "description": description,
190 "color": 5814783,
191 "fields": [
192 {
193 "name": "Authors",
194 "value": author_text,
195 "inline": True
196 },
197 {
198 "name": "Commits",
199 "value": str(len(commits)),
200 "inline": True
201 },
202 ],
203 "timestamp": timestamp
204 }
205 ]
206 }
207
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000208def main():
209 # Validate we're running in the correct environment
210 validate_environment()
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -0700211
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000212 # Check for test mode
213 if os.environ.get('DISCORD_TEST_MODE') == '1':
214 print("Running in test mode - will not send actual webhook")
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -0700215
Philip Zeyliger327c1962025-07-14 16:08:51 -0700216 # Get repository info
217 repo_name = os.environ.get('GITHUB_REPOSITORY')
218
219 # Try to get commit range from GitHub event
220 before, after = get_commit_range()
221
222 if before and after:
223 # Multiple commits pushed
224 commits = get_commits_in_range(before, after)
225 if not commits:
226 print("No commits found in range, falling back to single commit")
227 # Fall back to single commit
228 commit_message, commit_body, commit_author = get_commit_info()
229 github_sha = os.environ.get('GITHUB_SHA')
230 commits = [{
231 'sha': github_sha,
232 'short_sha': github_sha[:8],
233 'message': commit_message,
234 'body': commit_body,
235 'author': commit_author
236 }]
237 else:
238 # Single commit or fallback
239 commit_message, commit_body, commit_author = get_commit_info()
240 github_sha = os.environ.get('GITHUB_SHA')
241 commits = [{
242 'sha': github_sha,
243 'short_sha': github_sha[:8],
244 'message': commit_message,
245 'body': commit_body,
246 'author': commit_author
247 }]
Philip Zeyligerc3ecabb2025-06-06 22:29:03 +0000248
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000249 # Create Discord webhook payload
Philip Zeyliger327c1962025-07-14 16:08:51 -0700250 payload = create_discord_payload_for_commits(commits, repo_name)
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -0700251
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000252 # Convert to JSON
253 json_payload = json.dumps(payload)
Philip Zeyligerc3ecabb2025-06-06 22:29:03 +0000254
255 # Debug: print payload size info
256 if os.environ.get('DISCORD_TEST_MODE') == '1':
257 print(f"Payload size: {len(json_payload)} bytes")
258 print(f"Title length: {len(payload['embeds'][0]['title'])} chars")
259 print(f"Description length: {len(payload['embeds'][0]['description'])} chars")
Philip Zeyliger327c1962025-07-14 16:08:51 -0700260 print(f"Number of commits: {len(commits)}")
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -0700261
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000262 # Test mode - just print the payload
263 if os.environ.get('DISCORD_TEST_MODE') == '1':
264 print("Generated Discord payload:")
265 print(json.dumps(payload, indent=2))
266 print("✓ Test mode: payload generated successfully")
267 return
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -0700268
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000269 # Send to Discord webhook
270 webhook_url = os.environ.get('DISCORD_WEBHOOK_FOR_COMMITS')
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -0700271
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000272 req = urllib.request.Request(
273 webhook_url,
274 data=json_payload.encode('utf-8'),
Philip Zeyligerf1787552025-05-28 02:44:39 +0000275 headers={
276 'Content-Type': 'application/json',
277 'User-Agent': 'sketch.dev developers'
278 }
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000279 )
Josh Bleecher Snyderfa306772025-05-28 09:37:04 -0700280
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000281 try:
282 with urllib.request.urlopen(req) as response:
283 if response.status == 204:
Philip Zeyliger327c1962025-07-14 16:08:51 -0700284 print(f"Discord notification sent successfully for {len(commits)} commit(s)")
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000285 else:
286 print(f"Discord webhook returned status: {response.status}")
Philip Zeyligerf1787552025-05-28 02:44:39 +0000287 response_body = response.read().decode('utf-8')
288 print(f"Response body: {response_body}")
Philip Zeyliger9e3c8672025-05-27 17:09:14 +0000289 sys.exit(1)
290 except urllib.error.HTTPError as e:
291 print(f"Discord webhook HTTP error: {e.code} - {e.reason}")
292 try:
293 error_body = e.read().decode('utf-8')
294 print(f"Error details: {error_body}")
295 if e.code == 403 and 'error code: 1010' in error_body:
296 print("Error 1010: Webhook not found - the Discord webhook URL may be invalid or expired")
297 except:
298 pass
299 sys.exit(1)
300 except Exception as e:
301 print(f"Failed to send Discord notification: {e}")
302 sys.exit(1)
303
304if __name__ == "__main__":
305 main()