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.
Files changed (32) hide show
  1. {ghapi-2.1.2/ghapi.egg-info → ghapi-2.1.4}/PKG-INFO +6 -5
  2. {ghapi-2.1.2 → ghapi-2.1.4}/README.md +1 -1
  3. ghapi-2.1.4/ghapi/__init__.py +8 -0
  4. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/_modidx.py +2 -1
  5. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/core.py +97 -68
  6. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/gh_spec.py +770 -66
  7. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/gql_spec.py +29 -21
  8. ghapi-2.1.4/ghapi/graphql.py +47 -0
  9. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/page.py +1 -1
  10. ghapi-2.1.4/ghapi/skill.py +80 -0
  11. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/templates.py +4 -4
  12. {ghapi-2.1.2 → ghapi-2.1.4/ghapi.egg-info}/PKG-INFO +6 -5
  13. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi.egg-info/requires.txt +4 -3
  14. {ghapi-2.1.2 → ghapi-2.1.4}/pyproject.toml +3 -3
  15. ghapi-2.1.2/ghapi/__init__.py +0 -8
  16. ghapi-2.1.2/ghapi/graphql.py +0 -39
  17. ghapi-2.1.2/ghapi/skill.py +0 -136
  18. {ghapi-2.1.2 → ghapi-2.1.4}/CONTRIBUTING.md +0 -0
  19. {ghapi-2.1.2 → ghapi-2.1.4}/LICENSE +0 -0
  20. {ghapi-2.1.2 → ghapi-2.1.4}/MANIFEST.in +0 -0
  21. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/actions.py +0 -0
  22. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/all.py +0 -0
  23. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/auth.py +0 -0
  24. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/build_lib.py +0 -0
  25. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/cli.py +0 -0
  26. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/event.py +0 -0
  27. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi/py.typed +0 -0
  28. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi.egg-info/SOURCES.txt +0 -0
  29. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi.egg-info/dependency_links.txt +0 -0
  30. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi.egg-info/entry_points.txt +0 -0
  31. {ghapi-2.1.2 → ghapi-2.1.4}/ghapi.egg-info/top_level.txt +0 -0
  32. {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.2
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.7
19
- Requires-Dist: fastspec>=0.2.1
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
- Here’s how to learn about an endpoint you want to use, e.g.:
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
- 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.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', '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',
@@ -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 fastspec.errors import APIError
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, owner=None, repo=None, token=None, jwt_token=None, debug=None, limit_cb=None, gh_host=None, # chkstyle: ignore-node
123
- 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"
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
- def __getitem__(self, k):
161
- "Lookup an endpoint by path and verb (which defaults to 'GET')"
162
- a,b = k if isinstance(k,tuple) else (k,'GET')
163
- 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()}']
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` (which is assumed to be in UTC time zone) to a format suitable for GitHub API operations"
175
- 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'
176
195
 
177
196
  # %% ../nbs/00_core.ipynb #3f4c8b27
178
197
  def gh2date(dtstr:str)->datetime:
179
- "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`"
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: title, body, comments, and (for PRs) diff, review comments, and reviews"
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, # chkstyle: ignore-node
434
- comments=await self.issues.list_comments(issue_number))
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 # chkstyle: ignore-node
450
- if s.startswith(f'diff --git a/{folder}')
451
- and not any(f'/{f} ' in s.split('\n')[0] for f in skip_files))
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 = [dict(label=b['attributes']['label'], type=b['type'], # chkstyle: ignore-node
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([_parse_tmpl(f.name, base64.b64decode((await self.repos.get_content(path=f.path, **kw)).content).decode()) # chkstyle: ignore-node
570
- for f in fs if f.name.endswith(('.md','.yml','.yaml')) and f.name != 'config.yml'])
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
- "Log sections of `job_id`'s failed steps, each headed by its step name"
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
- log = await self.actions.download_job_logs_for_workflow_run(job_id=job_id)
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
- seg = [l[29:] for l in log.splitlines() if s.started_at[:-1] <= l[:19] <= s.completed_at[:-1]]
633
- errs = [i for i,l in enumerate(seg) if l.startswith(r'##[error]')]
634
- if errs: seg = seg[:errs[-1]+1]
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