pygitpub 0.0.63__tar.gz → 0.0.64__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.
- {pygitpub-0.0.63 → pygitpub-0.0.64}/PKG-INFO +2 -4
- {pygitpub-0.0.63 → pygitpub-0.0.64}/README.md +1 -3
- {pygitpub-0.0.63 → pygitpub-0.0.64}/pyproject.toml +19 -15
- {pygitpub-0.0.63 → pygitpub-0.0.64}/src/pygitpub/configs.py +20 -0
- {pygitpub-0.0.63 → pygitpub-0.0.64}/src/pygitpub/main.py +121 -2
- {pygitpub-0.0.63 → pygitpub-0.0.64}/src/pygitpub/static.py +1 -1
- {pygitpub-0.0.63 → pygitpub-0.0.64}/.gitignore +0 -0
- {pygitpub-0.0.63 → pygitpub-0.0.64}/LICENSE +0 -0
- {pygitpub-0.0.63 → pygitpub-0.0.64}/src/pygitpub/__init__.py +0 -0
- {pygitpub-0.0.63 → pygitpub-0.0.64}/src/pygitpub/utils/__init__.py +0 -0
- {pygitpub-0.0.63 → pygitpub-0.0.64}/src/pygitpub/utils/lua.py +0 -0
- {pygitpub-0.0.63 → pygitpub-0.0.64}/src/pygitpub/utils/misc.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pygitpub
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.64
|
|
4
4
|
Summary: Pygitpub will help you work with github
|
|
5
5
|
Project-URL: Homepage, https://github.com/veltzer/pygitpub
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/veltzer/pygitpub/issues
|
|
@@ -41,9 +41,7 @@ project website: https://veltzer.github.io/pygitpub
|
|
|
41
41
|
|
|
42
42
|
author: Mark Veltzer
|
|
43
43
|
|
|
44
|
-
version: 0.0.
|
|
45
|
-
|
|
46
|
-

|
|
44
|
+
version: 0.0.64
|
|
47
45
|
|
|
48
46
|
## github
|
|
49
47
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pygitpub"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.64"
|
|
8
8
|
requires-python = ">=3.14"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "Mark Veltzer", email = "mark.veltzer@gmail.com" }
|
|
@@ -41,8 +41,7 @@ dependencies = [
|
|
|
41
41
|
|
|
42
42
|
[dependency-groups]
|
|
43
43
|
dev = [
|
|
44
|
-
"
|
|
45
|
-
"pylint",
|
|
44
|
+
"pyrefly",
|
|
46
45
|
"pytest",
|
|
47
46
|
"ruff",
|
|
48
47
|
"sphinx",
|
|
@@ -83,24 +82,29 @@ exclude = [
|
|
|
83
82
|
"*.md",
|
|
84
83
|
]
|
|
85
84
|
|
|
86
|
-
[tool.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
85
|
+
[tool.pyrefly]
|
|
86
|
+
# Type checking (rsconstruct's pyrefly processor). preset = "legacy" is
|
|
87
|
+
# pyrefly's mypy-compatible behavior; "." on the search path is mypy's
|
|
88
|
+
# implicit current directory.
|
|
89
|
+
preset = "legacy"
|
|
90
|
+
python-version = "3.14"
|
|
91
|
+
search-path = [
|
|
92
|
+
"src",
|
|
93
|
+
"scripts",
|
|
94
|
+
".",
|
|
95
|
+
]
|
|
96
|
+
# Third-party libraries without type information.
|
|
97
|
+
ignore-missing-imports = [
|
|
97
98
|
"github.*",
|
|
98
99
|
"lupa.*",
|
|
99
100
|
"pyapikey.*",
|
|
100
101
|
"pylogconf.*",
|
|
101
102
|
"pytconf.*",
|
|
102
103
|
]
|
|
103
|
-
|
|
104
|
+
|
|
105
|
+
[tool.pyrefly.errors]
|
|
106
|
+
no-any-return = "error"
|
|
107
|
+
|
|
104
108
|
[tool.hatch.envs.default]
|
|
105
109
|
installer = "uv"
|
|
106
110
|
|
|
@@ -63,6 +63,26 @@ class ConfigAlgo(Config):
|
|
|
63
63
|
)
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
# The ways the runs_show_failing and runs_show_not_success endpoints can
|
|
67
|
+
# query github. The first is the default.
|
|
68
|
+
RUNS_APIS = [
|
|
69
|
+
"graphql",
|
|
70
|
+
"rest",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ConfigRuns(Config):
|
|
75
|
+
"""
|
|
76
|
+
Parameters to control how workflow runs are queried
|
|
77
|
+
"""
|
|
78
|
+
api = ParamCreator.create_choice(
|
|
79
|
+
choice_list=RUNS_APIS,
|
|
80
|
+
help_string="graphql: check the default branch head commit of all repos in parallel batches (fast);"
|
|
81
|
+
" rest: walk every workflow's last run on any branch (slow, one call per workflow)",
|
|
82
|
+
default="graphql",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
66
86
|
class ConfigOutput(Config):
|
|
67
87
|
"""
|
|
68
88
|
Parameters to control output
|
|
@@ -6,6 +6,9 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from typing import Any
|
|
9
12
|
|
|
10
13
|
import github
|
|
11
14
|
import pyapikey
|
|
@@ -15,7 +18,7 @@ from pytconf.pydoc import get_first_line
|
|
|
15
18
|
from pytconf.registry import the_registry
|
|
16
19
|
|
|
17
20
|
import pygitpub.static
|
|
18
|
-
from pygitpub.configs import AFFILIATIONS, ConfigAlgo, ConfigGithub, ConfigOutput
|
|
21
|
+
from pygitpub.configs import AFFILIATIONS, ConfigAlgo, ConfigGithub, ConfigOutput, ConfigRuns
|
|
19
22
|
from pygitpub.utils.lua import config_path, load_lua_file
|
|
20
23
|
from pygitpub.utils.misc import get_all_git_repos
|
|
21
24
|
|
|
@@ -31,7 +34,7 @@ def get_repo_folder(repo) -> str:
|
|
|
31
34
|
|
|
32
35
|
def get_affiliation() -> str:
|
|
33
36
|
""" validate the affiliation parameter and return it in the form github wants """
|
|
34
|
-
values = [x.strip() for x in ConfigAlgo.affiliation.split(",")]
|
|
37
|
+
values: list[str] = [x.strip() for x in ConfigAlgo.affiliation.split(",")]
|
|
35
38
|
values = [x for x in values if x != ""]
|
|
36
39
|
if not values:
|
|
37
40
|
raise ValueError("affiliation must not be empty")
|
|
@@ -296,14 +299,123 @@ def runs_show_running() -> None:
|
|
|
296
299
|
print(f"{repo.name}: {workflow.name} {run.conclusion}")
|
|
297
300
|
|
|
298
301
|
|
|
302
|
+
# repos per GraphQL query; measured fastest at around 10
|
|
303
|
+
GRAPHQL_BATCH_SIZE = 10
|
|
304
|
+
# GraphQL queries in flight at once, well below github's concurrency limits
|
|
305
|
+
GRAPHQL_WORKERS = 26
|
|
306
|
+
|
|
307
|
+
GRAPHQL_ROLLUP_FIELD = """
|
|
308
|
+
r{index}: repository(owner: {owner}, name: {name}) {{
|
|
309
|
+
defaultBranchRef {{ target {{ ... on Commit {{ statusCheckRollup {{ state }} }} }} }}
|
|
310
|
+
}}
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
GRAPHQL_SUITES_QUERY = """
|
|
314
|
+
query($owner: String!, $name: String!) {
|
|
315
|
+
repository(owner: $owner, name: $name) {
|
|
316
|
+
defaultBranchRef {
|
|
317
|
+
target {
|
|
318
|
+
... on Commit {
|
|
319
|
+
checkSuites(first: 50) {
|
|
320
|
+
nodes {
|
|
321
|
+
status
|
|
322
|
+
conclusion
|
|
323
|
+
workflowRun { workflow { name } }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def graphql_show_runs(bad_state: Callable[[str], bool], bad_conclusion: Callable[[str], bool]) -> None:
|
|
335
|
+
"""
|
|
336
|
+
Print the workflows that are not green on the default branch head commit
|
|
337
|
+
of every repo.
|
|
338
|
+
|
|
339
|
+
Resolving statusCheckRollup is slow on github's side (~4.5s per 50 repos)
|
|
340
|
+
and cursor pagination would run the pages one after another, so the repos
|
|
341
|
+
are split into small batches, each batch is one query with an aliased
|
|
342
|
+
`repository` field per repo, and the batches run in parallel. Repos whose
|
|
343
|
+
rollup state is bad then get one more query each to name the workflows.
|
|
344
|
+
Only the head commit of the default branch is looked at, so a failed run
|
|
345
|
+
on another branch, or a scheduled run on an older commit, does not show.
|
|
346
|
+
"""
|
|
347
|
+
apikey = pyapikey.get_key(ConfigGithub.apikey)
|
|
348
|
+
# the default client sleeps a second between POSTs, and every GraphQL
|
|
349
|
+
# query is a POST, which would serialize the parallel queries
|
|
350
|
+
g = github.Github(
|
|
351
|
+
login_or_token=apikey,
|
|
352
|
+
pool_size=GRAPHQL_WORKERS,
|
|
353
|
+
seconds_between_requests=None,
|
|
354
|
+
seconds_between_writes=None,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def graphql(query: str, variables: dict[str, str]) -> dict[str, Any]:
|
|
358
|
+
_headers, data = g.requester.graphql_query(query, variables)
|
|
359
|
+
result: dict[str, Any] = data["data"]
|
|
360
|
+
return result
|
|
361
|
+
|
|
362
|
+
def get_states(batch: list[str]) -> list[str | None]:
|
|
363
|
+
fields = []
|
|
364
|
+
for index, full_name in enumerate(batch):
|
|
365
|
+
owner, name = full_name.split("/", 1)
|
|
366
|
+
# json.dumps gives a correctly quoted GraphQL string literal
|
|
367
|
+
fields.append(GRAPHQL_ROLLUP_FIELD.format(index=index, owner=json.dumps(owner), name=json.dumps(name)))
|
|
368
|
+
data = graphql("query {" + "".join(fields) + "}", {})
|
|
369
|
+
states: list[str | None] = []
|
|
370
|
+
for index in range(len(batch)):
|
|
371
|
+
ref = data[f"r{index}"]["defaultBranchRef"]
|
|
372
|
+
rollup = ref and ref["target"].get("statusCheckRollup")
|
|
373
|
+
states.append(rollup and rollup["state"])
|
|
374
|
+
return states
|
|
375
|
+
|
|
376
|
+
def get_bad_workflows(full_name: str) -> list[tuple[str, str]]:
|
|
377
|
+
owner, name = full_name.split("/", 1)
|
|
378
|
+
data = graphql(GRAPHQL_SUITES_QUERY, {"owner": owner, "name": name})
|
|
379
|
+
workflows = []
|
|
380
|
+
for suite in data["repository"]["defaultBranchRef"]["target"]["checkSuites"]["nodes"]:
|
|
381
|
+
# github creates an empty, never-run suite for every installed
|
|
382
|
+
# app; without a workflow run behind it, it tells us nothing
|
|
383
|
+
if suite["workflowRun"] is None:
|
|
384
|
+
continue
|
|
385
|
+
# an unfinished suite has no conclusion yet; report its status
|
|
386
|
+
conclusion = suite["conclusion"] or suite["status"]
|
|
387
|
+
if bad_conclusion(conclusion):
|
|
388
|
+
workflows.append((suite["workflowRun"]["workflow"]["name"], conclusion.lower()))
|
|
389
|
+
return workflows
|
|
390
|
+
|
|
391
|
+
full_names = [repo.full_name for repo in yield_repos()]
|
|
392
|
+
batches = [full_names[i:i + GRAPHQL_BATCH_SIZE] for i in range(0, len(full_names), GRAPHQL_BATCH_SIZE)]
|
|
393
|
+
with ThreadPoolExecutor(GRAPHQL_WORKERS) as executor:
|
|
394
|
+
states = [state for result in executor.map(get_states, batches) for state in result]
|
|
395
|
+
# repos without any checks have no state and are skipped, as the rest
|
|
396
|
+
# api path skips workflows that never ran
|
|
397
|
+
bad = [full_name for full_name, state in zip(full_names, states) if state is not None and bad_state(state)]
|
|
398
|
+
for full_name, workflows in zip(bad, executor.map(get_bad_workflows, bad)):
|
|
399
|
+
name = full_name.split("/", 1)[1]
|
|
400
|
+
for workflow, conclusion in workflows:
|
|
401
|
+
print(f"{name}: {workflow} {conclusion}")
|
|
402
|
+
|
|
403
|
+
|
|
299
404
|
@register_endpoint(
|
|
300
405
|
description="Show all runs which are last and failing in all workflows and all repositories",
|
|
301
406
|
configs=[
|
|
302
407
|
ConfigGithub,
|
|
303
408
|
ConfigAlgo,
|
|
409
|
+
ConfigRuns,
|
|
304
410
|
],
|
|
305
411
|
)
|
|
306
412
|
def runs_show_failing() -> None:
|
|
413
|
+
if ConfigRuns.api == "graphql":
|
|
414
|
+
graphql_show_runs(
|
|
415
|
+
lambda state: state in ("FAILURE", "ERROR"),
|
|
416
|
+
lambda conclusion: conclusion == "FAILURE",
|
|
417
|
+
)
|
|
418
|
+
return
|
|
307
419
|
for repo in yield_repos():
|
|
308
420
|
for workflow in repo.get_workflows():
|
|
309
421
|
last_run = next(iter(workflow.get_runs()), None)
|
|
@@ -316,9 +428,16 @@ def runs_show_failing() -> None:
|
|
|
316
428
|
configs=[
|
|
317
429
|
ConfigGithub,
|
|
318
430
|
ConfigAlgo,
|
|
431
|
+
ConfigRuns,
|
|
319
432
|
],
|
|
320
433
|
)
|
|
321
434
|
def runs_show_not_success() -> None:
|
|
435
|
+
if ConfigRuns.api == "graphql":
|
|
436
|
+
graphql_show_runs(
|
|
437
|
+
lambda state: state != "SUCCESS",
|
|
438
|
+
lambda conclusion: conclusion != "SUCCESS",
|
|
439
|
+
)
|
|
440
|
+
return
|
|
322
441
|
for repo in yield_repos():
|
|
323
442
|
for workflow in repo.get_workflows():
|
|
324
443
|
last_run = next(iter(workflow.get_runs()), None)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|