tracktolib 0.68.0__py3-none-any.whl → 0.69.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.
- tracktolib/api.py +12 -13
- tracktolib/cf/__init__.py +8 -0
- tracktolib/cf/client.py +149 -0
- tracktolib/cf/types.py +17 -0
- tracktolib/gh/__init__.py +11 -0
- tracktolib/gh/client.py +206 -0
- tracktolib/gh/types.py +203 -0
- tracktolib/http_utils.py +1 -1
- tracktolib/logs.py +1 -1
- tracktolib/notion/markdown.py +3 -3
- tracktolib/notion/models.py +0 -1
- tracktolib/notion/utils.py +5 -5
- tracktolib/pg/__init__.py +10 -10
- tracktolib/pg/query.py +1 -1
- tracktolib/pg/utils.py +5 -5
- tracktolib/pg_sync.py +3 -5
- tracktolib/pg_utils.py +1 -4
- tracktolib/s3/minio.py +1 -1
- tracktolib/s3/niquests.py +235 -32
- tracktolib/s3/s3.py +1 -1
- tracktolib/utils.py +12 -3
- {tracktolib-0.68.0.dist-info → tracktolib-0.69.0.dist-info}/METADATA +95 -2
- tracktolib-0.69.0.dist-info/RECORD +31 -0
- {tracktolib-0.68.0.dist-info → tracktolib-0.69.0.dist-info}/WHEEL +1 -1
- tracktolib-0.68.0.dist-info/RECORD +0 -25
tracktolib/api.py
CHANGED
|
@@ -11,7 +11,6 @@ from typing import (
|
|
|
11
11
|
Literal,
|
|
12
12
|
Mapping,
|
|
13
13
|
Sequence,
|
|
14
|
-
Type,
|
|
15
14
|
TypeAlias,
|
|
16
15
|
TypedDict,
|
|
17
16
|
get_args,
|
|
@@ -19,14 +18,14 @@ from typing import (
|
|
|
19
18
|
get_type_hints,
|
|
20
19
|
)
|
|
21
20
|
|
|
22
|
-
from .utils import
|
|
21
|
+
from .utils import get_first_line, json_serial
|
|
23
22
|
|
|
24
23
|
try:
|
|
25
|
-
|
|
24
|
+
import starlette.status
|
|
25
|
+
from fastapi import APIRouter, params
|
|
26
26
|
from fastapi.responses import JSONResponse
|
|
27
|
-
from pydantic.alias_generators import to_camel
|
|
28
27
|
from pydantic import BaseModel, ConfigDict
|
|
29
|
-
import
|
|
28
|
+
from pydantic.alias_generators import to_camel
|
|
30
29
|
except ImportError:
|
|
31
30
|
raise ImportError('Please install fastapi, pydantic or tracktolib with "api" to use this module')
|
|
32
31
|
|
|
@@ -61,7 +60,7 @@ class MethodMeta(TypedDict):
|
|
|
61
60
|
status_code: StatusCode
|
|
62
61
|
dependencies: Dependencies
|
|
63
62
|
path: str | None
|
|
64
|
-
response_model:
|
|
63
|
+
response_model: type[BaseModel | None | Sequence[BaseModel]] | None
|
|
65
64
|
openapi_extra: dict[str, Any] | None
|
|
66
65
|
name: str | None
|
|
67
66
|
summary: str | None
|
|
@@ -82,7 +81,7 @@ class Endpoint:
|
|
|
82
81
|
status_code: StatusCode = None,
|
|
83
82
|
dependencies: Dependencies = None,
|
|
84
83
|
path: str | None = None,
|
|
85
|
-
model:
|
|
84
|
+
model: type[B] | None = None,
|
|
86
85
|
openapi_extra: dict[str, Any] | None = None,
|
|
87
86
|
name: str | None = None,
|
|
88
87
|
summary: str | None = None,
|
|
@@ -109,7 +108,7 @@ class Endpoint:
|
|
|
109
108
|
status_code: StatusCode = None,
|
|
110
109
|
dependencies: Dependencies = None,
|
|
111
110
|
path: str | None = None,
|
|
112
|
-
model:
|
|
111
|
+
model: type[B] | None = None,
|
|
113
112
|
openapi_extra: dict[str, Any] | None = None,
|
|
114
113
|
name: str | None = None,
|
|
115
114
|
summary: str | None = None,
|
|
@@ -135,7 +134,7 @@ class Endpoint:
|
|
|
135
134
|
status_code: StatusCode = None,
|
|
136
135
|
dependencies: Dependencies = None,
|
|
137
136
|
path: str | None = None,
|
|
138
|
-
model:
|
|
137
|
+
model: type[B] | None = None,
|
|
139
138
|
openapi_extra: dict[str, Any] | None = None,
|
|
140
139
|
name: str | None = None,
|
|
141
140
|
summary: str | None = None,
|
|
@@ -161,7 +160,7 @@ class Endpoint:
|
|
|
161
160
|
status_code: StatusCode = None,
|
|
162
161
|
dependencies: Dependencies = None,
|
|
163
162
|
path: str | None = None,
|
|
164
|
-
model:
|
|
163
|
+
model: type[B] | None = None,
|
|
165
164
|
openapi_extra: dict[str, Any] | None = None,
|
|
166
165
|
name: str | None = None,
|
|
167
166
|
summary: str | None = None,
|
|
@@ -187,7 +186,7 @@ class Endpoint:
|
|
|
187
186
|
status_code: StatusCode = None,
|
|
188
187
|
dependencies: Dependencies = None,
|
|
189
188
|
path: str | None = None,
|
|
190
|
-
model:
|
|
189
|
+
model: type[B] | None = None,
|
|
191
190
|
openapi_extra: dict[str, Any] | None = None,
|
|
192
191
|
name: str | None = None,
|
|
193
192
|
summary: str | None = None,
|
|
@@ -216,7 +215,7 @@ def _get_method_wrapper[B: _BaseModelBound](
|
|
|
216
215
|
status_code: StatusCode = None,
|
|
217
216
|
dependencies: Dependencies = None,
|
|
218
217
|
path: str | None = None,
|
|
219
|
-
model:
|
|
218
|
+
model: type[B] | None = None,
|
|
220
219
|
openapi_extra: dict[str, Any] | None = None,
|
|
221
220
|
name: str | None = None,
|
|
222
221
|
summary: str | None = None,
|
|
@@ -335,7 +334,7 @@ def check_status(resp, status: int = starlette.status.HTTP_200_OK):
|
|
|
335
334
|
raise AssertionError(json.dumps(resp.json(), indent=4))
|
|
336
335
|
|
|
337
336
|
|
|
338
|
-
def generate_list_name_model[B: _BaseModelBound](model:
|
|
337
|
+
def generate_list_name_model[B: _BaseModelBound](model: type[B], status: int | None = None) -> dict:
|
|
339
338
|
_status = "200" if status is None else str(status)
|
|
340
339
|
if get_origin(model) and get_origin(model) is list:
|
|
341
340
|
_title = f"Array[{get_args(model)[0].__name__}]"
|
tracktolib/cf/client.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import niquests
|
|
9
|
+
except ImportError:
|
|
10
|
+
raise ImportError('Please install niquests or tracktolib with "cf" to use this module')
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from urllib3.util.retry import Retry
|
|
14
|
+
|
|
15
|
+
from tracktolib.cf.types import DnsRecord
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CloudflareError(Exception):
|
|
19
|
+
"""Error raised when a Cloudflare API call fails."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, status_code: int | None = None, errors: list | None = None):
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.errors = errors or []
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CloudflareDNSClient:
|
|
29
|
+
"""
|
|
30
|
+
Async Cloudflare DNS API client for managing DNS records.
|
|
31
|
+
|
|
32
|
+
Requires CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID environment variables,
|
|
33
|
+
or pass them directly to the constructor.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
zone_id: str | None = field(default_factory=lambda: os.environ.get("CLOUDFLARE_ZONE_ID"))
|
|
37
|
+
token: str | None = field(default_factory=lambda: os.environ.get("CLOUDFLARE_API_TOKEN"))
|
|
38
|
+
base_url: str = "https://api.cloudflare.com/client/v4"
|
|
39
|
+
retries: int | Retry = 0
|
|
40
|
+
hooks: Any = None
|
|
41
|
+
session: niquests.AsyncSession = field(init=False, repr=False)
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if not self.token:
|
|
45
|
+
raise ValueError("CLOUDFLARE_API_TOKEN environment variable is required")
|
|
46
|
+
if not self.zone_id:
|
|
47
|
+
raise ValueError("CLOUDFLARE_ZONE_ID environment variable is required")
|
|
48
|
+
|
|
49
|
+
self.session = niquests.AsyncSession(
|
|
50
|
+
base_url=self.base_url,
|
|
51
|
+
retries=self.retries,
|
|
52
|
+
hooks=self.hooks,
|
|
53
|
+
headers={
|
|
54
|
+
"Authorization": f"Bearer {self.token}",
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def __aenter__(self) -> "CloudflareDNSClient":
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
63
|
+
await self.aclose()
|
|
64
|
+
|
|
65
|
+
async def aclose(self) -> None:
|
|
66
|
+
"""Close the underlying session."""
|
|
67
|
+
await self.session.close()
|
|
68
|
+
|
|
69
|
+
def _handle_response(self, response: niquests.Response) -> dict:
|
|
70
|
+
"""Handle Cloudflare API response and raise on errors."""
|
|
71
|
+
data = response.json()
|
|
72
|
+
if not data.get("success", False):
|
|
73
|
+
errors = data.get("errors", [])
|
|
74
|
+
error_messages = [e.get("message", str(e)) for e in errors]
|
|
75
|
+
raise CloudflareError(
|
|
76
|
+
f"Cloudflare API error: {', '.join(error_messages)}",
|
|
77
|
+
status_code=response.status_code,
|
|
78
|
+
errors=errors,
|
|
79
|
+
)
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
# DNS Records
|
|
83
|
+
|
|
84
|
+
async def get_dns_record(self, name: str, record_type: str = "CNAME") -> DnsRecord | None:
|
|
85
|
+
"""
|
|
86
|
+
Get a DNS record by name and type.
|
|
87
|
+
|
|
88
|
+
Returns None if the record doesn't exist.
|
|
89
|
+
"""
|
|
90
|
+
params = {"type": record_type, "name": name}
|
|
91
|
+
response = await self.session.get(f"/zones/{self.zone_id}/dns_records", params=params)
|
|
92
|
+
data = self._handle_response(response)
|
|
93
|
+
|
|
94
|
+
results = data.get("result", [])
|
|
95
|
+
if not results:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
return cast("DnsRecord", results[0])
|
|
99
|
+
|
|
100
|
+
async def create_dns_record(
|
|
101
|
+
self,
|
|
102
|
+
name: str,
|
|
103
|
+
content: str,
|
|
104
|
+
record_type: str = "CNAME",
|
|
105
|
+
*,
|
|
106
|
+
ttl: int = 1,
|
|
107
|
+
proxied: bool = False,
|
|
108
|
+
comment: str | None = None,
|
|
109
|
+
) -> DnsRecord:
|
|
110
|
+
"""
|
|
111
|
+
Create a DNS record.
|
|
112
|
+
|
|
113
|
+
The ttl parameter defaults to 1 (automatic). Set to a value between 60-86400 for manual TTL.
|
|
114
|
+
"""
|
|
115
|
+
payload: dict = {
|
|
116
|
+
"type": record_type,
|
|
117
|
+
"name": name,
|
|
118
|
+
"content": content,
|
|
119
|
+
"ttl": ttl,
|
|
120
|
+
"proxied": proxied,
|
|
121
|
+
}
|
|
122
|
+
if comment:
|
|
123
|
+
payload["comment"] = comment
|
|
124
|
+
|
|
125
|
+
response = await self.session.post(f"/zones/{self.zone_id}/dns_records", json=payload)
|
|
126
|
+
data = self._handle_response(response)
|
|
127
|
+
return cast("DnsRecord", data["result"])
|
|
128
|
+
|
|
129
|
+
async def delete_dns_record(self, record_id: str) -> None:
|
|
130
|
+
"""Delete a DNS record by ID."""
|
|
131
|
+
response = await self.session.delete(f"/zones/{self.zone_id}/dns_records/{record_id}")
|
|
132
|
+
self._handle_response(response)
|
|
133
|
+
|
|
134
|
+
async def delete_dns_record_by_name(self, name: str, record_type: str = "CNAME") -> bool:
|
|
135
|
+
"""
|
|
136
|
+
Delete a DNS record by name and type.
|
|
137
|
+
|
|
138
|
+
Returns True if deleted, False if the record didn't exist.
|
|
139
|
+
"""
|
|
140
|
+
record = await self.get_dns_record(name, record_type)
|
|
141
|
+
if record is None:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
await self.delete_dns_record(record["id"])
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
async def dns_record_exists(self, name: str, record_type: str = "CNAME") -> bool:
|
|
148
|
+
"""Check if a DNS record exists."""
|
|
149
|
+
return await self.get_dns_record(name, record_type) is not None
|
tracktolib/cf/types.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import NotRequired, TypedDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DnsRecord(TypedDict):
|
|
5
|
+
"""Cloudflare DNS record response."""
|
|
6
|
+
|
|
7
|
+
id: str
|
|
8
|
+
name: str
|
|
9
|
+
type: str
|
|
10
|
+
content: str
|
|
11
|
+
ttl: int
|
|
12
|
+
proxied: bool
|
|
13
|
+
proxiable: NotRequired[bool]
|
|
14
|
+
created_on: NotRequired[str]
|
|
15
|
+
modified_on: NotRequired[str]
|
|
16
|
+
comment: NotRequired[str]
|
|
17
|
+
tags: NotRequired[list[str]]
|
tracktolib/gh/client.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, cast
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import niquests
|
|
10
|
+
except ImportError:
|
|
11
|
+
raise ImportError('Please install niquests or tracktolib with "gh" to use this module')
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from urllib3.util.retry import Retry
|
|
15
|
+
|
|
16
|
+
from tracktolib.gh.types import Deployment, DeploymentStatus, IssueComment, Label
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ProgressCallback = Callable[[int, int], None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class GitHubClient:
|
|
24
|
+
"""
|
|
25
|
+
Async GitHub API client for issues, labels, and deployments.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
token: str | None = field(default_factory=lambda: os.environ.get("GITHUB_TOKEN"))
|
|
29
|
+
base_url: str = "https://api.github.com"
|
|
30
|
+
retries: int | Retry = 0
|
|
31
|
+
hooks: Any = None
|
|
32
|
+
session: niquests.AsyncSession = field(init=False, repr=False)
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
if not self.token:
|
|
36
|
+
raise ValueError("GITHUB_TOKEN environment variable is required")
|
|
37
|
+
|
|
38
|
+
self.session = niquests.AsyncSession(
|
|
39
|
+
base_url=self.base_url,
|
|
40
|
+
retries=self.retries,
|
|
41
|
+
hooks=self.hooks,
|
|
42
|
+
headers={
|
|
43
|
+
"Authorization": f"Bearer {self.token}",
|
|
44
|
+
"Accept": "application/vnd.github+json",
|
|
45
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
async def __aenter__(self) -> GitHubClient:
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
53
|
+
await self.aclose()
|
|
54
|
+
|
|
55
|
+
async def aclose(self) -> None:
|
|
56
|
+
"""Close the underlying session."""
|
|
57
|
+
await self.session.close()
|
|
58
|
+
|
|
59
|
+
# Issue Comments
|
|
60
|
+
|
|
61
|
+
async def get_issue_comments(self, repository: str, issue_number: int) -> list[IssueComment]:
|
|
62
|
+
"""Get all comments on an issue or PR."""
|
|
63
|
+
response = await self.session.get(f"/repos/{repository}/issues/{issue_number}/comments")
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
return cast("list[IssueComment]", response.json())
|
|
66
|
+
|
|
67
|
+
async def create_issue_comment(self, repository: str, issue_number: int, body: str) -> IssueComment:
|
|
68
|
+
"""Create a comment on an issue or PR."""
|
|
69
|
+
response = await self.session.post(f"/repos/{repository}/issues/{issue_number}/comments", json={"body": body})
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
return cast("IssueComment", response.json())
|
|
72
|
+
|
|
73
|
+
async def delete_issue_comment(self, repository: str, comment_id: int) -> None:
|
|
74
|
+
"""Delete a comment by ID."""
|
|
75
|
+
response = await self.session.delete(f"/repos/{repository}/issues/comments/{comment_id}")
|
|
76
|
+
response.raise_for_status()
|
|
77
|
+
|
|
78
|
+
async def find_comments_with_marker(self, repository: str, issue_number: int, marker: str) -> list[int]:
|
|
79
|
+
"""Find comment IDs containing a specific marker string."""
|
|
80
|
+
comments = await self.get_issue_comments(repository, issue_number)
|
|
81
|
+
return [c["id"] for c in comments if marker in c.get("body", "")]
|
|
82
|
+
|
|
83
|
+
async def delete_comments_with_marker(
|
|
84
|
+
self,
|
|
85
|
+
repository: str,
|
|
86
|
+
issue_number: int,
|
|
87
|
+
marker: str,
|
|
88
|
+
*,
|
|
89
|
+
on_progress: ProgressCallback | None = None,
|
|
90
|
+
) -> int:
|
|
91
|
+
"""Delete all comments containing a specific marker. Returns count deleted."""
|
|
92
|
+
comment_ids = await self.find_comments_with_marker(repository, issue_number, marker)
|
|
93
|
+
total = len(comment_ids)
|
|
94
|
+
for i, comment_id in enumerate(comment_ids):
|
|
95
|
+
await self.delete_issue_comment(repository, comment_id)
|
|
96
|
+
if on_progress:
|
|
97
|
+
on_progress(i + 1, total)
|
|
98
|
+
return total
|
|
99
|
+
|
|
100
|
+
async def create_idempotent_comment(
|
|
101
|
+
self, repository: str, issue_number: int, body: str, marker: str
|
|
102
|
+
) -> IssueComment | None:
|
|
103
|
+
"""
|
|
104
|
+
Create a comment only if one with the marker doesn't already exist.
|
|
105
|
+
|
|
106
|
+
The marker should be included in the body (e.g., an HTML comment like
|
|
107
|
+
'<!-- my-marker -->'). Returns the created comment, or None if skipped.
|
|
108
|
+
"""
|
|
109
|
+
if await self.find_comments_with_marker(repository, issue_number, marker):
|
|
110
|
+
return None
|
|
111
|
+
return await self.create_issue_comment(repository, issue_number, body)
|
|
112
|
+
|
|
113
|
+
# Labels
|
|
114
|
+
|
|
115
|
+
async def get_issue_labels(self, repository: str, issue_number: int) -> list[Label]:
|
|
116
|
+
"""Get all labels on an issue or PR."""
|
|
117
|
+
response = await self.session.get(f"/repos/{repository}/issues/{issue_number}/labels")
|
|
118
|
+
response.raise_for_status()
|
|
119
|
+
return cast("list[Label]", response.json())
|
|
120
|
+
|
|
121
|
+
async def add_labels(self, repository: str, issue_number: int, labels: list[str]) -> list[Label]:
|
|
122
|
+
"""Add labels to an issue or PR."""
|
|
123
|
+
response = await self.session.post(f"/repos/{repository}/issues/{issue_number}/labels", json={"labels": labels})
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
return cast("list[Label]", response.json())
|
|
126
|
+
|
|
127
|
+
async def remove_label(self, repository: str, issue_number: int, label: str) -> bool:
|
|
128
|
+
"""Remove a label from an issue/PR. Returns True if removed, False if not found."""
|
|
129
|
+
response = await self.session.delete(
|
|
130
|
+
f"/repos/{repository}/issues/{issue_number}/labels/{quote(label, safe='')}"
|
|
131
|
+
)
|
|
132
|
+
if response.status_code == 404:
|
|
133
|
+
return False
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
# Deployments
|
|
138
|
+
|
|
139
|
+
async def get_deployments(self, repository: str, *, environment: str | None = None) -> list[Deployment]:
|
|
140
|
+
"""Get deployments, optionally filtered by environment."""
|
|
141
|
+
params = {"environment": environment} if environment else {}
|
|
142
|
+
response = await self.session.get(f"/repos/{repository}/deployments", params=params)
|
|
143
|
+
response.raise_for_status()
|
|
144
|
+
return cast("list[Deployment]", response.json())
|
|
145
|
+
|
|
146
|
+
async def create_deployment_status(
|
|
147
|
+
self,
|
|
148
|
+
repository: str,
|
|
149
|
+
deployment_id: int,
|
|
150
|
+
state: str,
|
|
151
|
+
*,
|
|
152
|
+
description: str | None = None,
|
|
153
|
+
environment_url: str | None = None,
|
|
154
|
+
) -> DeploymentStatus:
|
|
155
|
+
"""
|
|
156
|
+
Create a deployment status.
|
|
157
|
+
|
|
158
|
+
State can be: error, failure, inactive, in_progress, queued, pending, success.
|
|
159
|
+
"""
|
|
160
|
+
payload: dict = {"state": state}
|
|
161
|
+
if description:
|
|
162
|
+
payload["description"] = description
|
|
163
|
+
if environment_url:
|
|
164
|
+
payload["environment_url"] = environment_url
|
|
165
|
+
response = await self.session.post(f"/repos/{repository}/deployments/{deployment_id}/statuses", json=payload)
|
|
166
|
+
response.raise_for_status()
|
|
167
|
+
return cast("DeploymentStatus", response.json())
|
|
168
|
+
|
|
169
|
+
async def get_deployment_statuses(
|
|
170
|
+
self,
|
|
171
|
+
repository: str,
|
|
172
|
+
deployment_id: int,
|
|
173
|
+
) -> list[DeploymentStatus]:
|
|
174
|
+
"""Get all statuses for a deployment, most recent first."""
|
|
175
|
+
response = await self.session.get(f"/repos/{repository}/deployments/{deployment_id}/statuses")
|
|
176
|
+
response.raise_for_status()
|
|
177
|
+
return cast("list[DeploymentStatus]", response.json())
|
|
178
|
+
|
|
179
|
+
async def get_latest_deployment_status(
|
|
180
|
+
self,
|
|
181
|
+
repository: str,
|
|
182
|
+
environment: str,
|
|
183
|
+
) -> DeploymentStatus | None:
|
|
184
|
+
"""Get the latest deployment status for an environment."""
|
|
185
|
+
deployments = await self.get_deployments(repository, environment=environment)
|
|
186
|
+
if not deployments:
|
|
187
|
+
return None
|
|
188
|
+
statuses = await self.get_deployment_statuses(repository, deployments[0]["id"])
|
|
189
|
+
return statuses[0] if statuses else None
|
|
190
|
+
|
|
191
|
+
async def mark_deployment_inactive(
|
|
192
|
+
self,
|
|
193
|
+
repository: str,
|
|
194
|
+
environment: str,
|
|
195
|
+
*,
|
|
196
|
+
description: str = "Environment removed",
|
|
197
|
+
on_progress: ProgressCallback | None = None,
|
|
198
|
+
) -> int:
|
|
199
|
+
"""Mark all deployments for an environment as inactive. Returns count updated."""
|
|
200
|
+
deployments = await self.get_deployments(repository, environment=environment)
|
|
201
|
+
total = len(deployments)
|
|
202
|
+
for i, deployment in enumerate(deployments):
|
|
203
|
+
await self.create_deployment_status(repository, deployment["id"], "inactive", description=description)
|
|
204
|
+
if on_progress:
|
|
205
|
+
on_progress(i + 1, total)
|
|
206
|
+
return total
|
tracktolib/gh/types.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# generated by datamodel-codegen:
|
|
2
|
+
# timestamp: 2026-02-02T13:26:29+00:00
|
|
3
|
+
|
|
4
|
+
from typing import Any, Literal, NotRequired, TypedDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SimpleUser(TypedDict):
|
|
8
|
+
"""
|
|
9
|
+
A GitHub user.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
name: NotRequired[str]
|
|
13
|
+
email: NotRequired[str]
|
|
14
|
+
login: str
|
|
15
|
+
id: int
|
|
16
|
+
node_id: str
|
|
17
|
+
avatar_url: str
|
|
18
|
+
gravatar_id: str
|
|
19
|
+
url: str
|
|
20
|
+
html_url: str
|
|
21
|
+
followers_url: str
|
|
22
|
+
following_url: str
|
|
23
|
+
gists_url: str
|
|
24
|
+
starred_url: str
|
|
25
|
+
subscriptions_url: str
|
|
26
|
+
organizations_url: str
|
|
27
|
+
repos_url: str
|
|
28
|
+
events_url: str
|
|
29
|
+
received_events_url: str
|
|
30
|
+
type: str
|
|
31
|
+
site_admin: bool
|
|
32
|
+
starred_at: NotRequired[str]
|
|
33
|
+
user_view_type: NotRequired[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NullableSimpleUser(TypedDict):
|
|
37
|
+
"""
|
|
38
|
+
A GitHub user.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: NotRequired[str]
|
|
42
|
+
email: NotRequired[str]
|
|
43
|
+
login: str
|
|
44
|
+
id: int
|
|
45
|
+
node_id: str
|
|
46
|
+
avatar_url: str
|
|
47
|
+
gravatar_id: str
|
|
48
|
+
url: str
|
|
49
|
+
html_url: str
|
|
50
|
+
followers_url: str
|
|
51
|
+
following_url: str
|
|
52
|
+
gists_url: str
|
|
53
|
+
starred_url: str
|
|
54
|
+
subscriptions_url: str
|
|
55
|
+
organizations_url: str
|
|
56
|
+
repos_url: str
|
|
57
|
+
events_url: str
|
|
58
|
+
received_events_url: str
|
|
59
|
+
type: str
|
|
60
|
+
site_admin: bool
|
|
61
|
+
starred_at: NotRequired[str]
|
|
62
|
+
user_view_type: NotRequired[str]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Label(TypedDict):
|
|
66
|
+
"""
|
|
67
|
+
Color-coded labels help you categorize and filter your issues (just like labels in Gmail).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
id: int
|
|
71
|
+
node_id: str
|
|
72
|
+
url: str
|
|
73
|
+
name: str
|
|
74
|
+
description: str
|
|
75
|
+
color: str
|
|
76
|
+
default: bool
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Permissions(TypedDict, total=False):
|
|
80
|
+
"""
|
|
81
|
+
The set of permissions for the GitHub app.
|
|
82
|
+
|
|
83
|
+
Note: GitHub returns many dynamic permission fields; only common ones are typed.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
issues: str
|
|
87
|
+
checks: str
|
|
88
|
+
metadata: str
|
|
89
|
+
contents: str
|
|
90
|
+
deployments: str
|
|
91
|
+
pull_requests: str
|
|
92
|
+
statuses: str
|
|
93
|
+
actions: str
|
|
94
|
+
administration: str
|
|
95
|
+
pages: str
|
|
96
|
+
packages: str
|
|
97
|
+
security_events: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
ReactionRollup = TypedDict(
|
|
101
|
+
"ReactionRollup",
|
|
102
|
+
{
|
|
103
|
+
"url": str,
|
|
104
|
+
"total_count": int,
|
|
105
|
+
"+1": int,
|
|
106
|
+
"-1": int,
|
|
107
|
+
"laugh": int,
|
|
108
|
+
"confused": int,
|
|
109
|
+
"heart": int,
|
|
110
|
+
"hooray": int,
|
|
111
|
+
"eyes": int,
|
|
112
|
+
"rocket": int,
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class NullableIntegration(TypedDict):
|
|
118
|
+
"""
|
|
119
|
+
GitHub apps are a new way to extend GitHub. They can be installed directly on organizations and user accounts and granted access to specific repositories. They come with granular permissions and built-in webhooks. GitHub apps are first class actors within GitHub.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
id: int
|
|
123
|
+
slug: NotRequired[str]
|
|
124
|
+
node_id: str
|
|
125
|
+
client_id: NotRequired[str]
|
|
126
|
+
owner: SimpleUser | Any
|
|
127
|
+
name: str
|
|
128
|
+
description: str
|
|
129
|
+
external_url: str
|
|
130
|
+
html_url: str
|
|
131
|
+
created_at: str
|
|
132
|
+
updated_at: str
|
|
133
|
+
permissions: Permissions
|
|
134
|
+
events: list[str]
|
|
135
|
+
installations_count: NotRequired[int]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class IssueComment(TypedDict):
|
|
139
|
+
"""
|
|
140
|
+
Comments provide a way for people to collaborate on an issue.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
id: int
|
|
144
|
+
node_id: str
|
|
145
|
+
url: str
|
|
146
|
+
body: NotRequired[str]
|
|
147
|
+
body_text: NotRequired[str]
|
|
148
|
+
body_html: NotRequired[str]
|
|
149
|
+
html_url: str
|
|
150
|
+
user: NullableSimpleUser
|
|
151
|
+
created_at: str
|
|
152
|
+
updated_at: str
|
|
153
|
+
issue_url: str
|
|
154
|
+
author_association: NotRequired[Any]
|
|
155
|
+
performed_via_github_app: NotRequired[NullableIntegration]
|
|
156
|
+
reactions: NotRequired[ReactionRollup]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Deployment(TypedDict):
|
|
160
|
+
"""
|
|
161
|
+
A request for a specific ref(branch,sha,tag) to be deployed
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
url: str
|
|
165
|
+
id: int
|
|
166
|
+
node_id: str
|
|
167
|
+
sha: str
|
|
168
|
+
ref: str
|
|
169
|
+
task: str
|
|
170
|
+
payload: dict[str, Any] | str
|
|
171
|
+
original_environment: NotRequired[str]
|
|
172
|
+
environment: str
|
|
173
|
+
description: str
|
|
174
|
+
creator: NullableSimpleUser
|
|
175
|
+
created_at: str
|
|
176
|
+
updated_at: str
|
|
177
|
+
statuses_url: str
|
|
178
|
+
repository_url: str
|
|
179
|
+
transient_environment: NotRequired[bool]
|
|
180
|
+
production_environment: NotRequired[bool]
|
|
181
|
+
performed_via_github_app: NotRequired[NullableIntegration]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class DeploymentStatus(TypedDict):
|
|
185
|
+
"""
|
|
186
|
+
The status of a deployment.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
url: str
|
|
190
|
+
id: int
|
|
191
|
+
node_id: str
|
|
192
|
+
state: Literal["error", "failure", "inactive", "pending", "success", "queued", "in_progress"]
|
|
193
|
+
creator: NullableSimpleUser
|
|
194
|
+
description: str
|
|
195
|
+
environment: NotRequired[str]
|
|
196
|
+
target_url: str
|
|
197
|
+
created_at: str
|
|
198
|
+
updated_at: str
|
|
199
|
+
deployment_url: str
|
|
200
|
+
repository_url: str
|
|
201
|
+
environment_url: NotRequired[str]
|
|
202
|
+
log_url: NotRequired[str]
|
|
203
|
+
performed_via_github_app: NotRequired[NullableIntegration]
|