ghapi 2.1.2__tar.gz → 2.1.4__tar.gz
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.
- {ghapi-2.1.2/ghapi.egg-info → ghapi-2.1.4}/PKG-INFO +6 -5
- {ghapi-2.1.2 → ghapi-2.1.4}/README.md +1 -1
- ghapi-2.1.4/ghapi/__init__.py +8 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/_modidx.py +2 -1
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/core.py +97 -68
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/gh_spec.py +770 -66
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/gql_spec.py +29 -21
- ghapi-2.1.4/ghapi/graphql.py +47 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/page.py +1 -1
- ghapi-2.1.4/ghapi/skill.py +80 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/templates.py +4 -4
- {ghapi-2.1.2 → ghapi-2.1.4/ghapi.egg-info}/PKG-INFO +6 -5
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi.egg-info/requires.txt +4 -3
- {ghapi-2.1.2 → ghapi-2.1.4}/pyproject.toml +3 -3
- ghapi-2.1.2/ghapi/__init__.py +0 -8
- ghapi-2.1.2/ghapi/graphql.py +0 -39
- ghapi-2.1.2/ghapi/skill.py +0 -136
- {ghapi-2.1.2 → ghapi-2.1.4}/CONTRIBUTING.md +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/LICENSE +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/MANIFEST.in +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/actions.py +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/all.py +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/auth.py +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/build_lib.py +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/cli.py +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/event.py +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/py.typed +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi.egg-info/SOURCES.txt +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi.egg-info/dependency_links.txt +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi.egg-info/entry_points.txt +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/ghapi.egg-info/top_level.txt +0 -0
- {ghapi-2.1.2 → ghapi-2.1.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ghapi
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.4
|
|
4
4
|
Summary: A python client for the GitHub API
|
|
5
5
|
Author-email: Jeremy Howard <info@fast.ai>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -15,8 +15,9 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
15
15
|
Requires-Python: >=3.10
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
License-File: LICENSE
|
|
18
|
-
Requires-Dist: fastcore>=2.2.
|
|
19
|
-
Requires-Dist: fastspec>=0.2.
|
|
18
|
+
Requires-Dist: fastcore>=2.2.23
|
|
19
|
+
Requires-Dist: fastspec>=0.2.5
|
|
20
|
+
Requires-Dist: fasttransport>=0.0.2
|
|
20
21
|
Requires-Dist: tomli; python_version < "3.11"
|
|
21
22
|
Provides-Extra: dev
|
|
22
23
|
Requires-Dist: fastgit; extra == "dev"
|
|
@@ -26,7 +27,7 @@ Requires-Dist: nbdev; extra == "dev"
|
|
|
26
27
|
Requires-Dist: pyyaml; extra == "dev"
|
|
27
28
|
Requires-Dist: pysym2md>=0.0.6; extra == "dev"
|
|
28
29
|
Requires-Dist: llms-txt>=0.0.6; extra == "dev"
|
|
29
|
-
Requires-Dist: pyskills; extra == "dev"
|
|
30
|
+
Requires-Dist: pyskills>=0.0.30; extra == "dev"
|
|
30
31
|
Dynamic: license-file
|
|
31
32
|
|
|
32
33
|
# ghapi
|
|
@@ -153,7 +154,7 @@ api.git
|
|
|
153
154
|
- [git.create_tree](https://docs.github.com/rest/git/trees#create-a-tree)(owner, repo, tree, base_tree): *Create a tree*
|
|
154
155
|
- [git.get_tree](https://docs.github.com/rest/git/trees#get-a-tree)(owner, repo, tree_sha, recursive): *Get a tree*
|
|
155
156
|
|
|
156
|
-
|
|
157
|
+
Read an endpoint’s full documentation on the generated object. Its display includes parameter descriptions, bound defaults, request controls, and async usage. With pyskills, use `doc(api.git.get_ref)` for the same documentation, or `xdir(api.git, 'ref')` to search names. Construction and discovery send no requests:
|
|
157
158
|
|
|
158
159
|
``` python
|
|
159
160
|
api.git.get_ref
|
|
@@ -122,7 +122,7 @@ api.git
|
|
|
122
122
|
- [git.create_tree](https://docs.github.com/rest/git/trees#create-a-tree)(owner, repo, tree, base_tree): *Create a tree*
|
|
123
123
|
- [git.get_tree](https://docs.github.com/rest/git/trees#get-a-tree)(owner, repo, tree_sha, recursive): *Get a tree*
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
Read an endpoint’s full documentation on the generated object. Its display includes parameter descriptions, bound defaults, request controls, and async usage. With pyskills, use `doc(api.git.get_ref)` for the same documentation, or `xdir(api.git, 'ref')` to search names. Construction and discovery send no requests:
|
|
126
126
|
|
|
127
127
|
``` python
|
|
128
128
|
api.git.get_ref
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""A python client for the GitHub API
|
|
2
|
+
|
|
3
|
+
Modules:
|
|
4
|
+
|
|
5
|
+
- `ghapi.graphql`: Query GitHub's GraphQL API with schema-aware chaining, batched requests and raw GraphQL
|
|
6
|
+
- `ghapi.skill`: GitHub REST and GraphQL access, plus local git operations. Read before working with GitHub issues, PRs, CI, releases, branches, or gists, and before repo-local git operations."""
|
|
7
|
+
|
|
8
|
+
__version__ = "2.1.4"
|
|
@@ -90,10 +90,11 @@ d = { 'settings': { 'branch': 'main',
|
|
|
90
90
|
'ghapi.core._age': ('core.html#_age', 'ghapi/core.py'),
|
|
91
91
|
'ghapi.core._dur': ('core.html#_dur', 'ghapi/core.py'),
|
|
92
92
|
'ghapi.core._filter_diff': ('core.html#_filter_diff', 'ghapi/core.py'),
|
|
93
|
-
'ghapi.core._find_matches': ('core.html#_find_matches', 'ghapi/core.py'),
|
|
94
93
|
'ghapi.core._fmt_replies': ('core.html#_fmt_replies', 'ghapi/core.py'),
|
|
95
94
|
'ghapi.core._include': ('core.html#_include', 'ghapi/core.py'),
|
|
96
95
|
'ghapi.core._listed': ('core.html#_listed', 'ghapi/core.py'),
|
|
96
|
+
'ghapi.core._load_tmpl': ('core.html#_load_tmpl', 'ghapi/core.py'),
|
|
97
|
+
'ghapi.core._parse_section': ('core.html#_parse_section', 'ghapi/core.py'),
|
|
97
98
|
'ghapi.core._parse_tmpl': ('core.html#_parse_tmpl', 'ghapi/core.py'),
|
|
98
99
|
'ghapi.core._pr_mark': ('core.html#_pr_mark', 'ghapi/core.py'),
|
|
99
100
|
'ghapi.core._reduce_ctx': ('core.html#_reduce_ctx', 'ghapi/core.py'),
|
|
@@ -5,8 +5,8 @@ Docs: https://ghapi.fast.ai/core.html.md"""
|
|
|
5
5
|
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.
|
|
6
6
|
|
|
7
7
|
# %% auto #0
|
|
8
|
-
__all__ = ['GH_HOST', 'pspec', 'img_md_pat', 'EMPTY_TREE_SHA', 'MAXLEN', '
|
|
9
|
-
'gh_patch', 'GhApi', 'call_gh', 'date2gh', 'gh2date', 'GhRows', '
|
|
8
|
+
__all__ = ['GH_HOST', 'pspec', 'img_md_pat', 'EMPTY_TREE_SHA', 'MAXLEN', 'print_summary', 'GhTransport', 'GhSyncTransport',
|
|
9
|
+
'gh_patch', 'GhApi', 'call_gh', 'date2gh', 'gh2date', 'GhRows', 'create_release', 'delete_release',
|
|
10
10
|
'list_tags', 'list_branches', 'create_branch_empty', 'delete_tag', 'delete_branch', 'get_branch',
|
|
11
11
|
'commit_tree', 'list_files', 'get_content', 'create_or_update_file', 'create_file', 'delete_file',
|
|
12
12
|
'update_contents', 'enable_pages', 'read_issue', 'read_pr', 'pr_file_diff', 'PullRow', 'PullRows',
|
|
@@ -18,17 +18,18 @@ __all__ = ['GH_HOST', 'pspec', 'img_md_pat', 'EMPTY_TREE_SHA', 'MAXLEN', 'GhTran
|
|
|
18
18
|
from contextvars import ContextVar
|
|
19
19
|
|
|
20
20
|
from fastcore.all import *
|
|
21
|
+
from fastcore.ansi import strip_ansi
|
|
21
22
|
from fastspec.spec import SpecParser
|
|
22
23
|
from fastspec.oapi import OpenAPIClient, OpFunc, SyncOpFunc
|
|
23
24
|
from fasttransport.core import AsyncTransport, SyncTransport
|
|
24
25
|
from .gh_spec import spec
|
|
25
|
-
from
|
|
26
|
+
from fasttransport.errors import APIError
|
|
26
27
|
|
|
27
28
|
import mimetypes,base64
|
|
28
29
|
from collections import Counter
|
|
29
30
|
from urllib.parse import quote
|
|
30
31
|
from datetime import datetime, timezone
|
|
31
|
-
import os, shutil, tempfile, subprocess, fnmatch, html
|
|
32
|
+
import os, shutil, tempfile, subprocess, fnmatch, html, io, zipfile
|
|
32
33
|
import asyncio
|
|
33
34
|
|
|
34
35
|
try: import tomllib
|
|
@@ -41,9 +42,17 @@ _all_ = ['APIError']
|
|
|
41
42
|
GH_HOST = os.getenv('GH_HOST', "https://api.github.com")
|
|
42
43
|
_DOC_URL = 'https://docs.github.com/'
|
|
43
44
|
|
|
45
|
+
_docroot = 'https://docs.github.com/rest/reference/'
|
|
46
|
+
|
|
44
47
|
# %% ../nbs/00_core.ipynb #ba730c65
|
|
45
48
|
pspec = SpecParser.from_dict(spec)
|
|
46
49
|
|
|
50
|
+
# %% ../nbs/00_core.ipynb #f361159b
|
|
51
|
+
def print_summary(method, url, kwargs):
|
|
52
|
+
"Debug callback for `GhApi(debug=...)`: print each request with the token (if any) removed"
|
|
53
|
+
hdrs = {k:v for k,v in (kwargs.get('headers') or {}).items() if k.lower()!='authorization'}
|
|
54
|
+
print(method, url, {**kwargs, 'headers':hdrs})
|
|
55
|
+
|
|
47
56
|
# %% ../nbs/00_core.ipynb #d4a51c1a
|
|
48
57
|
_binary_cts = ('octet-stream', 'zip', 'gzip', 'tar', 'image/', 'audio/', 'video/')
|
|
49
58
|
|
|
@@ -88,15 +97,6 @@ class GhSyncTransport(SyncTransport, GhTransport):
|
|
|
88
97
|
self._pre(method, url, kwargs)
|
|
89
98
|
return self._post(SyncTransport.request(self, method, url, raw=True, **kwargs), raw)
|
|
90
99
|
|
|
91
|
-
# %% ../nbs/00_core.ipynb #531aee7d
|
|
92
|
-
_docroot = 'https://docs.github.com/rest/reference/'
|
|
93
|
-
|
|
94
|
-
# %% ../nbs/00_core.ipynb #f361159b
|
|
95
|
-
def print_summary(method, url, kwargs):
|
|
96
|
-
"Debug callback for `GhApi(debug=...)`: print each request with the token (if any) removed"
|
|
97
|
-
hdrs = {k:v for k,v in (kwargs.get('headers') or {}).items() if k.lower()!='authorization'}
|
|
98
|
-
print(method, url, {**kwargs, 'headers':hdrs})
|
|
99
|
-
|
|
100
100
|
# %% ../nbs/00_core.ipynb #cecd0177
|
|
101
101
|
_gh_override = ContextVar('_gh_override', default={})
|
|
102
102
|
|
|
@@ -119,8 +119,20 @@ def gh_patch(fn):
|
|
|
119
119
|
# %% ../nbs/00_core.ipynb #83e8a9ce
|
|
120
120
|
class GhApi(OpenAPIClient):
|
|
121
121
|
"GitHub API client. Endpoint groups (`issues`, `pulls`, ...) are generated per-instance from GitHub's OpenAPI metadata, so the class shows only convenience methods -- inspect a live instance, e.g. `doc(GhApi())`, to see the full API."
|
|
122
|
-
def __init__(self,
|
|
123
|
-
|
|
122
|
+
def __init__(self,
|
|
123
|
+
owner=None, # Default repository owner; endpoint calls can override it
|
|
124
|
+
repo=None, # Default repository name; endpoint calls can override it
|
|
125
|
+
token=None, # Personal access token; otherwise GITHUB_TOKEN
|
|
126
|
+
jwt_token=None, # JWT; otherwise GITHUB_JWT_TOKEN; takes precedence over token
|
|
127
|
+
debug=None, # Request callback (method, url, kwargs); print_summary omits authorization
|
|
128
|
+
limit_cb=None, # Callback (remaining, quota) when rate-limit headers change
|
|
129
|
+
gh_host=None, # API base URL; otherwise GH_HOST
|
|
130
|
+
authenticate=True, # False skips credentials; missing credentials otherwise warn and remain unauthenticated
|
|
131
|
+
timeout=60.0, # Request timeout in seconds
|
|
132
|
+
sync=False, # Generated endpoints block instead of returning awaitables; async convenience methods still need an async client
|
|
133
|
+
**kwargs # Additional default route/query parameters
|
|
134
|
+
):
|
|
135
|
+
"Construct the generated API locally; no request is made until an operation is called"
|
|
124
136
|
self.headers = { 'Accept': 'application/vnd.github.v3+json' }
|
|
125
137
|
if authenticate:
|
|
126
138
|
token = token or os.getenv('GITHUB_TOKEN', None)
|
|
@@ -138,14 +150,6 @@ class GhApi(OpenAPIClient):
|
|
|
138
150
|
self.groups = mk_groups(self.ops)
|
|
139
151
|
for k,v in self.groups.items(): setattr(self, k, v)
|
|
140
152
|
|
|
141
|
-
def __call__(self, path:str, verb:str=None, headers:dict=None, route:dict=None, query:dict=None, data=None):
|
|
142
|
-
"Call a fully specified `path` (or full URL) using HTTP `verb` directly (returns an awaitable on an async client)"
|
|
143
|
-
if verb is None: verb = 'POST' if data else 'GET'
|
|
144
|
-
if route: path = path.format(**{k:quote(str(v), safe='') for k,v in route.items()})
|
|
145
|
-
if not path.startswith(('http://', 'https://')): path = self.gh_host + path
|
|
146
|
-
kw = dict(content=data) if isinstance(data, (bytes,str)) else dict(json=data)
|
|
147
|
-
return self.transport.request(verb, path, headers=headers, params=query, **kw)
|
|
148
|
-
|
|
149
153
|
@property
|
|
150
154
|
def debug(self): return self.transport.debug
|
|
151
155
|
@debug.setter
|
|
@@ -157,10 +161,24 @@ class GhApi(OpenAPIClient):
|
|
|
157
161
|
|
|
158
162
|
def _repr_markdown_(self): return "\n".join(f"- [{o}]({_docroot + o.replace('_', '-')})" for o in sorted(self.groups))
|
|
159
163
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
|
|
165
|
+
# %% ../nbs/00_core.ipynb #5ba617de
|
|
166
|
+
@patch
|
|
167
|
+
def __call__(self:GhApi, path:str, verb:str=None, headers:dict=None, route:dict=None, query:dict=None, data=None):
|
|
168
|
+
"Call a fully specified `path` (or full URL) using HTTP `verb` directly (returns an awaitable on an async client)"
|
|
169
|
+
if verb is None: verb = 'POST' if data else 'GET'
|
|
170
|
+
if route: path = path.format(**{k:quote(str(v), safe='') for k,v in route.items()})
|
|
171
|
+
if not path.startswith(('http://', 'https://')): path = self.gh_host + path
|
|
172
|
+
kw = dict(content=data) if isinstance(data, (bytes,str)) else dict(json=data)
|
|
173
|
+
return self.transport.request(verb, path, headers=headers, params=query, **kw)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# %% ../nbs/00_core.ipynb #2f980e70
|
|
177
|
+
@patch
|
|
178
|
+
def __getitem__(self:GhApi, k):
|
|
179
|
+
"Lookup an endpoint by path and verb (which defaults to 'GET')"
|
|
180
|
+
a,b = k if isinstance(k,tuple) else (k,'GET')
|
|
181
|
+
return self.func_dict[f'{a}:{b.upper()}']
|
|
164
182
|
|
|
165
183
|
# %% ../nbs/00_core.ipynb #562eee39
|
|
166
184
|
def call_gh(op:str, *args, token=None, **kwargs):
|
|
@@ -171,12 +189,13 @@ def call_gh(op:str, *args, token=None, **kwargs):
|
|
|
171
189
|
|
|
172
190
|
# %% ../nbs/00_core.ipynb #05cbdf91
|
|
173
191
|
def date2gh(dt:datetime)->str:
|
|
174
|
-
"Convert `dt`
|
|
175
|
-
|
|
192
|
+
"Convert `dt` to GitHub's UTC timestamp format; naive datetimes are assumed UTC"
|
|
193
|
+
if dt.tzinfo is not None: dt = dt.astimezone(timezone.utc)
|
|
194
|
+
return f'{dt.replace(tzinfo=None, microsecond=0).isoformat()}Z'
|
|
176
195
|
|
|
177
196
|
# %% ../nbs/00_core.ipynb #3f4c8b27
|
|
178
197
|
def gh2date(dtstr:str)->datetime:
|
|
179
|
-
"Convert
|
|
198
|
+
"Convert a GitHub UTC date string to a naive UTC `datetime`"
|
|
180
199
|
return datetime.fromisoformat(dtstr.replace('Z', ''))
|
|
181
200
|
|
|
182
201
|
# %% ../nbs/00_core.ipynb #7876622c
|
|
@@ -230,13 +249,6 @@ async def update_gist(self:GhApi, gist_id:str, content:str):
|
|
|
230
249
|
fname = first(gist.files.keys())
|
|
231
250
|
return (await self.gists.update(gist_id, files={fname: {'content': content}})).html_url
|
|
232
251
|
|
|
233
|
-
# %% ../nbs/00_core.ipynb #4b7a278c
|
|
234
|
-
@gh_patch
|
|
235
|
-
async def delete_release(self:GhApi, release):
|
|
236
|
-
"Delete a release and its associated tag"
|
|
237
|
-
await self.repos.delete_release(release.id)
|
|
238
|
-
await self.git.delete_ref(f'tags/{release.tag_name}')
|
|
239
|
-
|
|
240
252
|
# %% ../nbs/00_core.ipynb #b2bf7e22
|
|
241
253
|
@patch
|
|
242
254
|
async def upload_file(self:GhApi, rel, fn):
|
|
@@ -246,17 +258,23 @@ async def upload_file(self:GhApi, rel, fn):
|
|
|
246
258
|
mime = mimetypes.guess_type(fn, False)[0] or 'application/octet-stream'
|
|
247
259
|
return await self(url, 'POST', headers={'Content-Type':mime}, query = {'name':fn.name}, data=fn.read_bytes())
|
|
248
260
|
|
|
249
|
-
# %% ../nbs/00_core.ipynb #3cad71a4
|
|
250
261
|
@gh_patch
|
|
251
262
|
async def create_release(self:GhApi, tag_name, branch=None, name=None, body='',
|
|
252
|
-
draft=False, prerelease=False, files=None, make_latest=UNSET):
|
|
263
|
+
draft=False, prerelease=False, files=None, make_latest=UNSET, generate_release_notes=UNSET):
|
|
253
264
|
"Wrapper for `GhApi.repos.create_release` which also uploads `files`; `branch` defaults to the repo's default branch"
|
|
254
265
|
if name is None: name = 'v'+tag_name
|
|
255
266
|
rel = await self.repos.create_release(tag_name, target_commitish=branch or UNSET, name=name, body=body,
|
|
256
|
-
draft=draft, prerelease=prerelease, make_latest=make_latest)
|
|
267
|
+
draft=draft, prerelease=prerelease, make_latest=make_latest, generate_release_notes=generate_release_notes)
|
|
257
268
|
for file in listify(files): await self.upload_file(rel, file)
|
|
258
269
|
return rel
|
|
259
270
|
|
|
271
|
+
# %% ../nbs/00_core.ipynb #4b7a278c
|
|
272
|
+
@gh_patch
|
|
273
|
+
async def delete_release(self:GhApi, release):
|
|
274
|
+
"Delete a release and its associated tag"
|
|
275
|
+
await self.repos.delete_release(release.id)
|
|
276
|
+
await self.git.delete_ref(f'tags/{release.tag_name}')
|
|
277
|
+
|
|
260
278
|
# %% ../nbs/00_core.ipynb #2be73ae0
|
|
261
279
|
@gh_patch
|
|
262
280
|
async def list_tags(self:GhApi, prefix:str=''):
|
|
@@ -340,7 +358,6 @@ async def create_or_update_file(self:GhApi, path, message, committer, author, co
|
|
|
340
358
|
return await self.repos.create_or_update_file_contents(path, message, content=content,
|
|
341
359
|
branch=branch, committer=committer or {}, author=author or {}, **kwargs)
|
|
342
360
|
|
|
343
|
-
# %% ../nbs/00_core.ipynb #5044445d
|
|
344
361
|
@gh_patch
|
|
345
362
|
async def create_file(self:GhApi, path, message, committer, author, content=None, branch=None):
|
|
346
363
|
if not branch: branch = (await self.repos.get())['default_branch']
|
|
@@ -350,21 +367,16 @@ async def create_file(self:GhApi, path, message, committer, author, content=None
|
|
|
350
367
|
@gh_patch
|
|
351
368
|
async def delete_file(self:GhApi, path, message, committer, author, sha=None, branch=None):
|
|
352
369
|
if not branch: branch = (await self.repos.get())['default_branch']
|
|
353
|
-
if sha is None: sha = (await self.list_files())[path].sha
|
|
370
|
+
if sha is None: sha = (await self.list_files(branch))[path].sha
|
|
354
371
|
return await self.repos.delete_file(path, message=message, sha=sha, branch=branch, committer=committer, author=author)
|
|
355
372
|
|
|
356
373
|
# %% ../nbs/00_core.ipynb #93f6b559
|
|
357
374
|
@gh_patch
|
|
358
375
|
async def update_contents(self:GhApi, path, message, committer, author, content, sha=None, branch=None):
|
|
359
376
|
if not branch: branch = (await self.repos.get())['default_branch']
|
|
360
|
-
if sha is None: sha = (await self.list_files())[path].sha
|
|
377
|
+
if sha is None: sha = (await self.list_files(branch))[path].sha
|
|
361
378
|
return await self.create_or_update_file(path, message, committer=committer, author=author, content=content, sha=sha, branch=branch)
|
|
362
379
|
|
|
363
|
-
# %% ../nbs/00_core.ipynb #444784ce
|
|
364
|
-
def _find_matches(path, pats):
|
|
365
|
-
"Returns matched patterns"
|
|
366
|
-
return L(pats).filter(lambda p: fnmatch.fnmatch(path, p))
|
|
367
|
-
|
|
368
380
|
# %% ../nbs/00_core.ipynb #9ffb3b8f
|
|
369
381
|
def _include(path, include, exclude):
|
|
370
382
|
"Returns True if path matches include patterns (if any) and doesn't match any exclude pattern."
|
|
@@ -390,7 +402,6 @@ async def get_file_content(self:GhApi, path, owner, repo, branch="main"):
|
|
|
390
402
|
o['content_decoded'] = base64.b64decode(o.content).decode('utf-8')
|
|
391
403
|
return o
|
|
392
404
|
|
|
393
|
-
# %% ../nbs/00_core.ipynb #d02d1bab
|
|
394
405
|
@patch
|
|
395
406
|
@delegates(GhApi.get_repo_files)
|
|
396
407
|
async def get_repo_contents(self:GhApi, owner, repo, branch='main',
|
|
@@ -426,12 +437,15 @@ class _IssueInfo(AttrDict):
|
|
|
426
437
|
|
|
427
438
|
@gh_patch
|
|
428
439
|
async def read_issue(self:GhApi, issue_number:int):
|
|
429
|
-
"Fetch an issue or PR
|
|
440
|
+
"""Fetch an issue or PR as structured data with a compact display.
|
|
441
|
+
|
|
442
|
+
Fields include `title`, `body`, `is_pr`, and general `comments`. PRs also have the full `diff`, inline `review_comments`, and `reviews`. Display the result bare for the summary; inspect those fields for detail. General PR comments use the Issues API, not the Pulls review API.
|
|
443
|
+
"""
|
|
430
444
|
try: pr = await self.pulls.get(issue_number)
|
|
431
445
|
except APIError: pr = None
|
|
432
446
|
iss = await self.issues.get(issue_number)
|
|
433
|
-
res = dict(title=iss.title, body=iss.body or '', is_pr=pr is not None,
|
|
434
|
-
|
|
447
|
+
res = dict(title=iss.title, body=iss.body or '', is_pr=pr is not None,
|
|
448
|
+
comments=await self.issues.list_comments(issue_number))
|
|
435
449
|
if pr is not None:
|
|
436
450
|
res['diff'] = await self.pulls.get(issue_number, headers_={'Accept': 'application/vnd.github.v3.diff'})
|
|
437
451
|
res['review_comments'] = await self.pulls.list_review_comments(issue_number)
|
|
@@ -446,9 +460,9 @@ async def read_issue(self:GhApi, issue_number:int):
|
|
|
446
460
|
def _filter_diff(diff, folder='', skip_files=('_modidx.py',)):
|
|
447
461
|
"Filter unified diff to only include files under `folder`, skipping `skip_files`"
|
|
448
462
|
sections = re.split(r'(?=^diff --git )', diff, flags=re.MULTILINE)
|
|
449
|
-
return ''.join(s for s in sections
|
|
450
|
-
|
|
451
|
-
|
|
463
|
+
return ''.join(s for s in sections
|
|
464
|
+
if s.startswith(f'diff --git a/{folder}')
|
|
465
|
+
and not any(f'/{f} ' in s.split('\n')[0] for f in skip_files))
|
|
452
466
|
|
|
453
467
|
def _reduce_ctx(diff):
|
|
454
468
|
"Keep only diff headers and changed lines"
|
|
@@ -526,6 +540,7 @@ class PullRow(AttrDict):
|
|
|
526
540
|
def line(self, maxlen=MAXLEN): return f'#{self.number} {_age(self.updated_at)} {_pr_mark(self)}{truncstr(self.title, maxlen)}'
|
|
527
541
|
__repr__ = line
|
|
528
542
|
|
|
543
|
+
# %% ../nbs/00_core.ipynb #21b48014
|
|
529
544
|
class PullRows(GhRows):
|
|
530
545
|
"Pull request rows, displayed with titles truncated to `maxlen`"
|
|
531
546
|
def __init__(self, items, maxlen=MAXLEN):
|
|
@@ -545,18 +560,26 @@ async def list_prs(
|
|
|
545
560
|
prs = await self.pulls.list(state=state, sort='updated', direction='desc', per_page=per_page, page=page)
|
|
546
561
|
return PullRows([PullRow(o) for o in prs], maxlen=maxlen)
|
|
547
562
|
|
|
563
|
+
# %% ../nbs/00_core.ipynb #c74057c8
|
|
564
|
+
def _parse_section(b):
|
|
565
|
+
attrs = b['attributes']
|
|
566
|
+
options = [o['label'] if isinstance(o, dict) else o for o in attrs.get('options', [])]
|
|
567
|
+
return dict(label=attrs['label'], type=b['type'], required=bool(nested_idx(b, 'validations', 'required')), options=options)
|
|
568
|
+
|
|
569
|
+
|
|
548
570
|
# %% ../nbs/00_core.ipynb #55a976ea
|
|
549
571
|
def _parse_tmpl(name, txt):
|
|
550
572
|
"Parse issue template `txt`: yml forms into section labels for `issue_body`; md templates kept as `raw`"
|
|
551
573
|
if not name.endswith(('.yml','.yaml')): return dict2obj(dict(name=name, raw=txt))
|
|
552
574
|
import yaml
|
|
553
575
|
d = yaml.safe_load(txt)
|
|
554
|
-
secs = [
|
|
555
|
-
required=bool(nested_idx(b, 'validations', 'required')),
|
|
556
|
-
options=[o['label'] if isinstance(o, dict) else o for o in b['attributes'].get('options', [])])
|
|
557
|
-
for b in d.get('body', []) if b['type']!='markdown']
|
|
576
|
+
secs = [_parse_section(b) for b in d.get('body', []) if b['type']!='markdown']
|
|
558
577
|
return dict2obj(dict(name=name, title=d.get('name'), description=d.get('description'), sections=secs))
|
|
559
578
|
|
|
579
|
+
async def _load_tmpl(api, f, **kw):
|
|
580
|
+
txt = (await api.get_content(f.path, **kw)).decode()
|
|
581
|
+
return _parse_tmpl(f.name, txt)
|
|
582
|
+
|
|
560
583
|
@gh_patch
|
|
561
584
|
async def issue_template(self:GhApi):
|
|
562
585
|
"Parsed issue templates for the repo (or the owner's `.github` repo as fallback); pass one to `issue_body`"
|
|
@@ -566,8 +589,8 @@ async def issue_template(self:GhApi):
|
|
|
566
589
|
except APIError as e:
|
|
567
590
|
if e.status_code != 404: raise
|
|
568
591
|
continue
|
|
569
|
-
return L([
|
|
570
|
-
|
|
592
|
+
return L([await _load_tmpl(self, f, **kw) for f in fs
|
|
593
|
+
if f.name.endswith(('.md','.yml','.yaml')) and f.name != 'config.yml'])
|
|
571
594
|
return L()
|
|
572
595
|
|
|
573
596
|
# %% ../nbs/00_core.ipynb #173fc534
|
|
@@ -597,6 +620,7 @@ class CheckRun(AttrDict):
|
|
|
597
620
|
class CommitStatus(AttrDict):
|
|
598
621
|
def __repr__(self): return f'{self.context}: {self.state}'
|
|
599
622
|
|
|
623
|
+
# %% ../nbs/00_core.ipynb #6a9289a9
|
|
600
624
|
class _CheckStatus(AttrDict):
|
|
601
625
|
def _repr_markdown_(self):
|
|
602
626
|
runs = self.check_runs
|
|
@@ -609,7 +633,10 @@ class _CheckStatus(AttrDict):
|
|
|
609
633
|
|
|
610
634
|
@gh_patch
|
|
611
635
|
async def check_status(self:GhApi, ref:str):
|
|
612
|
-
"Combined commit status and check-run results for `ref` (a SHA, branch, or tag)
|
|
636
|
+
"""Combined commit status and check-run results for `ref` (a SHA, branch, or tag).
|
|
637
|
+
|
|
638
|
+
Display the result bare for the check-run verdict and all run/status rows. `.state` comes only from legacy commit statuses and can be pending even when every Actions check passed. `.check_runs` and `.statuses` retain the structured rows.
|
|
639
|
+
"""
|
|
613
640
|
combined = await self.repos.get_combined_status_for_ref(ref)
|
|
614
641
|
checks = await self.checks.list_for_ref(ref)
|
|
615
642
|
return _CheckStatus(dict(state=combined.state, statuses=GhRows(CommitStatus(o) for o in combined.statuses),
|
|
@@ -623,16 +650,18 @@ async def pr_status(self:GhApi, pull_number:int):
|
|
|
623
650
|
# %% ../nbs/00_core.ipynb #569864f5
|
|
624
651
|
@gh_patch
|
|
625
652
|
async def failed_step_log(self:GhApi, job_id:int):
|
|
626
|
-
"
|
|
653
|
+
"""Return the failed steps' log sections, each headed by its step name.
|
|
654
|
+
|
|
655
|
+
An Actions check run's id is its job id. Pass that id from `check_status` or `pr_status`. The returned text's display is truncated at 8,000 characters. GitHub can expire or delete stored logs.
|
|
656
|
+
"""
|
|
627
657
|
job = await self.actions.get_job_for_workflow_run(job_id=job_id)
|
|
628
|
-
|
|
658
|
+
zf = zipfile.ZipFile(io.BytesIO(await self.actions.download_workflow_run_logs(run_id=job.run_id)))
|
|
629
659
|
res = []
|
|
630
660
|
for s in job.steps:
|
|
631
661
|
if s.conclusion!='failure': continue
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
res.append(f'# {s.name}\n' + '\n'.join(seg))
|
|
662
|
+
name = first(n for n in zf.namelist() if n.startswith(f'{job.name}/{s.number}_'))
|
|
663
|
+
lines = [strip_ansi(l.split(' ', 1)[-1]) for l in zf.read(name).decode().splitlines()]
|
|
664
|
+
res.append(f'# {s.name}\n' + '\n'.join(lines))
|
|
636
665
|
return TruncatedString('\n\n'.join(res), 8_000)
|
|
637
666
|
|
|
638
667
|
# %% ../nbs/00_core.ipynb #751c4e54
|