ghapi 2.1.3__tar.gz → 2.1.5__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.
Files changed (32) hide show
  1. {ghapi-2.1.3/ghapi.egg-info → ghapi-2.1.5}/PKG-INFO +7 -6
  2. {ghapi-2.1.3 → ghapi-2.1.5}/README.md +1 -1
  3. ghapi-2.1.5/ghapi/__init__.py +8 -0
  4. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/_modidx.py +5 -7
  5. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/core.py +124 -69
  6. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/gh_spec.py +972 -77
  7. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/gql_spec.py +44 -36
  8. ghapi-2.1.5/ghapi/graphql.py +47 -0
  9. ghapi-2.1.5/ghapi/skill.py +75 -0
  10. {ghapi-2.1.3 → ghapi-2.1.5/ghapi.egg-info}/PKG-INFO +7 -6
  11. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi.egg-info/requires.txt +4 -3
  12. {ghapi-2.1.3 → ghapi-2.1.5}/pyproject.toml +5 -5
  13. ghapi-2.1.3/ghapi/__init__.py +0 -8
  14. ghapi-2.1.3/ghapi/graphql.py +0 -39
  15. ghapi-2.1.3/ghapi/skill.py +0 -136
  16. {ghapi-2.1.3 → ghapi-2.1.5}/CONTRIBUTING.md +0 -0
  17. {ghapi-2.1.3 → ghapi-2.1.5}/LICENSE +0 -0
  18. {ghapi-2.1.3 → ghapi-2.1.5}/MANIFEST.in +0 -0
  19. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/actions.py +0 -0
  20. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/all.py +0 -0
  21. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/auth.py +0 -0
  22. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/build_lib.py +0 -0
  23. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/cli.py +0 -0
  24. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/event.py +0 -0
  25. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/page.py +0 -0
  26. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/py.typed +0 -0
  27. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/templates.py +0 -0
  28. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi.egg-info/SOURCES.txt +0 -0
  29. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi.egg-info/dependency_links.txt +0 -0
  30. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi.egg-info/entry_points.txt +0 -0
  31. {ghapi-2.1.3 → ghapi-2.1.5}/ghapi.egg-info/top_level.txt +0 -0
  32. {ghapi-2.1.3 → ghapi-2.1.5}/setup.cfg +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghapi
3
- Version: 2.1.3
3
+ Version: 2.1.5
4
4
  Summary: A python client for the GitHub API
5
5
  Author-email: Jeremy Howard <info@fast.ai>
6
- License: Apache-2.0
6
+ License-Expression: Apache-2.0
7
7
  Project-URL: Repository, https://github.com/fastai/ghapi
8
8
  Project-URL: Documentation, https://ghapi.fast.ai/
9
9
  Keywords: github,api
@@ -15,8 +15,8 @@ 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.7
19
- Requires-Dist: fastspec>=0.2.1
18
+ Requires-Dist: fastcore>=2.2.23
19
+ Requires-Dist: fastspec>=0.2.5
20
20
  Requires-Dist: fasttransport>=0.0.2
21
21
  Requires-Dist: tomli; python_version < "3.11"
22
22
  Provides-Extra: dev
@@ -27,7 +27,8 @@ Requires-Dist: nbdev; extra == "dev"
27
27
  Requires-Dist: pyyaml; extra == "dev"
28
28
  Requires-Dist: pysym2md>=0.0.6; extra == "dev"
29
29
  Requires-Dist: llms-txt>=0.0.6; extra == "dev"
30
- Requires-Dist: pyskills; extra == "dev"
30
+ Requires-Dist: pyskills>=0.0.30; extra == "dev"
31
+ Requires-Dist: pycachy>=0.0.21; extra == "dev"
31
32
  Dynamic: license-file
32
33
 
33
34
  # ghapi
@@ -154,7 +155,7 @@ api.git
154
155
  - [git.create_tree](https://docs.github.com/rest/git/trees#create-a-tree)(owner, repo, tree, base_tree): *Create a tree*
155
156
  - [git.get_tree](https://docs.github.com/rest/git/trees#get-a-tree)(owner, repo, tree_sha, recursive): *Get a tree*
156
157
 
157
- Here’s how to learn about an endpoint you want to use, e.g.:
158
+ 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:
158
159
 
159
160
  ``` python
160
161
  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
- Here’s how to learn about an endpoint you want to use, e.g.:
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.5"
@@ -22,7 +22,6 @@ d = { 'settings': { 'branch': 'main',
22
22
  'ghapi.actions.github_token': ('actions.html#github_token', 'ghapi/actions.py'),
23
23
  'ghapi.actions.set_git_user': ('actions.html#set_git_user', 'ghapi/actions.py'),
24
24
  'ghapi.actions.user_repo': ('actions.html#user_repo', 'ghapi/actions.py')},
25
- 'ghapi.all': {},
26
25
  'ghapi.auth': { 'ghapi.auth.GhDeviceAuth': ('auth.html#ghdeviceauth', 'ghapi/auth.py'),
27
26
  'ghapi.auth.GhDeviceAuth.__init__': ('auth.html#ghdeviceauth.__init__', 'ghapi/auth.py'),
28
27
  'ghapi.auth.GhDeviceAuth._getattr': ('auth.html#ghdeviceauth._getattr', 'ghapi/auth.py'),
@@ -90,14 +89,17 @@ d = { 'settings': { 'branch': 'main',
90
89
  'ghapi.core._age': ('core.html#_age', 'ghapi/core.py'),
91
90
  'ghapi.core._dur': ('core.html#_dur', 'ghapi/core.py'),
92
91
  'ghapi.core._filter_diff': ('core.html#_filter_diff', 'ghapi/core.py'),
93
- 'ghapi.core._find_matches': ('core.html#_find_matches', 'ghapi/core.py'),
94
92
  'ghapi.core._fmt_replies': ('core.html#_fmt_replies', 'ghapi/core.py'),
93
+ 'ghapi.core._fold_groups': ('core.html#_fold_groups', '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'),
100
101
  'ghapi.core._run_subp': ('core.html#_run_subp', 'ghapi/core.py'),
102
+ 'ghapi.core._step_lines': ('core.html#_step_lines', 'ghapi/core.py'),
101
103
  'ghapi.core._transdeps': ('core.html#_transdeps', 'ghapi/core.py'),
102
104
  'ghapi.core.call_gh': ('core.html#call_gh', 'ghapi/core.py'),
103
105
  'ghapi.core.check_status': ('core.html#check_status', 'ghapi/core.py'),
@@ -152,8 +154,6 @@ d = { 'settings': { 'branch': 'main',
152
154
  'ghapi.event._want_evt': ('event.html#_want_evt', 'ghapi/event.py'),
153
155
  'ghapi.event.load_sample_events': ('event.html#load_sample_events', 'ghapi/event.py'),
154
156
  'ghapi.event.save_sample_events': ('event.html#save_sample_events', 'ghapi/event.py')},
155
- 'ghapi.gh_spec': {},
156
- 'ghapi.gql_spec': {},
157
157
  'ghapi.graphql': { 'ghapi.graphql.GhGql': ('graphql.html#ghgql', 'ghapi/graphql.py'),
158
158
  'ghapi.graphql.GhGql.__init__': ('graphql.html#ghgql.__init__', 'ghapi/graphql.py'),
159
159
  'ghapi.graphql.GhGql.repo': ('graphql.html#ghgql.repo', 'ghapi/graphql.py')},
@@ -174,6 +174,4 @@ d = { 'settings': { 'branch': 'main',
174
174
  'ghapi.page.pages': ('page.html#pages', 'ghapi/page.py'),
175
175
  'ghapi.page.parse_link_hdr': ('page.html#parse_link_hdr', 'ghapi/page.py'),
176
176
  'ghapi.page.sync_paged': ('page.html#sync_paged', 'ghapi/page.py'),
177
- 'ghapi.page.sync_pages': ('page.html#sync_pages', 'ghapi/page.py')},
178
- 'ghapi.skill': {},
179
- 'ghapi.templates': {}}}
177
+ 'ghapi.page.sync_pages': ('page.html#sync_pages', 'ghapi/page.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', 'GhTransport', 'GhSyncTransport', 'print_summary',
9
- 'gh_patch', 'GhApi', 'call_gh', 'date2gh', 'gh2date', 'GhRows', 'delete_release', 'create_release',
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',
@@ -42,9 +42,17 @@ _all_ = ['APIError']
42
42
  GH_HOST = os.getenv('GH_HOST', "https://api.github.com")
43
43
  _DOC_URL = 'https://docs.github.com/'
44
44
 
45
+ _docroot = 'https://docs.github.com/rest/reference/'
46
+
45
47
  # %% ../nbs/00_core.ipynb #ba730c65
46
48
  pspec = SpecParser.from_dict(spec)
47
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
+
48
56
  # %% ../nbs/00_core.ipynb #d4a51c1a
49
57
  _binary_cts = ('octet-stream', 'zip', 'gzip', 'tar', 'image/', 'audio/', 'video/')
50
58
 
@@ -89,15 +97,6 @@ class GhSyncTransport(SyncTransport, GhTransport):
89
97
  self._pre(method, url, kwargs)
90
98
  return self._post(SyncTransport.request(self, method, url, raw=True, **kwargs), raw)
91
99
 
92
- # %% ../nbs/00_core.ipynb #531aee7d
93
- _docroot = 'https://docs.github.com/rest/reference/'
94
-
95
- # %% ../nbs/00_core.ipynb #f361159b
96
- def print_summary(method, url, kwargs):
97
- "Debug callback for `GhApi(debug=...)`: print each request with the token (if any) removed"
98
- hdrs = {k:v for k,v in (kwargs.get('headers') or {}).items() if k.lower()!='authorization'}
99
- print(method, url, {**kwargs, 'headers':hdrs})
100
-
101
100
  # %% ../nbs/00_core.ipynb #cecd0177
102
101
  _gh_override = ContextVar('_gh_override', default={})
103
102
 
@@ -120,8 +119,20 @@ def gh_patch(fn):
120
119
  # %% ../nbs/00_core.ipynb #83e8a9ce
121
120
  class GhApi(OpenAPIClient):
122
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."
123
- def __init__(self, owner=None, repo=None, token=None, jwt_token=None, debug=None, limit_cb=None, gh_host=None, # chkstyle: ignore-node
124
- authenticate=True, timeout=60.0, sync=False, **kwargs):
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"
125
136
  self.headers = { 'Accept': 'application/vnd.github.v3+json' }
126
137
  if authenticate:
127
138
  token = token or os.getenv('GITHUB_TOKEN', None)
@@ -139,14 +150,6 @@ class GhApi(OpenAPIClient):
139
150
  self.groups = mk_groups(self.ops)
140
151
  for k,v in self.groups.items(): setattr(self, k, v)
141
152
 
142
- def __call__(self, path:str, verb:str=None, headers:dict=None, route:dict=None, query:dict=None, data=None):
143
- "Call a fully specified `path` (or full URL) using HTTP `verb` directly (returns an awaitable on an async client)"
144
- if verb is None: verb = 'POST' if data else 'GET'
145
- if route: path = path.format(**{k:quote(str(v), safe='') for k,v in route.items()})
146
- if not path.startswith(('http://', 'https://')): path = self.gh_host + path
147
- kw = dict(content=data) if isinstance(data, (bytes,str)) else dict(json=data)
148
- return self.transport.request(verb, path, headers=headers, params=query, **kw)
149
-
150
153
  @property
151
154
  def debug(self): return self.transport.debug
152
155
  @debug.setter
@@ -158,10 +161,24 @@ class GhApi(OpenAPIClient):
158
161
 
159
162
  def _repr_markdown_(self): return "\n".join(f"- [{o}]({_docroot + o.replace('_', '-')})" for o in sorted(self.groups))
160
163
 
161
- def __getitem__(self, k):
162
- "Lookup an endpoint by path and verb (which defaults to 'GET')"
163
- a,b = k if isinstance(k,tuple) else (k,'GET')
164
- return self.func_dict[f'{a}:{b.upper()}']
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()}']
165
182
 
166
183
  # %% ../nbs/00_core.ipynb #562eee39
167
184
  def call_gh(op:str, *args, token=None, **kwargs):
@@ -172,12 +189,13 @@ def call_gh(op:str, *args, token=None, **kwargs):
172
189
 
173
190
  # %% ../nbs/00_core.ipynb #05cbdf91
174
191
  def date2gh(dt:datetime)->str:
175
- "Convert `dt` (which is assumed to be in UTC time zone) to a format suitable for GitHub API operations"
176
- return f'{dt.replace(microsecond=0).isoformat()}Z'
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'
177
195
 
178
196
  # %% ../nbs/00_core.ipynb #3f4c8b27
179
197
  def gh2date(dtstr:str)->datetime:
180
- "Convert date string `dtstr` received from a GitHub API operation to a UTC `datetime`"
198
+ "Convert a GitHub UTC date string to a naive UTC `datetime`"
181
199
  return datetime.fromisoformat(dtstr.replace('Z', ''))
182
200
 
183
201
  # %% ../nbs/00_core.ipynb #7876622c
@@ -214,9 +232,7 @@ async def create_gist(self:GhApi, description, content, filename='gist.txt', pub
214
232
  @patch
215
233
  def load_gist(self:GhApi, gist_id:str):
216
234
  "Retrieve a gist by id, or by `user/id` (as it appears in gist URLs); coro if async client"
217
- if '/' in gist_id: *_,user,gist_id = gist_id.split('/')
218
- else: user = None
219
- return self.gists.get(gist_id, user=user)
235
+ return self.gists.get(gist_id.split('/')[-1])
220
236
 
221
237
  @patch
222
238
  def gist_file(self:GhApi, gist_id:str):
@@ -231,13 +247,6 @@ async def update_gist(self:GhApi, gist_id:str, content:str):
231
247
  fname = first(gist.files.keys())
232
248
  return (await self.gists.update(gist_id, files={fname: {'content': content}})).html_url
233
249
 
234
- # %% ../nbs/00_core.ipynb #4b7a278c
235
- @gh_patch
236
- async def delete_release(self:GhApi, release):
237
- "Delete a release and its associated tag"
238
- await self.repos.delete_release(release.id)
239
- await self.git.delete_ref(f'tags/{release.tag_name}')
240
-
241
250
  # %% ../nbs/00_core.ipynb #b2bf7e22
242
251
  @patch
243
252
  async def upload_file(self:GhApi, rel, fn):
@@ -247,17 +256,23 @@ async def upload_file(self:GhApi, rel, fn):
247
256
  mime = mimetypes.guess_type(fn, False)[0] or 'application/octet-stream'
248
257
  return await self(url, 'POST', headers={'Content-Type':mime}, query = {'name':fn.name}, data=fn.read_bytes())
249
258
 
250
- # %% ../nbs/00_core.ipynb #3cad71a4
251
259
  @gh_patch
252
260
  async def create_release(self:GhApi, tag_name, branch=None, name=None, body='',
253
- draft=False, prerelease=False, files=None, make_latest=UNSET):
261
+ draft=False, prerelease=False, files=None, make_latest=UNSET, generate_release_notes=UNSET):
254
262
  "Wrapper for `GhApi.repos.create_release` which also uploads `files`; `branch` defaults to the repo's default branch"
255
263
  if name is None: name = 'v'+tag_name
256
264
  rel = await self.repos.create_release(tag_name, target_commitish=branch or UNSET, name=name, body=body,
257
- draft=draft, prerelease=prerelease, make_latest=make_latest)
265
+ draft=draft, prerelease=prerelease, make_latest=make_latest, generate_release_notes=generate_release_notes)
258
266
  for file in listify(files): await self.upload_file(rel, file)
259
267
  return rel
260
268
 
269
+ # %% ../nbs/00_core.ipynb #4b7a278c
270
+ @gh_patch
271
+ async def delete_release(self:GhApi, release):
272
+ "Delete a release and its associated tag"
273
+ await self.repos.delete_release(release.id)
274
+ await self.git.delete_ref(f'tags/{release.tag_name}')
275
+
261
276
  # %% ../nbs/00_core.ipynb #2be73ae0
262
277
  @gh_patch
263
278
  async def list_tags(self:GhApi, prefix:str=''):
@@ -341,7 +356,6 @@ async def create_or_update_file(self:GhApi, path, message, committer, author, co
341
356
  return await self.repos.create_or_update_file_contents(path, message, content=content,
342
357
  branch=branch, committer=committer or {}, author=author or {}, **kwargs)
343
358
 
344
- # %% ../nbs/00_core.ipynb #5044445d
345
359
  @gh_patch
346
360
  async def create_file(self:GhApi, path, message, committer, author, content=None, branch=None):
347
361
  if not branch: branch = (await self.repos.get())['default_branch']
@@ -351,21 +365,16 @@ async def create_file(self:GhApi, path, message, committer, author, content=None
351
365
  @gh_patch
352
366
  async def delete_file(self:GhApi, path, message, committer, author, sha=None, branch=None):
353
367
  if not branch: branch = (await self.repos.get())['default_branch']
354
- if sha is None: sha = (await self.list_files())[path].sha
368
+ if sha is None: sha = (await self.list_files(branch))[path].sha
355
369
  return await self.repos.delete_file(path, message=message, sha=sha, branch=branch, committer=committer, author=author)
356
370
 
357
371
  # %% ../nbs/00_core.ipynb #93f6b559
358
372
  @gh_patch
359
373
  async def update_contents(self:GhApi, path, message, committer, author, content, sha=None, branch=None):
360
374
  if not branch: branch = (await self.repos.get())['default_branch']
361
- if sha is None: sha = (await self.list_files())[path].sha
375
+ if sha is None: sha = (await self.list_files(branch))[path].sha
362
376
  return await self.create_or_update_file(path, message, committer=committer, author=author, content=content, sha=sha, branch=branch)
363
377
 
364
- # %% ../nbs/00_core.ipynb #444784ce
365
- def _find_matches(path, pats):
366
- "Returns matched patterns"
367
- return L(pats).filter(lambda p: fnmatch.fnmatch(path, p))
368
-
369
378
  # %% ../nbs/00_core.ipynb #9ffb3b8f
370
379
  def _include(path, include, exclude):
371
380
  "Returns True if path matches include patterns (if any) and doesn't match any exclude pattern."
@@ -391,7 +400,6 @@ async def get_file_content(self:GhApi, path, owner, repo, branch="main"):
391
400
  o['content_decoded'] = base64.b64decode(o.content).decode('utf-8')
392
401
  return o
393
402
 
394
- # %% ../nbs/00_core.ipynb #d02d1bab
395
403
  @patch
396
404
  @delegates(GhApi.get_repo_files)
397
405
  async def get_repo_contents(self:GhApi, owner, repo, branch='main',
@@ -410,9 +418,10 @@ async def enable_pages(self:GhApi, branch=None, path="/"):
410
418
  r = await self.repos.get()
411
419
  branch = branch or r.default_branch
412
420
  source = {"branch": branch, "path": path}
413
- if r.has_pages: return # await self.repos.update_information_about_pages_site(source=source)
414
421
  if len(await self.list_branches(branch))==0: await self.create_branch_empty(branch)
415
- return await self.repos.create_pages_site(source=source)
422
+ if not r.has_pages: return await self.repos.create_pages_site(source=source)
423
+ await self.repos.update_information_about_pages_site(source=source)
424
+ return await self.repos.get_pages()
416
425
 
417
426
  # %% ../nbs/00_core.ipynb #519ec71c
418
427
  class _IssueInfo(AttrDict):
@@ -427,12 +436,15 @@ class _IssueInfo(AttrDict):
427
436
 
428
437
  @gh_patch
429
438
  async def read_issue(self:GhApi, issue_number:int):
430
- "Fetch an issue or PR: title, body, comments, and (for PRs) diff, review comments, and reviews"
439
+ """Fetch an issue or PR as structured data with a compact display.
440
+
441
+ 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.
442
+ """
431
443
  try: pr = await self.pulls.get(issue_number)
432
444
  except APIError: pr = None
433
445
  iss = await self.issues.get(issue_number)
434
- res = dict(title=iss.title, body=iss.body or '', is_pr=pr is not None, # chkstyle: ignore-node
435
- comments=await self.issues.list_comments(issue_number))
446
+ res = dict(title=iss.title, body=iss.body or '', is_pr=pr is not None,
447
+ comments=await self.issues.list_comments(issue_number))
436
448
  if pr is not None:
437
449
  res['diff'] = await self.pulls.get(issue_number, headers_={'Accept': 'application/vnd.github.v3.diff'})
438
450
  res['review_comments'] = await self.pulls.list_review_comments(issue_number)
@@ -447,9 +459,9 @@ async def read_issue(self:GhApi, issue_number:int):
447
459
  def _filter_diff(diff, folder='', skip_files=('_modidx.py',)):
448
460
  "Filter unified diff to only include files under `folder`, skipping `skip_files`"
449
461
  sections = re.split(r'(?=^diff --git )', diff, flags=re.MULTILINE)
450
- return ''.join(s for s in sections # chkstyle: ignore-node
451
- if s.startswith(f'diff --git a/{folder}')
452
- and not any(f'/{f} ' in s.split('\n')[0] for f in skip_files))
462
+ return ''.join(s for s in sections
463
+ if s.startswith(f'diff --git a/{folder}')
464
+ and not any(f'/{f} ' in s.split('\n')[0] for f in skip_files))
453
465
 
454
466
  def _reduce_ctx(diff):
455
467
  "Keep only diff headers and changed lines"
@@ -527,6 +539,7 @@ class PullRow(AttrDict):
527
539
  def line(self, maxlen=MAXLEN): return f'#{self.number} {_age(self.updated_at)} {_pr_mark(self)}{truncstr(self.title, maxlen)}'
528
540
  __repr__ = line
529
541
 
542
+ # %% ../nbs/00_core.ipynb #21b48014
530
543
  class PullRows(GhRows):
531
544
  "Pull request rows, displayed with titles truncated to `maxlen`"
532
545
  def __init__(self, items, maxlen=MAXLEN):
@@ -546,18 +559,26 @@ async def list_prs(
546
559
  prs = await self.pulls.list(state=state, sort='updated', direction='desc', per_page=per_page, page=page)
547
560
  return PullRows([PullRow(o) for o in prs], maxlen=maxlen)
548
561
 
562
+ # %% ../nbs/00_core.ipynb #c74057c8
563
+ def _parse_section(b):
564
+ attrs = b['attributes']
565
+ options = [o['label'] if isinstance(o, dict) else o for o in attrs.get('options', [])]
566
+ return dict(label=attrs['label'], type=b['type'], required=bool(nested_idx(b, 'validations', 'required')), options=options)
567
+
568
+
549
569
  # %% ../nbs/00_core.ipynb #55a976ea
550
570
  def _parse_tmpl(name, txt):
551
571
  "Parse issue template `txt`: yml forms into section labels for `issue_body`; md templates kept as `raw`"
552
572
  if not name.endswith(('.yml','.yaml')): return dict2obj(dict(name=name, raw=txt))
553
573
  import yaml
554
574
  d = yaml.safe_load(txt)
555
- secs = [dict(label=b['attributes']['label'], type=b['type'], # chkstyle: ignore-node
556
- required=bool(nested_idx(b, 'validations', 'required')),
557
- options=[o['label'] if isinstance(o, dict) else o for o in b['attributes'].get('options', [])])
558
- for b in d.get('body', []) if b['type']!='markdown']
575
+ secs = [_parse_section(b) for b in d.get('body', []) if b['type']!='markdown']
559
576
  return dict2obj(dict(name=name, title=d.get('name'), description=d.get('description'), sections=secs))
560
577
 
578
+ async def _load_tmpl(api, f, **kw):
579
+ txt = (await api.get_content(f.path, **kw)).decode()
580
+ return _parse_tmpl(f.name, txt)
581
+
561
582
  @gh_patch
562
583
  async def issue_template(self:GhApi):
563
584
  "Parsed issue templates for the repo (or the owner's `.github` repo as fallback); pass one to `issue_body`"
@@ -567,8 +588,8 @@ async def issue_template(self:GhApi):
567
588
  except APIError as e:
568
589
  if e.status_code != 404: raise
569
590
  continue
570
- return L([_parse_tmpl(f.name, base64.b64decode((await self.repos.get_content(path=f.path, **kw)).content).decode()) # chkstyle: ignore-node
571
- for f in fs if f.name.endswith(('.md','.yml','.yaml')) and f.name != 'config.yml'])
591
+ return L([await _load_tmpl(self, f, **kw) for f in fs
592
+ if f.name.endswith(('.md','.yml','.yaml')) and f.name != 'config.yml'])
572
593
  return L()
573
594
 
574
595
  # %% ../nbs/00_core.ipynb #173fc534
@@ -598,6 +619,7 @@ class CheckRun(AttrDict):
598
619
  class CommitStatus(AttrDict):
599
620
  def __repr__(self): return f'{self.context}: {self.state}'
600
621
 
622
+ # %% ../nbs/00_core.ipynb #6a9289a9
601
623
  class _CheckStatus(AttrDict):
602
624
  def _repr_markdown_(self):
603
625
  runs = self.check_runs
@@ -610,7 +632,10 @@ class _CheckStatus(AttrDict):
610
632
 
611
633
  @gh_patch
612
634
  async def check_status(self:GhApi, ref:str):
613
- "Combined commit status and check-run results for `ref` (a SHA, branch, or tag)"
635
+ """Combined commit status and check-run results for `ref` (a SHA, branch, or tag).
636
+
637
+ 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.
638
+ """
614
639
  combined = await self.repos.get_combined_status_for_ref(ref)
615
640
  checks = await self.checks.list_for_ref(ref)
616
641
  return _CheckStatus(dict(state=combined.state, statuses=GhRows(CommitStatus(o) for o in combined.statuses),
@@ -621,17 +646,47 @@ async def pr_status(self:GhApi, pull_number:int):
621
646
  "Combined status and check-run results for a PR's head commit"
622
647
  return await self.check_status((await self.pulls.get(pull_number)).head.sha)
623
648
 
649
+ # %% ../nbs/00_core.ipynb #40f5c88a
650
+ def _step_lines(zf, job, s):
651
+ "Timestamped log lines for step `s` of `job`. They come from the step's own archive file, or else from the job's combined log, cut to the step's time window and its last `##[error]` line."
652
+ names = zf.namelist()
653
+ if name := first(n for n in names if n.startswith(f'{job.name}/{s.number}_')): return zf.read(name).decode().splitlines()
654
+ name = first(n for n in names if re.fullmatch(rf'\d+_{re.escape(job.name)}\.txt', n))
655
+ lines = [l for l in zf.read(name).decode('utf-8-sig').splitlines() if s.started_at[:19] <= l[:19] <= s.completed_at[:19]]
656
+ last = max((i for i,l in enumerate(lines) if l.split(' ', 1)[-1].startswith('##[error]')), default=len(lines)-1)
657
+ return lines[:last+1]
658
+
659
+ # %% ../nbs/00_core.ipynb #fd5e2942
660
+ def _fold_groups(lines, n=20):
661
+ "Fold each finished `##[group]` of more than `n` lines, unless it holds an error or warning. An unfinished group stays whole, since the step died inside it."
662
+ res, start = [], None
663
+ for l in lines:
664
+ res.append(l)
665
+ if l.startswith('##[group]'): start = len(res)
666
+ elif l.startswith('##[endgroup]') and start is not None:
667
+ body = res[start:-1]
668
+ if len(body) > n and not any(o.startswith(('##[error]', '##[warning]')) for o in body): res[start:-1] = [f'… {len(body)} lines folded']
669
+ start = None
670
+ return res
671
+
624
672
  # %% ../nbs/00_core.ipynb #569864f5
625
673
  @gh_patch
626
- async def failed_step_log(self:GhApi, job_id:int):
627
- "Log sections of `job_id`'s failed steps, each headed by its step name"
674
+ async def failed_step_log(
675
+ self:GhApi,
676
+ job_id:int, # Workflow job id, which is also its check run id
677
+ fold:bool=True, # Fold long finished `##[group]` blocks that hold no error or warning?
678
+ ):
679
+ """Return the failed steps' log sections, each headed by its step name.
680
+
681
+ 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. Long finished `##[group]` blocks with no error or warning are folded to a line count. GitHub can expire or delete stored logs.
682
+ """
628
683
  job = await self.actions.get_job_for_workflow_run(job_id=job_id)
629
684
  zf = zipfile.ZipFile(io.BytesIO(await self.actions.download_workflow_run_logs(run_id=job.run_id)))
630
685
  res = []
631
686
  for s in job.steps:
632
687
  if s.conclusion!='failure': continue
633
- name = first(n for n in zf.namelist() if n.startswith(f'{job.name}/{s.number}_'))
634
- lines = [strip_ansi(l.split(' ', 1)[-1]) for l in zf.read(name).decode().splitlines()]
688
+ lines = [strip_ansi(l.split(' ', 1)[-1]) for l in _step_lines(zf, job, s)]
689
+ if fold: lines = _fold_groups(lines)
635
690
  res.append(f'# {s.name}\n' + '\n'.join(lines))
636
691
  return TruncatedString('\n\n'.join(res), 8_000)
637
692