ghapi 2.1.3__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.3/ghapi.egg-info → ghapi-2.1.4}/PKG-INFO +5 -5
  2. {ghapi-2.1.3 → ghapi-2.1.4}/README.md +1 -1
  3. ghapi-2.1.4/ghapi/__init__.py +8 -0
  4. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/_modidx.py +2 -1
  5. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/core.py +90 -61
  6. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/gh_spec.py +770 -66
  7. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/gql_spec.py +29 -21
  8. ghapi-2.1.4/ghapi/graphql.py +47 -0
  9. ghapi-2.1.4/ghapi/skill.py +80 -0
  10. {ghapi-2.1.3 → ghapi-2.1.4/ghapi.egg-info}/PKG-INFO +5 -5
  11. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi.egg-info/requires.txt +3 -3
  12. {ghapi-2.1.3 → ghapi-2.1.4}/pyproject.toml +3 -3
  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.4}/CONTRIBUTING.md +0 -0
  17. {ghapi-2.1.3 → ghapi-2.1.4}/LICENSE +0 -0
  18. {ghapi-2.1.3 → ghapi-2.1.4}/MANIFEST.in +0 -0
  19. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/actions.py +0 -0
  20. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/all.py +0 -0
  21. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/auth.py +0 -0
  22. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/build_lib.py +0 -0
  23. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/cli.py +0 -0
  24. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/event.py +0 -0
  25. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/page.py +0 -0
  26. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/py.typed +0 -0
  27. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi/templates.py +0 -0
  28. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi.egg-info/SOURCES.txt +0 -0
  29. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi.egg-info/dependency_links.txt +0 -0
  30. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi.egg-info/entry_points.txt +0 -0
  31. {ghapi-2.1.3 → ghapi-2.1.4}/ghapi.egg-info/top_level.txt +0 -0
  32. {ghapi-2.1.3 → 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
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,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,7 @@ 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
31
  Dynamic: license-file
32
32
 
33
33
  # ghapi
@@ -154,7 +154,7 @@ api.git
154
154
  - [git.create_tree](https://docs.github.com/rest/git/trees#create-a-tree)(owner, repo, tree, base_tree): *Create a tree*
155
155
  - [git.get_tree](https://docs.github.com/rest/git/trees#get-a-tree)(owner, repo, tree_sha, recursive): *Get a tree*
156
156
 
157
- 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:
158
158
 
159
159
  ``` python
160
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',
@@ -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
@@ -231,13 +249,6 @@ async def update_gist(self:GhApi, gist_id:str, content:str):
231
249
  fname = first(gist.files.keys())
232
250
  return (await self.gists.update(gist_id, files={fname: {'content': content}})).html_url
233
251
 
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
252
  # %% ../nbs/00_core.ipynb #b2bf7e22
242
253
  @patch
243
254
  async def upload_file(self:GhApi, rel, fn):
@@ -247,17 +258,23 @@ async def upload_file(self:GhApi, rel, fn):
247
258
  mime = mimetypes.guess_type(fn, False)[0] or 'application/octet-stream'
248
259
  return await self(url, 'POST', headers={'Content-Type':mime}, query = {'name':fn.name}, data=fn.read_bytes())
249
260
 
250
- # %% ../nbs/00_core.ipynb #3cad71a4
251
261
  @gh_patch
252
262
  async def create_release(self:GhApi, tag_name, branch=None, name=None, body='',
253
- draft=False, prerelease=False, files=None, make_latest=UNSET):
263
+ draft=False, prerelease=False, files=None, make_latest=UNSET, generate_release_notes=UNSET):
254
264
  "Wrapper for `GhApi.repos.create_release` which also uploads `files`; `branch` defaults to the repo's default branch"
255
265
  if name is None: name = 'v'+tag_name
256
266
  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)
267
+ draft=draft, prerelease=prerelease, make_latest=make_latest, generate_release_notes=generate_release_notes)
258
268
  for file in listify(files): await self.upload_file(rel, file)
259
269
  return rel
260
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
+
261
278
  # %% ../nbs/00_core.ipynb #2be73ae0
262
279
  @gh_patch
263
280
  async def list_tags(self:GhApi, prefix:str=''):
@@ -341,7 +358,6 @@ async def create_or_update_file(self:GhApi, path, message, committer, author, co
341
358
  return await self.repos.create_or_update_file_contents(path, message, content=content,
342
359
  branch=branch, committer=committer or {}, author=author or {}, **kwargs)
343
360
 
344
- # %% ../nbs/00_core.ipynb #5044445d
345
361
  @gh_patch
346
362
  async def create_file(self:GhApi, path, message, committer, author, content=None, branch=None):
347
363
  if not branch: branch = (await self.repos.get())['default_branch']
@@ -351,21 +367,16 @@ async def create_file(self:GhApi, path, message, committer, author, content=None
351
367
  @gh_patch
352
368
  async def delete_file(self:GhApi, path, message, committer, author, sha=None, branch=None):
353
369
  if not branch: branch = (await self.repos.get())['default_branch']
354
- if sha is None: sha = (await self.list_files())[path].sha
370
+ if sha is None: sha = (await self.list_files(branch))[path].sha
355
371
  return await self.repos.delete_file(path, message=message, sha=sha, branch=branch, committer=committer, author=author)
356
372
 
357
373
  # %% ../nbs/00_core.ipynb #93f6b559
358
374
  @gh_patch
359
375
  async def update_contents(self:GhApi, path, message, committer, author, content, sha=None, branch=None):
360
376
  if not branch: branch = (await self.repos.get())['default_branch']
361
- if sha is None: sha = (await self.list_files())[path].sha
377
+ if sha is None: sha = (await self.list_files(branch))[path].sha
362
378
  return await self.create_or_update_file(path, message, committer=committer, author=author, content=content, sha=sha, branch=branch)
363
379
 
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
380
  # %% ../nbs/00_core.ipynb #9ffb3b8f
370
381
  def _include(path, include, exclude):
371
382
  "Returns True if path matches include patterns (if any) and doesn't match any exclude pattern."
@@ -391,7 +402,6 @@ async def get_file_content(self:GhApi, path, owner, repo, branch="main"):
391
402
  o['content_decoded'] = base64.b64decode(o.content).decode('utf-8')
392
403
  return o
393
404
 
394
- # %% ../nbs/00_core.ipynb #d02d1bab
395
405
  @patch
396
406
  @delegates(GhApi.get_repo_files)
397
407
  async def get_repo_contents(self:GhApi, owner, repo, branch='main',
@@ -427,12 +437,15 @@ class _IssueInfo(AttrDict):
427
437
 
428
438
  @gh_patch
429
439
  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"
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
+ """
431
444
  try: pr = await self.pulls.get(issue_number)
432
445
  except APIError: pr = None
433
446
  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))
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))
436
449
  if pr is not None:
437
450
  res['diff'] = await self.pulls.get(issue_number, headers_={'Accept': 'application/vnd.github.v3.diff'})
438
451
  res['review_comments'] = await self.pulls.list_review_comments(issue_number)
@@ -447,9 +460,9 @@ async def read_issue(self:GhApi, issue_number:int):
447
460
  def _filter_diff(diff, folder='', skip_files=('_modidx.py',)):
448
461
  "Filter unified diff to only include files under `folder`, skipping `skip_files`"
449
462
  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))
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))
453
466
 
454
467
  def _reduce_ctx(diff):
455
468
  "Keep only diff headers and changed lines"
@@ -527,6 +540,7 @@ class PullRow(AttrDict):
527
540
  def line(self, maxlen=MAXLEN): return f'#{self.number} {_age(self.updated_at)} {_pr_mark(self)}{truncstr(self.title, maxlen)}'
528
541
  __repr__ = line
529
542
 
543
+ # %% ../nbs/00_core.ipynb #21b48014
530
544
  class PullRows(GhRows):
531
545
  "Pull request rows, displayed with titles truncated to `maxlen`"
532
546
  def __init__(self, items, maxlen=MAXLEN):
@@ -546,18 +560,26 @@ async def list_prs(
546
560
  prs = await self.pulls.list(state=state, sort='updated', direction='desc', per_page=per_page, page=page)
547
561
  return PullRows([PullRow(o) for o in prs], maxlen=maxlen)
548
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
+
549
570
  # %% ../nbs/00_core.ipynb #55a976ea
550
571
  def _parse_tmpl(name, txt):
551
572
  "Parse issue template `txt`: yml forms into section labels for `issue_body`; md templates kept as `raw`"
552
573
  if not name.endswith(('.yml','.yaml')): return dict2obj(dict(name=name, raw=txt))
553
574
  import yaml
554
575
  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']
576
+ secs = [_parse_section(b) for b in d.get('body', []) if b['type']!='markdown']
559
577
  return dict2obj(dict(name=name, title=d.get('name'), description=d.get('description'), sections=secs))
560
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
+
561
583
  @gh_patch
562
584
  async def issue_template(self:GhApi):
563
585
  "Parsed issue templates for the repo (or the owner's `.github` repo as fallback); pass one to `issue_body`"
@@ -567,8 +589,8 @@ async def issue_template(self:GhApi):
567
589
  except APIError as e:
568
590
  if e.status_code != 404: raise
569
591
  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'])
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'])
572
594
  return L()
573
595
 
574
596
  # %% ../nbs/00_core.ipynb #173fc534
@@ -598,6 +620,7 @@ class CheckRun(AttrDict):
598
620
  class CommitStatus(AttrDict):
599
621
  def __repr__(self): return f'{self.context}: {self.state}'
600
622
 
623
+ # %% ../nbs/00_core.ipynb #6a9289a9
601
624
  class _CheckStatus(AttrDict):
602
625
  def _repr_markdown_(self):
603
626
  runs = self.check_runs
@@ -610,7 +633,10 @@ class _CheckStatus(AttrDict):
610
633
 
611
634
  @gh_patch
612
635
  async def check_status(self:GhApi, ref:str):
613
- "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
+ """
614
640
  combined = await self.repos.get_combined_status_for_ref(ref)
615
641
  checks = await self.checks.list_for_ref(ref)
616
642
  return _CheckStatus(dict(state=combined.state, statuses=GhRows(CommitStatus(o) for o in combined.statuses),
@@ -624,7 +650,10 @@ async def pr_status(self:GhApi, pull_number:int):
624
650
  # %% ../nbs/00_core.ipynb #569864f5
625
651
  @gh_patch
626
652
  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"
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
+ """
628
657
  job = await self.actions.get_job_for_workflow_run(job_id=job_id)
629
658
  zf = zipfile.ZipFile(io.BytesIO(await self.actions.download_workflow_run_logs(run_id=job.run_id)))
630
659
  res = []