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.
@@ -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