github-manage-security-alerts-skill 1.0.0 → 1.0.2

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.
@@ -1,358 +1,360 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import os
5
- import re
6
- import subprocess
7
- from dataclasses import dataclass
8
- from pathlib import Path
9
- from typing import Any
10
- from urllib import error, parse, request
11
-
12
- from github_security_common import GitHubSecurityCliError
13
-
14
- DEFAULT_ACCEPT = "application/vnd.github+json"
15
- DEFAULT_API_VERSION = "2026-03-10"
16
- DEFAULT_TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN")
17
- GITHUB_DOT_COM_HOST = "github.com"
18
- GITHUB_DOT_COM_API_BASE = "https://api.github.com"
19
-
20
-
21
- @dataclass(frozen=True)
22
- class RepoContext:
23
- """Resolved repository and authentication context."""
24
-
25
- api_base_url: str
26
- owner: str
27
- repo: str
28
- repo_path: Path
29
- token: str
30
- token_env_name: str
31
- web_base_url: str
32
-
33
- @property
34
- def full_name(self) -> str:
35
- return f"{self.owner}/{self.repo}"
36
-
37
-
38
- @dataclass(frozen=True)
39
- class GitHubApiResponse:
40
- """HTTP response wrapper used by the helper."""
41
-
42
- data: Any
43
- headers: dict[str, str]
44
- status_code: int
45
- url: str
46
-
47
-
48
- class GitHubApiError(GitHubSecurityCliError):
49
- """Raised when the GitHub API returns a non-success response."""
50
-
51
- def __init__(
52
- self,
53
- *,
54
- endpoint: str,
55
- message: str,
56
- response_data: Any,
57
- status_code: int,
58
- url: str,
59
- ) -> None:
60
- super().__init__(message)
61
- self.endpoint = endpoint
62
- self.response_data = response_data
63
- self.status_code = status_code
64
- self.url = url
65
-
66
-
67
- def run_git(repo_path: Path, *arguments: str) -> str:
68
- """Run a git command inside the target repository."""
69
-
70
- completed = subprocess.run(
71
- ["git", "-C", str(repo_path), *arguments],
72
- check=False,
73
- capture_output=True,
74
- text=True,
75
- )
76
- if completed.returncode != 0:
77
- stderr = completed.stderr.strip()
78
- raise GitHubSecurityCliError(
79
- f"Git command failed in '{repo_path}': git {' '.join(arguments)}"
80
- + (f"\n{stderr}" if stderr else "")
81
- )
82
-
83
- return completed.stdout.strip()
84
-
85
-
86
- def parse_repository_input(repository: str) -> tuple[str, str, str, str]:
87
- """Parse owner/repo or a repository URL into host/owner/repo/base URL pieces."""
88
-
89
- repository = repository.strip()
90
- if not repository:
91
- raise GitHubSecurityCliError("Repository input cannot be empty.")
92
-
93
- owner_repo_match = re.fullmatch(
94
- r"(?P<owner>[^/]+)/(?P<repo>[^/]+)", repository
95
- )
96
- if owner_repo_match:
97
- return (
98
- GITHUB_DOT_COM_HOST,
99
- owner_repo_match.group("owner"),
100
- owner_repo_match.group("repo"),
101
- "https",
102
- )
103
-
104
- remote_match = re.fullmatch(
105
- r"git@(?P<host>[^:]+):(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?",
106
- repository,
107
- )
108
- if remote_match:
109
- return (
110
- remote_match.group("host"),
111
- remote_match.group("owner"),
112
- remote_match.group("repo"),
113
- "https",
114
- )
115
-
116
- parsed = parse.urlparse(repository)
117
- if parsed.scheme and parsed.netloc:
118
- path_segments = [
119
- segment for segment in parsed.path.split("/") if segment
120
- ]
121
- if len(path_segments) < 2:
122
- raise GitHubSecurityCliError(
123
- f"Could not parse owner/repo from repository URL '{repository}'."
124
- )
125
-
126
- repo_name = path_segments[-1]
127
- if repo_name.endswith(".git"):
128
- repo_name = repo_name[:-4]
129
-
130
- return parsed.netloc, path_segments[-2], repo_name, parsed.scheme
131
-
132
- raise GitHubSecurityCliError(
133
- f"Unsupported repository input '{repository}'. Use owner/repo or a GitHub URL."
134
- )
135
-
136
-
137
- def parse_remote_url(remote_url: str) -> tuple[str, str, str, str]:
138
- """Parse a git remote URL into host/owner/repo/scheme."""
139
-
140
- return parse_repository_input(remote_url)
141
-
142
-
143
- def resolve_token(token_envs: list[str] | None) -> tuple[str, str]:
144
- """Resolve the first non-empty token from the candidate environment variables."""
145
-
146
- candidates = token_envs or list(DEFAULT_TOKEN_ENVS)
147
-
148
- for candidate in candidates:
149
- token = os.environ.get(candidate)
150
- if token and token.strip():
151
- return candidate, token.strip()
152
-
153
- candidate_text = ", ".join(candidates)
154
- raise GitHubSecurityCliError(
155
- "Could not find a GitHub token in any configured environment variable "
156
- f"({candidate_text}).\n"
157
- "Populate one first, for example in PowerShell:\n"
158
- "$env:GITHUB_TOKEN = Get-Secret GITHUB_TOKEN -AsPlainText"
159
- )
160
-
161
-
162
- def resolve_context(arguments: Any) -> RepoContext:
163
- """Resolve repository ownership, host, and token context."""
164
-
165
- repo_path = Path(arguments.repo).expanduser().resolve()
166
- token_env_name, token = resolve_token(arguments.token_envs)
167
-
168
- if arguments.repository is not None:
169
- host, owner, repo_name, scheme = parse_repository_input(
170
- arguments.repository
171
- )
172
- else:
173
- remote_url = run_git(repo_path, "config", "--get", "remote.origin.url")
174
- host, owner, repo_name, scheme = parse_remote_url(remote_url)
175
-
176
- web_base_url = (arguments.web_base_url or f"{scheme}://{host}").rstrip("/")
177
- if arguments.api_base_url is not None:
178
- api_base_url = arguments.api_base_url.rstrip("/")
179
- elif host.lower() == GITHUB_DOT_COM_HOST:
180
- api_base_url = GITHUB_DOT_COM_API_BASE
181
- else:
182
- api_base_url = f"{scheme}://{host.rstrip('/')}/api/v3"
183
-
184
- return RepoContext(
185
- api_base_url=api_base_url,
186
- owner=owner,
187
- repo=repo_name,
188
- repo_path=repo_path,
189
- token=token,
190
- token_env_name=token_env_name,
191
- web_base_url=web_base_url,
192
- )
193
-
194
-
195
- def normalize_query_value(value: Any) -> str:
196
- """Normalize query-parameter values to GitHub-friendly strings."""
197
-
198
- if isinstance(value, bool):
199
- return "true" if value else "false"
200
-
201
- return str(value)
202
-
203
-
204
- def build_query_string(params: dict[str, Any] | None) -> str:
205
- """Serialize query parameters, omitting null values."""
206
-
207
- if not params:
208
- return ""
209
-
210
- normalized_params: list[tuple[str, str]] = []
211
-
212
- for key, value in params.items():
213
- if value is None:
214
- continue
215
- if isinstance(value, list):
216
- for item in value:
217
- normalized_params.append((key, normalize_query_value(item)))
218
- continue
219
- normalized_params.append((key, normalize_query_value(value)))
220
-
221
- if not normalized_params:
222
- return ""
223
-
224
- return "?" + parse.urlencode(normalized_params, doseq=True)
225
-
226
-
227
- def extract_api_error_message(payload: Any) -> str:
228
- """Extract a readable message from a GitHub API error payload."""
229
-
230
- if isinstance(payload, dict):
231
- message = payload.get("message")
232
- if isinstance(message, str) and message.strip():
233
- return message.strip()
234
-
235
- if isinstance(payload, str) and payload.strip():
236
- return payload.strip()
237
-
238
- return "GitHub API request failed."
239
-
240
-
241
- def api_request(
242
- context: RepoContext,
243
- *,
244
- endpoint: str,
245
- method: str = "GET",
246
- params: dict[str, Any] | None = None,
247
- body: dict[str, Any] | list[Any] | None = None,
248
- accept: str = DEFAULT_ACCEPT,
249
- ) -> GitHubApiResponse:
250
- """Send a GitHub REST API request."""
251
-
252
- query_string = build_query_string(params)
253
- if endpoint.startswith(("http://", "https://")):
254
- url = endpoint
255
- else:
256
- url = f"{context.api_base_url}{endpoint}"
257
- url = f"{url}{query_string}"
258
-
259
- request_body: bytes | None = None
260
- headers = {
261
- "Accept": accept,
262
- "Authorization": f"Bearer {context.token}",
263
- "User-Agent": "github-manage-security-alerts-skill",
264
- "X-GitHub-Api-Version": DEFAULT_API_VERSION,
265
- }
266
-
267
- if body is not None:
268
- request_body = json.dumps(body).encode("utf-8")
269
- headers["Content-Type"] = "application/json"
270
-
271
- http_request = request.Request(
272
- url,
273
- data=request_body,
274
- headers=headers,
275
- method=method.upper(),
276
- )
277
-
278
- try:
279
- with request.urlopen(http_request) as response:
280
- response_headers = {
281
- key.lower(): value for key, value in response.headers.items()
282
- }
283
- raw_body = response.read()
284
- content_type = response_headers.get("content-type", "")
285
- if not raw_body:
286
- parsed_body: Any = None
287
- elif (
288
- "application/json" in content_type or accept == DEFAULT_ACCEPT
289
- ):
290
- parsed_body = json.loads(raw_body.decode("utf-8"))
291
- else:
292
- parsed_body = raw_body.decode("utf-8")
293
-
294
- return GitHubApiResponse(
295
- data=parsed_body,
296
- headers=response_headers,
297
- status_code=response.status,
298
- url=url,
299
- )
300
- except error.HTTPError as exc:
301
- raw_error_body = exc.read()
302
- content_type = exc.headers.get("Content-Type", "")
303
- if raw_error_body:
304
- if "application/json" in content_type:
305
- response_data = json.loads(raw_error_body.decode("utf-8"))
306
- else:
307
- response_data = raw_error_body.decode("utf-8")
308
- else:
309
- response_data = None
310
-
311
- raise GitHubApiError(
312
- endpoint=endpoint,
313
- message=extract_api_error_message(response_data),
314
- response_data=response_data,
315
- status_code=exc.code,
316
- url=url,
317
- ) from exc
318
-
319
-
320
- def safe_api_request(
321
- context: RepoContext,
322
- *,
323
- endpoint: str,
324
- method: str = "GET",
325
- params: dict[str, Any] | None = None,
326
- body: dict[str, Any] | list[Any] | None = None,
327
- accept: str = DEFAULT_ACCEPT,
328
- ) -> dict[str, Any]:
329
- """Execute an API request and return a structured success/error result."""
330
-
331
- try:
332
- response = api_request(
333
- context,
334
- endpoint=endpoint,
335
- method=method,
336
- params=params,
337
- body=body,
338
- accept=accept,
339
- )
340
- except GitHubApiError as exc:
341
- return {
342
- "error": {
343
- "endpoint": exc.endpoint,
344
- "message": str(exc),
345
- "response": exc.response_data,
346
- "status_code": exc.status_code,
347
- "url": exc.url,
348
- },
349
- "ok": False,
350
- }
351
-
352
- return {
353
- "data": response.data,
354
- "headers": response.headers,
355
- "ok": True,
356
- "status_code": response.status_code,
357
- "url": response.url,
358
- }
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib import error, parse, request
11
+
12
+ from github_security_common import GitHubSecurityCliError
13
+
14
+ DEFAULT_ACCEPT = "application/vnd.github+json"
15
+ DEFAULT_API_VERSION = "2026-03-10"
16
+ DEFAULT_TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN")
17
+ GITHUB_DOT_COM_HOST = "github.com"
18
+ GITHUB_DOT_COM_API_BASE = "https://api.github.com"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RepoContext:
23
+ """Resolved repository and authentication context."""
24
+
25
+ api_base_url: str
26
+ owner: str
27
+ repo: str
28
+ repo_path: Path
29
+ token: str
30
+ token_env_name: str
31
+ web_base_url: str
32
+
33
+ @property
34
+ def full_name(self) -> str:
35
+ return f"{self.owner}/{self.repo}"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class GitHubApiResponse:
40
+ """HTTP response wrapper used by the helper."""
41
+
42
+ data: Any
43
+ headers: dict[str, str]
44
+ status_code: int
45
+ url: str
46
+
47
+
48
+ class GitHubApiError(GitHubSecurityCliError):
49
+ """Raised when the GitHub API returns a non-success response."""
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ endpoint: str,
55
+ message: str,
56
+ response_data: Any,
57
+ status_code: int,
58
+ url: str,
59
+ ) -> None:
60
+ super().__init__(message)
61
+ self.endpoint = endpoint
62
+ self.response_data = response_data
63
+ self.status_code = status_code
64
+ self.url = url
65
+
66
+
67
+ def run_git(repo_path: Path, *arguments: str) -> str:
68
+ """Run a git command inside the target repository."""
69
+
70
+ completed = subprocess.run(
71
+ ["git", "-C", str(repo_path), *arguments],
72
+ check=False,
73
+ capture_output=True,
74
+ text=True,
75
+ )
76
+ if completed.returncode != 0:
77
+ stderr = completed.stderr.strip()
78
+ raise GitHubSecurityCliError(
79
+ f"Git command failed in '{repo_path}': git {' '.join(arguments)}"
80
+ + (f"\n{stderr}" if stderr else "")
81
+ )
82
+
83
+ return completed.stdout.strip()
84
+
85
+
86
+ def parse_repository_input(repository: str) -> tuple[str, str, str, str]:
87
+ """Parse owner/repo or a repository URL into host/owner/repo/base URL pieces."""
88
+
89
+ repository = repository.strip()
90
+ if not repository:
91
+ raise GitHubSecurityCliError("Repository input cannot be empty.")
92
+
93
+ owner_repo_match = re.fullmatch(
94
+ r"(?P<owner>[^/]+)/(?P<repo>[^/]+)", repository
95
+ )
96
+ if owner_repo_match:
97
+ return (
98
+ GITHUB_DOT_COM_HOST,
99
+ owner_repo_match.group("owner"),
100
+ owner_repo_match.group("repo"),
101
+ "https",
102
+ )
103
+
104
+ remote_match = re.fullmatch(
105
+ r"git@(?P<host>[^:]+):(?P<owner>[^/]+)/(?P<repo>[^/]+)(?:\.git)?",
106
+ repository,
107
+ )
108
+ if remote_match:
109
+ return (
110
+ remote_match.group("host"),
111
+ remote_match.group("owner"),
112
+ remote_match.group("repo"),
113
+ "https",
114
+ )
115
+
116
+ parsed = parse.urlparse(repository)
117
+ if parsed.scheme and parsed.netloc:
118
+ path_segments = [
119
+ segment for segment in parsed.path.split("/") if segment
120
+ ]
121
+ if len(path_segments) < 2:
122
+ raise GitHubSecurityCliError(
123
+ f"Could not parse owner/repo from repository URL '{repository}'."
124
+ )
125
+
126
+ repo_name = path_segments[-1]
127
+ if repo_name.endswith(".git"):
128
+ repo_name = repo_name[:-4]
129
+
130
+ return parsed.netloc, path_segments[-2], repo_name, parsed.scheme
131
+
132
+ raise GitHubSecurityCliError(
133
+ f"Unsupported repository input '{repository}'. Use owner/repo or a GitHub URL."
134
+ )
135
+
136
+
137
+ def parse_remote_url(remote_url: str) -> tuple[str, str, str, str]:
138
+ """Parse a git remote URL into host/owner/repo/scheme."""
139
+
140
+ return parse_repository_input(remote_url)
141
+
142
+
143
+ def resolve_token(token_envs: list[str] | None) -> tuple[str, str]:
144
+ """Resolve the first non-empty token from the candidate environment variables."""
145
+
146
+ candidates = token_envs or list(DEFAULT_TOKEN_ENVS)
147
+
148
+ for candidate in candidates:
149
+ token = os.environ.get(candidate)
150
+ if token and token.strip():
151
+ return candidate, token.strip()
152
+
153
+ candidate_text = ", ".join(candidates)
154
+ raise GitHubSecurityCliError(
155
+ "Could not find a GitHub token in any configured environment variable "
156
+ f"({candidate_text}).\n"
157
+ "Populate one first, for example in PowerShell:\n"
158
+ "$env:GITHUB_TOKEN = Get-Secret GITHUB_TOKEN -AsPlainText"
159
+ )
160
+
161
+
162
+ def resolve_context(arguments: Any) -> RepoContext:
163
+ """Resolve repository ownership, host, and token context."""
164
+
165
+ repo_path = Path(arguments.repo).expanduser().resolve()
166
+ token_env_name, token = resolve_token(arguments.token_envs)
167
+
168
+ if arguments.repository is not None:
169
+ host, owner, repo_name, scheme = parse_repository_input(
170
+ arguments.repository
171
+ )
172
+ else:
173
+ remote_url = run_git(repo_path, "config", "--get", "remote.origin.url")
174
+ host, owner, repo_name, scheme = parse_remote_url(remote_url)
175
+
176
+ web_base_url = (arguments.web_base_url or f"{scheme}://{host}").rstrip("/")
177
+ if arguments.api_base_url is not None:
178
+ api_base_url = arguments.api_base_url.rstrip("/")
179
+ elif host.lower() == GITHUB_DOT_COM_HOST:
180
+ api_base_url = GITHUB_DOT_COM_API_BASE
181
+ else:
182
+ api_base_url = f"{scheme}://{host.rstrip('/')}/api/v3"
183
+
184
+ return RepoContext(
185
+ api_base_url=api_base_url,
186
+ owner=owner,
187
+ repo=repo_name,
188
+ repo_path=repo_path,
189
+ token=token,
190
+ token_env_name=token_env_name,
191
+ web_base_url=web_base_url,
192
+ )
193
+
194
+
195
+ def normalize_query_value(value: Any) -> str:
196
+ """Normalize query-parameter values to GitHub-friendly strings."""
197
+
198
+ if isinstance(value, bool):
199
+ return "true" if value else "false"
200
+
201
+ return str(value)
202
+
203
+
204
+ def build_query_string(params: dict[str, Any] | None) -> str:
205
+ """Serialize query parameters, omitting null values."""
206
+
207
+ if not params:
208
+ return ""
209
+
210
+ normalized_params: list[tuple[str, str]] = []
211
+
212
+ for key, value in params.items():
213
+ if value is None:
214
+ continue
215
+ if isinstance(value, list):
216
+ for item in value:
217
+ normalized_params.append((key, normalize_query_value(item)))
218
+ continue
219
+ normalized_params.append((key, normalize_query_value(value)))
220
+
221
+ if not normalized_params:
222
+ return ""
223
+
224
+ return "?" + parse.urlencode(normalized_params, doseq=True)
225
+
226
+
227
+ def extract_api_error_message(payload: Any) -> str:
228
+ """Extract a readable message from a GitHub API error payload."""
229
+
230
+ if isinstance(payload, dict):
231
+ message = payload.get("message")
232
+ if isinstance(message, str) and message.strip():
233
+ return message.strip()
234
+
235
+ if isinstance(payload, str) and payload.strip():
236
+ return payload.strip()
237
+
238
+ return "GitHub API request failed."
239
+
240
+
241
+ def api_request(
242
+ context: RepoContext,
243
+ *,
244
+ endpoint: str,
245
+ method: str = "GET",
246
+ params: dict[str, Any] | None = None,
247
+ body: dict[str, Any] | list[Any] | None = None,
248
+ accept: str = DEFAULT_ACCEPT,
249
+ ) -> GitHubApiResponse:
250
+ """Send a GitHub REST API request."""
251
+
252
+ query_string = build_query_string(params)
253
+ if endpoint.startswith(("http://", "https://")):
254
+ url = endpoint
255
+ else:
256
+ url = f"{context.api_base_url}{endpoint}"
257
+ url = f"{url}{query_string}"
258
+
259
+ request_body: bytes | None = None
260
+ headers = {
261
+ "Accept": accept,
262
+ "Authorization": f"Bearer {context.token}",
263
+ "User-Agent": "github-manage-security-alerts-skill",
264
+ "X-GitHub-Api-Version": DEFAULT_API_VERSION,
265
+ }
266
+
267
+ json_content_type = "application/json"
268
+
269
+ if body is not None:
270
+ request_body = json.dumps(body).encode("utf-8")
271
+ headers["Content-Type"] = json_content_type
272
+
273
+ http_request = request.Request(
274
+ url,
275
+ data=request_body,
276
+ headers=headers,
277
+ method=method.upper(),
278
+ )
279
+
280
+ try:
281
+ with request.urlopen(http_request) as response:
282
+ response_headers = {
283
+ key.lower(): value for key, value in response.headers.items()
284
+ }
285
+ raw_body = response.read()
286
+ content_type = response_headers.get("content-type", "")
287
+ if not raw_body:
288
+ parsed_body: Any = None
289
+ elif (
290
+ json_content_type in content_type or accept == DEFAULT_ACCEPT
291
+ ):
292
+ parsed_body = json.loads(raw_body.decode("utf-8"))
293
+ else:
294
+ parsed_body = raw_body.decode("utf-8")
295
+
296
+ return GitHubApiResponse(
297
+ data=parsed_body,
298
+ headers=response_headers,
299
+ status_code=response.status,
300
+ url=url,
301
+ )
302
+ except error.HTTPError as exc:
303
+ raw_error_body = exc.read()
304
+ content_type = exc.headers.get("Content-Type", "")
305
+ if raw_error_body:
306
+ if json_content_type in content_type:
307
+ response_data = json.loads(raw_error_body.decode("utf-8"))
308
+ else:
309
+ response_data = raw_error_body.decode("utf-8")
310
+ else:
311
+ response_data = None
312
+
313
+ raise GitHubApiError(
314
+ endpoint=endpoint,
315
+ message=extract_api_error_message(response_data),
316
+ response_data=response_data,
317
+ status_code=exc.code,
318
+ url=url,
319
+ ) from exc
320
+
321
+
322
+ def safe_api_request(
323
+ context: RepoContext,
324
+ *,
325
+ endpoint: str,
326
+ method: str = "GET",
327
+ params: dict[str, Any] | None = None,
328
+ body: dict[str, Any] | list[Any] | None = None,
329
+ accept: str = DEFAULT_ACCEPT,
330
+ ) -> dict[str, Any]:
331
+ """Execute an API request and return a structured success/error result."""
332
+
333
+ try:
334
+ response = api_request(
335
+ context,
336
+ endpoint=endpoint,
337
+ method=method,
338
+ params=params,
339
+ body=body,
340
+ accept=accept,
341
+ )
342
+ except GitHubApiError as exc:
343
+ return {
344
+ "error": {
345
+ "endpoint": exc.endpoint,
346
+ "message": str(exc),
347
+ "response": exc.response_data,
348
+ "status_code": exc.status_code,
349
+ "url": exc.url,
350
+ },
351
+ "ok": False,
352
+ }
353
+
354
+ return {
355
+ "data": response.data,
356
+ "headers": response.headers,
357
+ "ok": True,
358
+ "status_code": response.status_code,
359
+ "url": response.url,
360
+ }