queue-watch 0.1.0__py3-none-any.whl
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.
- queue_watch/__init__.py +3 -0
- queue_watch/adapters/__init__.py +34 -0
- queue_watch/adapters/command.py +54 -0
- queue_watch/adapters/flock_queue.py +322 -0
- queue_watch/adapters/github_actions.py +164 -0
- queue_watch/adapters/github_merge_queue.py +141 -0
- queue_watch/app.py +876 -0
- queue_watch/changes.py +181 -0
- queue_watch/cli.py +129 -0
- queue_watch/config.py +105 -0
- queue_watch/discovery.py +149 -0
- queue_watch/model.py +188 -0
- queue_watch/process.py +67 -0
- queue_watch/widgets.py +774 -0
- queue_watch-0.1.0.dist-info/METADATA +141 -0
- queue_watch-0.1.0.dist-info/RECORD +19 -0
- queue_watch-0.1.0.dist-info/WHEEL +4 -0
- queue_watch-0.1.0.dist-info/entry_points.txt +2 -0
- queue_watch-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""GitHub's native merge queue through the authenticated ``gh`` CLI (GraphQL)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from .. import process
|
|
9
|
+
from ..model import SCHEMA
|
|
10
|
+
from . import Adapter
|
|
11
|
+
|
|
12
|
+
PAGE = 100
|
|
13
|
+
STATES = {
|
|
14
|
+
"QUEUED": "queued",
|
|
15
|
+
"AWAITING_CHECKS": "running",
|
|
16
|
+
"MERGEABLE": "queued",
|
|
17
|
+
"LOCKED": "running",
|
|
18
|
+
"UNMERGEABLE": "blocked",
|
|
19
|
+
}
|
|
20
|
+
QUERY = """
|
|
21
|
+
query($owner: String!, $name: String!, $branch: String, $page: Int!) {
|
|
22
|
+
repository(owner: $owner, name: $name) {
|
|
23
|
+
defaultBranchRef { name }
|
|
24
|
+
mergeQueue(branch: $branch) {
|
|
25
|
+
url
|
|
26
|
+
nextEntryEstimatedTimeToMerge
|
|
27
|
+
entries(first: $page) {
|
|
28
|
+
totalCount
|
|
29
|
+
nodes {
|
|
30
|
+
id position state enqueuedAt estimatedTimeToMerge solo jump
|
|
31
|
+
headCommit { oid }
|
|
32
|
+
enqueuer { login }
|
|
33
|
+
pullRequest { number title url headRefName author { login } }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def query(repo: str, branch: str | None) -> dict:
|
|
43
|
+
owner, name = repo.split("/")
|
|
44
|
+
argv = ["gh", "api", "graphql", "-f", f"query={QUERY}", "-F", f"page={PAGE}"]
|
|
45
|
+
argv += ["-f", f"owner={owner}", "-f", f"name={name}"]
|
|
46
|
+
if branch:
|
|
47
|
+
argv += ["-f", f"branch={branch}"]
|
|
48
|
+
data = await process.run_json(argv)
|
|
49
|
+
if data.get("errors"):
|
|
50
|
+
raise RuntimeError("; ".join(error.get("message", "?") for error in data["errors"]))
|
|
51
|
+
repository = data["data"]["repository"]
|
|
52
|
+
if repository is None:
|
|
53
|
+
raise RuntimeError(f"{repo} is not visible to the gh account")
|
|
54
|
+
return repository
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def has_queue(repo: str) -> bool:
|
|
58
|
+
"""True when the default branch has a merge queue (used by auto-detection)."""
|
|
59
|
+
return (await query(repo, None)).get("mergeQueue") is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GitHubMergeQueue(Adapter):
|
|
63
|
+
name = "github-merge-queue"
|
|
64
|
+
label = "Merge queue"
|
|
65
|
+
interval = 15.0
|
|
66
|
+
|
|
67
|
+
def __init__(self, *, repo: str, branch: str | None = None):
|
|
68
|
+
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
|
|
69
|
+
raise ValueError(f"repo must be owner/repo, got {repo!r}")
|
|
70
|
+
self.repo, self.branch = repo, branch
|
|
71
|
+
|
|
72
|
+
async def snapshot(self) -> dict:
|
|
73
|
+
repository = await query(self.repo, self.branch)
|
|
74
|
+
branch = self.branch or (repository.get("defaultBranchRef") or {}).get("name")
|
|
75
|
+
queue = repository.get("mergeQueue")
|
|
76
|
+
records, warnings = [], []
|
|
77
|
+
if queue is None:
|
|
78
|
+
warnings.append(f"{branch} has no merge queue")
|
|
79
|
+
else:
|
|
80
|
+
entries = queue["entries"]
|
|
81
|
+
if entries["totalCount"] > len(entries["nodes"]):
|
|
82
|
+
warnings.append(
|
|
83
|
+
f"showing first {len(entries['nodes'])} of {entries['totalCount']} entries"
|
|
84
|
+
)
|
|
85
|
+
records = [self.record(entry) for entry in entries["nodes"]]
|
|
86
|
+
return {
|
|
87
|
+
"schema": SCHEMA,
|
|
88
|
+
"observed_at": datetime.now(UTC).isoformat(),
|
|
89
|
+
"records": records,
|
|
90
|
+
"views": [
|
|
91
|
+
{
|
|
92
|
+
"kind": "queue_entry",
|
|
93
|
+
"label": "Queue",
|
|
94
|
+
"columns": [
|
|
95
|
+
{"key": "position", "label": "#", "width": 4},
|
|
96
|
+
{"key": "state", "label": "State", "width": 15, "format": "state"},
|
|
97
|
+
{"key": "title", "label": "Pull request", "width": 32},
|
|
98
|
+
{"key": "owner", "label": "Author", "width": 14},
|
|
99
|
+
{"key": "created_at", "label": "Queued", "width": 9, "format": "age"},
|
|
100
|
+
{
|
|
101
|
+
"key": "fields.estimated_seconds",
|
|
102
|
+
"label": "ETA",
|
|
103
|
+
"width": 9,
|
|
104
|
+
"format": "duration",
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
"warnings": warnings,
|
|
110
|
+
"note": f"Merge queue for {branch}. Positions and estimates come from GitHub.",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def record(entry: dict) -> dict:
|
|
115
|
+
pull = entry.get("pullRequest") or {}
|
|
116
|
+
number = pull.get("number")
|
|
117
|
+
title = pull.get("title") or "pull request"
|
|
118
|
+
return {
|
|
119
|
+
"id": entry["id"],
|
|
120
|
+
"kind": "queue_entry",
|
|
121
|
+
"state": STATES.get(entry.get("state"), "unknown"),
|
|
122
|
+
"raw_state": str(entry.get("state") or "unknown").lower(),
|
|
123
|
+
"title": f"#{number} {title}" if number else title,
|
|
124
|
+
"ref": pull.get("headRefName"),
|
|
125
|
+
"sha": (entry.get("headCommit") or {}).get("oid"),
|
|
126
|
+
"owner": (pull.get("author") or {}).get("login"),
|
|
127
|
+
"position": entry.get("position"),
|
|
128
|
+
"created_at": iso(entry.get("enqueuedAt")),
|
|
129
|
+
"url": pull.get("url"),
|
|
130
|
+
"fields": {
|
|
131
|
+
"pull_request": number,
|
|
132
|
+
"estimated_seconds": entry.get("estimatedTimeToMerge"),
|
|
133
|
+
"enqueuer": (entry.get("enqueuer") or {}).get("login"),
|
|
134
|
+
"solo": entry.get("solo"),
|
|
135
|
+
"jump": entry.get("jump"),
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def iso(value: str | None) -> str | None:
|
|
141
|
+
return datetime.fromisoformat(value).isoformat() if value else None
|