alissa-tools-github-revloop 0.15.0__tar.gz → 0.16.1__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.
- alissa_tools_github_revloop-0.16.1/LICENSE +202 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/MANIFEST.in +2 -0
- alissa_tools_github_revloop-0.16.1/NOTICE +5 -0
- {alissa_tools_github_revloop-0.15.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.1}/PKG-INFO +14 -1
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/README.md +8 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/setup.py +2 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/__main__.py +10 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/config.py +30 -1
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/ghclient.py +45 -16
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/loop.py +466 -23
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/state.py +87 -2
- alissa_tools_github_revloop-0.16.1/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/webui/sources.py +4 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +14 -1
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +2 -0
- alissa_tools_github_revloop-0.15.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.1}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alissa-tools-github-revloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.1
|
|
4
4
|
Summary: ALISSA-TOOLS-GITHUB-REVLOOP
|
|
5
5
|
Home-page: https://alissa.app
|
|
6
6
|
Author: Fahera
|
|
7
7
|
Author-email: support@alissa.app
|
|
8
|
+
License: Apache-2.0
|
|
8
9
|
Requires-Python: >=3.11
|
|
9
10
|
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
License-File: NOTICE
|
|
10
13
|
Dynamic: author
|
|
11
14
|
Dynamic: author-email
|
|
12
15
|
Dynamic: description
|
|
13
16
|
Dynamic: description-content-type
|
|
14
17
|
Dynamic: home-page
|
|
18
|
+
Dynamic: license
|
|
19
|
+
Dynamic: license-file
|
|
15
20
|
Dynamic: requires-python
|
|
16
21
|
Dynamic: summary
|
|
17
22
|
|
|
@@ -59,3 +64,11 @@ pip install -e ./alissa-tools-github-revloop
|
|
|
59
64
|
distribution version lives in the plain-text `version` file next to the module
|
|
60
65
|
it versions (`src/main/alissa/tools/github/revloop/version`), read by both
|
|
61
66
|
`setup.py` and `version.py`.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
Licensed under the Apache License 2.0 — see the `LICENSE` and `NOTICE` files
|
|
71
|
+
shipped with this distribution.
|
|
72
|
+
|
|
73
|
+
"Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS S. DE
|
|
74
|
+
R.L.; the license does not grant permission to use them.
|
|
@@ -42,3 +42,11 @@ pip install -e ./alissa-tools-github-revloop
|
|
|
42
42
|
distribution version lives in the plain-text `version` file next to the module
|
|
43
43
|
it versions (`src/main/alissa/tools/github/revloop/version`), read by both
|
|
44
44
|
`setup.py` and `version.py`.
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
Licensed under the Apache License 2.0 — see the `LICENSE` and `NOTICE` files
|
|
49
|
+
shipped with this distribution.
|
|
50
|
+
|
|
51
|
+
"Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS S. DE
|
|
52
|
+
R.L.; the license does not grant permission to use them.
|
|
@@ -28,6 +28,8 @@ with open("README.md") as file:
|
|
|
28
28
|
setup(
|
|
29
29
|
name="alissa-tools-github-revloop",
|
|
30
30
|
version=version,
|
|
31
|
+
license="Apache-2.0",
|
|
32
|
+
license_files=("LICENSE", "NOTICE"),
|
|
31
33
|
description="ALISSA-TOOLS-GITHUB-REVLOOP",
|
|
32
34
|
long_description=readme,
|
|
33
35
|
long_description_content_type='text/markdown',
|
|
@@ -80,6 +80,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
80
80
|
metavar="OWNER/REPO",
|
|
81
81
|
help="only watch this repo; repeatable. Replaces the config list entirely.",
|
|
82
82
|
)
|
|
83
|
+
over.add_argument(
|
|
84
|
+
"--operator",
|
|
85
|
+
dest="operators",
|
|
86
|
+
action="append",
|
|
87
|
+
metavar="LOGIN",
|
|
88
|
+
help="GitHub login allowed to ack a review-loop re-entry "
|
|
89
|
+
"(`alissa-review: re-enter +N` on a capped PR); repeatable. Replaces "
|
|
90
|
+
"the config list entirely. Empty = no ack is ever honoured.",
|
|
91
|
+
)
|
|
83
92
|
over.add_argument("--poll-interval", type=int, metavar="SECONDS")
|
|
84
93
|
over.add_argument("--round-cap", type=int, metavar="N", help="CR9 round cap")
|
|
85
94
|
over.add_argument("--hub-template", metavar="TEMPLATE")
|
|
@@ -114,6 +123,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
114
123
|
through. `repos` becomes a tuple so it matches the config-file form."""
|
|
115
124
|
return {
|
|
116
125
|
"repos": tuple(args.repos) if args.repos else None,
|
|
126
|
+
"operators": tuple(args.operators) if args.operators else None,
|
|
117
127
|
"poll_interval": args.poll_interval,
|
|
118
128
|
"round_cap": args.round_cap,
|
|
119
129
|
"hub_template": args.hub_template,
|
|
@@ -41,6 +41,7 @@ CONFIG_KEYS = (
|
|
|
41
41
|
"poll_interval",
|
|
42
42
|
"round_cap",
|
|
43
43
|
"repos",
|
|
44
|
+
"operators",
|
|
44
45
|
"agent_profile",
|
|
45
46
|
"reviewer_login",
|
|
46
47
|
"state_path",
|
|
@@ -68,6 +69,14 @@ class Config:
|
|
|
68
69
|
# Empty tuple means "every repo that requests a review from me".
|
|
69
70
|
repos: tuple[str, ...] = ()
|
|
70
71
|
|
|
72
|
+
# GitHub logins whose re-entry ack may raise a capped PR's effective cap
|
|
73
|
+
# (loop.parse_reentry_ack). Empty -- the default -- means NO ack is ever
|
|
74
|
+
# honoured: the lever fails closed, because anyone who can comment on a PR
|
|
75
|
+
# could otherwise buy it more rounds. The reviewer identity itself is never
|
|
76
|
+
# an operator however it is configured (the daemon's own escalation quotes
|
|
77
|
+
# the grammar, and self-granting would defeat CR9's cap outright).
|
|
78
|
+
operators: tuple[str, ...] = ()
|
|
79
|
+
|
|
71
80
|
agent_profile: str = "claude"
|
|
72
81
|
reviewer_login: str | None = None # None -> resolve once via `gh api user`
|
|
73
82
|
|
|
@@ -148,7 +157,10 @@ class Config:
|
|
|
148
157
|
f"on_missing_hub must be one of {sorted(_HUB_MODES)}, got {hub_mode!r}"
|
|
149
158
|
)
|
|
150
159
|
|
|
151
|
-
repos =
|
|
160
|
+
repos = _string_list(raw.get("repos", ()), "repos", "owner/repo entries")
|
|
161
|
+
operators = _string_list(
|
|
162
|
+
raw.get("operators", ()), "operators", "GitHub logins"
|
|
163
|
+
)
|
|
152
164
|
if hub_mode == HUB_ADD and not repos:
|
|
153
165
|
# Anyone who can request a review could otherwise cause an arbitrary
|
|
154
166
|
# repo to be cloned onto this machine and opened as an agent's cwd.
|
|
@@ -175,6 +187,7 @@ class Config:
|
|
|
175
187
|
poll_interval=interval,
|
|
176
188
|
round_cap=cap,
|
|
177
189
|
repos=repos,
|
|
190
|
+
operators=operators,
|
|
178
191
|
agent_profile=raw.get("agent_profile", "claude"),
|
|
179
192
|
reviewer_login=raw.get("reviewer_login"),
|
|
180
193
|
state_path=Path(state_path).expanduser() if state_path else None,
|
|
@@ -184,6 +197,22 @@ class Config:
|
|
|
184
197
|
)
|
|
185
198
|
|
|
186
199
|
|
|
200
|
+
def _string_list(value: Any, key: str, what: str) -> tuple[str, ...]:
|
|
201
|
+
"""A config list key as a tuple of non-empty, stripped strings.
|
|
202
|
+
|
|
203
|
+
The guard is the point: JSON makes `"repos": "org/repo"` an easy typo, and
|
|
204
|
+
Python would iterate it into single CHARACTERS -- an allowlist of 30-odd
|
|
205
|
+
one-character names, which `watches()` then matches against nothing and the
|
|
206
|
+
daemon quietly reviews no PR at all. Both list keys go through here so
|
|
207
|
+
neither can grow the footgun back.
|
|
208
|
+
"""
|
|
209
|
+
if isinstance(value, str):
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"{key} must be a list of {what}, not a string (got {value!r})"
|
|
212
|
+
)
|
|
213
|
+
return tuple(str(item).strip() for item in value if str(item).strip())
|
|
214
|
+
|
|
215
|
+
|
|
187
216
|
def load_config_file(path: Path) -> dict[str, Any]:
|
|
188
217
|
data = json.loads(Path(path).expanduser().read_text())
|
|
189
218
|
if not isinstance(data, dict):
|
|
@@ -17,6 +17,19 @@ log = logging.getLogger(__name__)
|
|
|
17
17
|
# was never submitted, so it does not close a round.
|
|
18
18
|
SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
|
|
19
19
|
|
|
20
|
+
# GitHub's page size ceiling, and how many pages of issue comments we will walk.
|
|
21
|
+
# Both readers of the comment list need the WHOLE thread, and a truncated read
|
|
22
|
+
# fails silently in each: the activity-comment finder would stop seeing its own
|
|
23
|
+
# comment and fork a second one, and the re-entry ack scan would drop an
|
|
24
|
+
# operator's ack on the floor -- on exactly the long-lived, capped-out PRs where
|
|
25
|
+
# the thread is longest and the ack matters most. Bounded anyway so a
|
|
26
|
+
# pathological thread cannot stall a poll pass; 20 pages is 2000 comments, and
|
|
27
|
+
# the truncation is logged rather than assumed away. Paged explicitly rather
|
|
28
|
+
# than with `gh api --paginate`: this targets gh 2.4.0, whose --paginate
|
|
29
|
+
# concatenates one JSON document per page and would not parse.
|
|
30
|
+
PER_PAGE = 100
|
|
31
|
+
COMMENT_PAGE_LIMIT = 20
|
|
32
|
+
|
|
20
33
|
|
|
21
34
|
@dataclass(frozen=True)
|
|
22
35
|
class PullRequest:
|
|
@@ -234,24 +247,40 @@ class GitHub:
|
|
|
234
247
|
)
|
|
235
248
|
|
|
236
249
|
def issue_comments(self, owner: str, repo: str, number: int) -> list[IssueComment]:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
250
|
+
"""Every issue comment on the PR, oldest first -- see COMMENT_PAGE_LIMIT
|
|
251
|
+
for why this pages instead of reading the first 100 and hoping."""
|
|
252
|
+
out: list[IssueComment] = []
|
|
253
|
+
for page in range(1, COMMENT_PAGE_LIMIT + 1):
|
|
254
|
+
data = (
|
|
255
|
+
self._api(
|
|
256
|
+
"-X",
|
|
257
|
+
"GET",
|
|
258
|
+
f"repos/{owner}/{repo}/issues/{number}/comments",
|
|
259
|
+
"-f",
|
|
260
|
+
f"per_page={PER_PAGE}",
|
|
261
|
+
"-f",
|
|
262
|
+
f"page={page}",
|
|
263
|
+
)
|
|
264
|
+
or []
|
|
244
265
|
)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
266
|
+
out.extend(
|
|
267
|
+
IssueComment(
|
|
268
|
+
id=int(c.get("id") or 0),
|
|
269
|
+
author=(c.get("user") or {}).get("login", ""),
|
|
270
|
+
body=c.get("body") or "",
|
|
271
|
+
)
|
|
272
|
+
for c in data
|
|
252
273
|
)
|
|
253
|
-
|
|
254
|
-
|
|
274
|
+
if len(data) < PER_PAGE:
|
|
275
|
+
break
|
|
276
|
+
else:
|
|
277
|
+
log.warning(
|
|
278
|
+
"%s/%s#%d has more than %d comments — only the first %d were "
|
|
279
|
+
"read; an operator re-entry ack past that point cannot be seen",
|
|
280
|
+
owner, repo, number, COMMENT_PAGE_LIMIT * PER_PAGE,
|
|
281
|
+
COMMENT_PAGE_LIMIT * PER_PAGE,
|
|
282
|
+
)
|
|
283
|
+
return out
|
|
255
284
|
|
|
256
285
|
def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> None:
|
|
257
286
|
self._api(
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import logging
|
|
14
14
|
import re
|
|
15
15
|
import secrets
|
|
16
|
+
import sqlite3
|
|
16
17
|
import time
|
|
17
18
|
from collections import Counter
|
|
18
19
|
from dataclasses import dataclass
|
|
@@ -21,7 +22,7 @@ from pathlib import Path
|
|
|
21
22
|
|
|
22
23
|
from .alissa import VERDICT_APPROVE, Alissa, Task
|
|
23
24
|
from .config import HUB_ADD, ON_MISSING_SKIP, Config
|
|
24
|
-
from .ghclient import GitHub, PullRequest, RateLimited, Review
|
|
25
|
+
from .ghclient import GitHub, IssueComment, PullRequest, RateLimited, Review
|
|
25
26
|
from .proc import CommandError
|
|
26
27
|
from .state import State
|
|
27
28
|
|
|
@@ -79,12 +80,27 @@ _RELEASE_SLOT = (
|
|
|
79
80
|
"`alissa tmux kill {session}`. Do nothing after it."
|
|
80
81
|
)
|
|
81
82
|
|
|
83
|
+
# The cap the reviewer must write down. The review-task description template in
|
|
84
|
+
# the alissa-code-review skill documents a default of 3, which this daemon's
|
|
85
|
+
# config has not used since the cap moved to 10 -- so round 1 (the round that
|
|
86
|
+
# CREATES the review task) has to carry the effective number, or every task
|
|
87
|
+
# description keeps repeating the stale one. Both directives say it: a round-k
|
|
88
|
+
# reviewer reading a task that records the wrong cap is the case that surfaced
|
|
89
|
+
# the drift in the first place.
|
|
90
|
+
_RECORD_THE_CAP = (
|
|
91
|
+
"This loop's EFFECTIVE round cap is {cap} (the daemon's config, plus any "
|
|
92
|
+
"operator re-entry grants) — record THAT number in the review task "
|
|
93
|
+
"description, and correct it if the description carries a different cap "
|
|
94
|
+
"from a stale template default. "
|
|
95
|
+
)
|
|
96
|
+
|
|
82
97
|
ROUND_1_DIRECTIVE = (
|
|
83
98
|
"You are a PR REVIEWER, not an implementer. {assignment} "
|
|
84
99
|
"Load the alissa-code-review skill and follow procedures/review-a-pr.md: "
|
|
85
100
|
"hydrate the task and the PR it names, review per the rubric, post "
|
|
86
101
|
"severity-tagged comments via gh pr review, record the verdict evidence, "
|
|
87
102
|
"move the task to pending_validation. "
|
|
103
|
+
+ _RECORD_THE_CAP
|
|
88
104
|
+ _CLOSE_THE_ROUND +
|
|
89
105
|
"NEVER push commits, merge, or change PR state. "
|
|
90
106
|
"Do NOT create further ali-* sessions. "
|
|
@@ -98,19 +114,88 @@ ROUND_K_DIRECTIVE = (
|
|
|
98
114
|
"including its round-k section: verify the triage of every prior finding, "
|
|
99
115
|
"verify the fixes, sweep the new diff with the full rubric, record a "
|
|
100
116
|
"round-{round} verdict envelope, move the task to pending_validation. "
|
|
117
|
+
+ _RECORD_THE_CAP
|
|
101
118
|
+ _CLOSE_THE_ROUND +
|
|
102
119
|
"NEVER push commits, merge, or change PR state. "
|
|
103
120
|
"Do NOT create further ali-* sessions. "
|
|
104
121
|
+ _RELEASE_SLOT
|
|
105
122
|
)
|
|
106
123
|
|
|
124
|
+
# The operator re-entry ack (issue #42). A capped PR is unreviewable until an
|
|
125
|
+
# operator says otherwise, and the cap-out message used to OFFER "re-enter with
|
|
126
|
+
# a fresh cap" without any mechanism existing: the only lever was raising
|
|
127
|
+
# round_cap for every PR and restarting the daemon. The grammar below is that
|
|
128
|
+
# mechanism, and it is deliberately dull -- one line, a literal prefix, an
|
|
129
|
+
# explicit +N -- so it is trivially auditable in the PR's comment history and
|
|
130
|
+
# impossible to fire by accident.
|
|
131
|
+
REENTRY_GRAMMAR = "alissa-review: re-enter +N"
|
|
132
|
+
|
|
133
|
+
# The ceiling on a single ack. A constant, not a config key: the point of the
|
|
134
|
+
# ack is a SMALL bounded re-entry (the live case wanted exactly one round), and
|
|
135
|
+
# an operator who needs more says so again in another comment -- which leaves
|
|
136
|
+
# two auditable rows instead of one giant grant. Pinned by a test.
|
|
137
|
+
MAX_REENTRY_ROUNDS = 5
|
|
138
|
+
|
|
139
|
+
# "Is this line trying to be a directive?" -- the loose sieve. A line that
|
|
140
|
+
# reaches for the prefix but misses the grammar is reported as malformed
|
|
141
|
+
# (a silent typo would look exactly like a cap that refuses to lift).
|
|
142
|
+
_ACK_LEAD_RE = re.compile(r"^`?\s*alissa-review\s*:", re.IGNORECASE)
|
|
143
|
+
|
|
144
|
+
# The grammar itself: the WHOLE line, optionally wrapped in backticks, is the
|
|
145
|
+
# directive. Anchored on both ends so a mention inside prose ("just post
|
|
146
|
+
# `alissa-review: re-enter +1`") is not a directive, and quoted lines (`>`) are
|
|
147
|
+
# dropped before matching so replying to the escalation cannot re-grant it.
|
|
148
|
+
_ACK_RE = re.compile(
|
|
149
|
+
r"^`?\s*alissa-review:\s+re-enter\s+\+(\d{1,3})\s*`?$", re.IGNORECASE
|
|
150
|
+
)
|
|
151
|
+
|
|
107
152
|
ESCALATION_COMMENT = (
|
|
108
153
|
"**Review loop cap-out (CR9)** — {rounds} rounds ran on this PR without "
|
|
109
|
-
"converging on `approve
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
"
|
|
113
|
-
"
|
|
154
|
+
"converging on `approve` (effective cap {cap}).{grant_note} Per the "
|
|
155
|
+
"alissa-code-review skill the loop does not run past the cap and never "
|
|
156
|
+
"silently merges, so it stops here: this needs an operator decision.\n\n"
|
|
157
|
+
"{verdict_line}\n"
|
|
158
|
+
"{verification}"
|
|
159
|
+
"\n**Operator re-entry — grant N more rounds.** Comment with a line that "
|
|
160
|
+
"reads exactly:\n\n"
|
|
161
|
+
"```\n" + REENTRY_GRAMMAR + "\n```\n\n"
|
|
162
|
+
"…with `N` from 1 to {max_rounds}. It is honoured only from an allowlisted "
|
|
163
|
+
"operator account (the daemon's `operators` config), counted once per "
|
|
164
|
+
"comment — a further grant needs a further comment — logged, and appended "
|
|
165
|
+
"to the review-loop activity comment. Anything else is ignored: a quoted "
|
|
166
|
+
"line, another wording, a bigger N, any other author.\n\n"
|
|
167
|
+
"Without an ack no further round runs. The other options are unchanged: "
|
|
168
|
+
"merge with a recorded waiver, or park it."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# The last-verdict line. Two shapes, because one sha is not enough once the
|
|
172
|
+
# head can have moved: the page has to say what was judged AND what is sitting
|
|
173
|
+
# there now, or an operator reading "last verdict at <current head>" concludes
|
|
174
|
+
# the verdict already covers the pushed fixes -- the exact opposite of what the
|
|
175
|
+
# verification hint below is telling them.
|
|
176
|
+
VERDICT_LINE = "Last verdict: `{last_state}` at `{sha}`."
|
|
177
|
+
VERDICT_LINE_MOVED = (
|
|
178
|
+
"Last verdict: `{last_state}` on `{reviewed}` — the head is now `{sha}`."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Only when the head has moved past the head the last verdict was written
|
|
182
|
+
# against -- the PR #277 shape, where the fixes are already pushed and sitting
|
|
183
|
+
# unreviewed. That case wants exactly one round, and saying so at the moment
|
|
184
|
+
# the operator reads the page is the whole point of the lever being
|
|
185
|
+
# discoverable.
|
|
186
|
+
VERIFICATION_HINT = (
|
|
187
|
+
"\n**The head has moved since that verdict** (`{sha}`, reviewed at "
|
|
188
|
+
"`{reviewed}`) — fixes are already pushed and unreviewed, so one round is "
|
|
189
|
+
"usually all this needs: ack `alissa-review: re-enter +1` and the next "
|
|
190
|
+
"reviewer verifies the fix against its own final findings and flips to "
|
|
191
|
+
"approve (or re-requests, consuming the grant).\n"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Named in the fresh escalation that fires once a grant has been spent without
|
|
195
|
+
# an approve, so the page says which decision was already tried.
|
|
196
|
+
GRANT_CONSUMED_NOTE = (
|
|
197
|
+
" The re-entry granted by @{author} (`+{rounds}`, comment {comment_id}) "
|
|
198
|
+
"has been consumed without an approve."
|
|
114
199
|
)
|
|
115
200
|
|
|
116
201
|
STALLED_COMMENT = (
|
|
@@ -172,6 +257,106 @@ def stalled_kind(session: str) -> str:
|
|
|
172
257
|
return f"{ESCALATION_STALLED}:{session}"
|
|
173
258
|
|
|
174
259
|
|
|
260
|
+
def capout_kind(head_sha: str, granted: int) -> str:
|
|
261
|
+
"""The ping-ledger kind that dedupes ONE cap-out page.
|
|
262
|
+
|
|
263
|
+
`escalations` is keyed by head alone, which was the whole story while the
|
|
264
|
+
cap was global: same head, same decision, one page. A re-entry grant breaks
|
|
265
|
+
that -- rounds run on the operator's own authority and end without an
|
|
266
|
+
approve on the very SAME head, and that is a new decision, not a repeat of
|
|
267
|
+
the page the operator already answered. Folding the PR's granted total into
|
|
268
|
+
the kind says exactly that: one page per (head, grant total), so a consumed
|
|
269
|
+
grant pages once more and then goes quiet until the next ack. Deliberately
|
|
270
|
+
not a timestamp comparison against the escalation row -- ordering by
|
|
271
|
+
wall-clock seconds ties when two events land in the same second, and the
|
|
272
|
+
tie fails in the loud direction (a page every poll).
|
|
273
|
+
"""
|
|
274
|
+
return f"capout:{head_sha}:{granted}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _now() -> str:
|
|
278
|
+
"""The activity comment's timestamp format (UTC, seconds)."""
|
|
279
|
+
return time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def grant_activity_kind(comment_id: int) -> str:
|
|
283
|
+
"""The ping-ledger kind that dedupes ONE grant's activity line.
|
|
284
|
+
|
|
285
|
+
The grant itself is recorded the moment it is honoured (the effective cap
|
|
286
|
+
must not depend on a comment API call succeeding), but the activity line is
|
|
287
|
+
a separate best-effort append -- so the ledger row lands only AFTER the
|
|
288
|
+
append does, and a failed append is retried on the next poll instead of
|
|
289
|
+
losing the audit line the grant is supposed to leave.
|
|
290
|
+
"""
|
|
291
|
+
return f"activity-grant:{comment_id}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(frozen=True)
|
|
295
|
+
class Ack:
|
|
296
|
+
"""One comment read against the re-entry grammar.
|
|
297
|
+
|
|
298
|
+
Three outcomes, deliberately distinguishable: not a directive at all
|
|
299
|
+
(`rounds` and `problem` both None -- an ordinary comment, no log), a
|
|
300
|
+
directive that cannot be honoured (`problem` set -- ignored WITH a log
|
|
301
|
+
line, because a silent typo is indistinguishable from a cap that refuses
|
|
302
|
+
to lift), or a good ack (`rounds` set).
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
rounds: int | None = None
|
|
306
|
+
problem: str | None = None
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def is_directive(self) -> bool:
|
|
310
|
+
return self.rounds is not None or self.problem is not None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_reentry_ack(body: str) -> Ack:
|
|
314
|
+
"""Parse a comment body for the operator re-entry directive.
|
|
315
|
+
|
|
316
|
+
The grammar is one whole line, `alissa-review: re-enter +N`, optionally
|
|
317
|
+
wrapped in backticks. Everything else about the comment is ignored, so an
|
|
318
|
+
operator may explain themselves above or below the line -- but the line
|
|
319
|
+
itself has to be exact, and a quoted (`>`) line never counts, which is what
|
|
320
|
+
stops a reply that quotes the escalation from re-granting it.
|
|
321
|
+
|
|
322
|
+
Two or more directives disagreeing in one comment is a refusal, not a
|
|
323
|
+
guess: "acks are counted, never inferred". Repeating the SAME `+N` is fine
|
|
324
|
+
(it is still one grant, and one comment is one grant however many times it
|
|
325
|
+
says so).
|
|
326
|
+
"""
|
|
327
|
+
lead = False
|
|
328
|
+
found: set[int] = set()
|
|
329
|
+
for raw in (body or "").splitlines():
|
|
330
|
+
line = raw.strip()
|
|
331
|
+
if line.startswith(">"): # a quoted escalation is not a directive
|
|
332
|
+
continue
|
|
333
|
+
if not _ACK_LEAD_RE.match(line):
|
|
334
|
+
continue
|
|
335
|
+
lead = True
|
|
336
|
+
match = _ACK_RE.match(line)
|
|
337
|
+
if match:
|
|
338
|
+
found.add(int(match.group(1)))
|
|
339
|
+
|
|
340
|
+
if not found:
|
|
341
|
+
if lead:
|
|
342
|
+
return Ack(
|
|
343
|
+
problem=f"malformed re-entry directive (expected `{REENTRY_GRAMMAR}`)"
|
|
344
|
+
)
|
|
345
|
+
return Ack()
|
|
346
|
+
if len(found) > 1:
|
|
347
|
+
return Ack(
|
|
348
|
+
problem="contradictory re-entry directives in one comment "
|
|
349
|
+
f"(+{', +'.join(str(n) for n in sorted(found))})"
|
|
350
|
+
)
|
|
351
|
+
rounds = found.pop()
|
|
352
|
+
if not 1 <= rounds <= MAX_REENTRY_ROUNDS:
|
|
353
|
+
return Ack(
|
|
354
|
+
problem=f"+{rounds} is outside the re-entry ceiling "
|
|
355
|
+
f"(+1 to +{MAX_REENTRY_ROUNDS})"
|
|
356
|
+
)
|
|
357
|
+
return Ack(rounds=rounds)
|
|
358
|
+
|
|
359
|
+
|
|
175
360
|
class Action(str, Enum):
|
|
176
361
|
SPAWNED = "spawned"
|
|
177
362
|
IN_FLIGHT = "in-flight"
|
|
@@ -227,6 +412,9 @@ class ReviewWatcher:
|
|
|
227
412
|
self.github = github or GitHub(config.reviewer_login)
|
|
228
413
|
self.alissa = alissa or Alissa()
|
|
229
414
|
self.state = state or State(config.state_db)
|
|
415
|
+
# (repo, number, comment id) of every re-entry directive already
|
|
416
|
+
# refused in this process -- see _log_ignored_ack.
|
|
417
|
+
self._ignored_acks: set[tuple[str, int, int]] = set()
|
|
230
418
|
|
|
231
419
|
# -- per-PR decision ---------------------------------------------------
|
|
232
420
|
|
|
@@ -267,11 +455,26 @@ class ReviewWatcher:
|
|
|
267
455
|
if converged is not None:
|
|
268
456
|
return Decision(Action.CONVERGED, converged, completed)
|
|
269
457
|
|
|
270
|
-
#
|
|
271
|
-
|
|
272
|
-
|
|
458
|
+
# The effective cap is the configured one plus every re-entry an
|
|
459
|
+
# operator has explicitly acked on THIS PR (issue #42). The sum is a
|
|
460
|
+
# local read, so it costs nothing on the common path; the GitHub scan
|
|
461
|
+
# that can DISCOVER a new ack runs only when the loop would otherwise
|
|
462
|
+
# be capped -- exactly the state an ack exists to unstick, and the only
|
|
463
|
+
# one where an extra comments fetch per poll is worth paying for.
|
|
464
|
+
granted = self.state.granted_rounds(pr.full_name, number)
|
|
465
|
+
if completed >= self.config.round_cap + granted:
|
|
466
|
+
granted = self._collect_acks(pr, granted)
|
|
467
|
+
self._announce_grants(pr)
|
|
468
|
+
cap = self.config.round_cap + granted
|
|
469
|
+
|
|
470
|
+
# CR9: never queue round cap+1 -- where "cap" is now the effective one.
|
|
471
|
+
# No ack, no rounds: with no grant this is byte-for-byte the old
|
|
472
|
+
# behaviour.
|
|
473
|
+
if completed >= cap:
|
|
474
|
+
if not self._escalation_owed(pr, granted):
|
|
273
475
|
return Decision(Action.CAPPED, "already escalated", completed)
|
|
274
|
-
self.
|
|
476
|
+
grant = self.state.newest_grant(pr.full_name, number)
|
|
477
|
+
self._escalate(pr, my_reviews, completed, cap, grant)
|
|
275
478
|
return Decision(Action.ESCALATED, f"{completed} rounds, no approve", completed)
|
|
276
479
|
|
|
277
480
|
round_ = completed + 1
|
|
@@ -280,7 +483,7 @@ class ReviewWatcher:
|
|
|
280
483
|
if age is not None and age < STALE_ROUND_SECONDS:
|
|
281
484
|
return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
|
|
282
485
|
if age is not None:
|
|
283
|
-
deferred = self._defer_stale_round(pr, round_, age)
|
|
486
|
+
deferred = self._defer_stale_round(pr, round_, age, cap)
|
|
284
487
|
if deferred is not None:
|
|
285
488
|
return deferred
|
|
286
489
|
log.warning(
|
|
@@ -292,9 +495,188 @@ class ReviewWatcher:
|
|
|
292
495
|
age / 60,
|
|
293
496
|
)
|
|
294
497
|
|
|
295
|
-
return self._spawn(pr, round_, task, reenqueued=age is not None)
|
|
498
|
+
return self._spawn(pr, round_, task, cap, reenqueued=age is not None)
|
|
499
|
+
|
|
500
|
+
# -- operator re-entry -------------------------------------------------
|
|
501
|
+
|
|
502
|
+
def _is_operator(self, author: str) -> bool:
|
|
503
|
+
"""Whether an ack from this author may be honoured.
|
|
504
|
+
|
|
505
|
+
Allowlist membership, case-insensitively (GitHub logins are), minus the
|
|
506
|
+
reviewer identity itself: the daemon's own escalation comment carries
|
|
507
|
+
the grammar, so an identity that could ack its own page would be able
|
|
508
|
+
to lift CR9's cap without a human ever touching it.
|
|
509
|
+
|
|
510
|
+
The PR author is deliberately NOT excluded the same way. Refusing them
|
|
511
|
+
outright would block the ordinary case -- an operator who opened the PR
|
|
512
|
+
by hand -- and the protection that matters is structural rather than
|
|
513
|
+
per-identity: the allowlist is opt-in and empty by default, so putting
|
|
514
|
+
an AGENT identity on it (an implementer that can comment on its own PR
|
|
515
|
+
and has read the escalation) is an operator's deliberate choice, not
|
|
516
|
+
something the daemon does for them. The reviewer identity is different
|
|
517
|
+
in kind because it is not opt-in at all.
|
|
518
|
+
"""
|
|
519
|
+
if not author or author.lower() == (self.github.login or "").lower():
|
|
520
|
+
return False
|
|
521
|
+
return author.lower() in {o.lower() for o in self.config.operators}
|
|
522
|
+
|
|
523
|
+
def _collect_acks(self, pr: PullRequest, granted: int) -> int:
|
|
524
|
+
"""Scan the capped PR's comments for operator acks; record new grants.
|
|
525
|
+
|
|
526
|
+
Returns the PR's total granted rounds. Every honoured ack is recorded
|
|
527
|
+
under its comment id, so the same comment can never grant twice however
|
|
528
|
+
many polls read it, and the log/announce side effects fire once.
|
|
529
|
+
|
|
530
|
+
With no operator allowlist -- the default, and what the container bakes
|
|
531
|
+
on purpose -- NO ack can ever be honoured, so the scan does not run at
|
|
532
|
+
all: a capped PR keeps its review request pending and therefore stays
|
|
533
|
+
in the search result set indefinitely, which would make this a full
|
|
534
|
+
comment-thread fetch (up to COMMENT_PAGE_LIMIT paged requests, on
|
|
535
|
+
exactly the long threads paging exists for) every poll, forever, to
|
|
536
|
+
discard everything it found. The fail-closed default stays free.
|
|
537
|
+
|
|
538
|
+
Comment reads are best-effort: an unreadable comment list leaves the PR
|
|
539
|
+
capped for this pass (the conservative direction -- it can only ever
|
|
540
|
+
withhold rounds, never invent them) and the scan retries next poll.
|
|
541
|
+
"""
|
|
542
|
+
if not self.config.operators:
|
|
543
|
+
return granted
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
comments = self.github.issue_comments(pr.owner, pr.repo, pr.number)
|
|
547
|
+
except RateLimited:
|
|
548
|
+
# Not swallowed: run_forever's backoff is the whole response to a
|
|
549
|
+
# rate limit, and eating it here would keep the pass hammering.
|
|
550
|
+
raise
|
|
551
|
+
except Exception as exc:
|
|
552
|
+
log.warning(
|
|
553
|
+
"%s is at its cap but its comments are unreadable (%s) — no "
|
|
554
|
+
"re-entry ack can be honoured this pass; retrying next poll",
|
|
555
|
+
pr.slug,
|
|
556
|
+
exc,
|
|
557
|
+
)
|
|
558
|
+
return granted
|
|
559
|
+
|
|
560
|
+
for comment in comments:
|
|
561
|
+
# Our own comments are never directives -- the escalation itself
|
|
562
|
+
# quotes the grammar.
|
|
563
|
+
if comment.author == self.github.login:
|
|
564
|
+
continue
|
|
565
|
+
ack = parse_reentry_ack(comment.body)
|
|
566
|
+
if not ack.is_directive:
|
|
567
|
+
continue
|
|
568
|
+
if not self._is_operator(comment.author):
|
|
569
|
+
self._log_ignored_ack(
|
|
570
|
+
pr, comment, f"{comment.author} is not an allowlisted operator"
|
|
571
|
+
)
|
|
572
|
+
continue
|
|
573
|
+
if ack.rounds is None:
|
|
574
|
+
self._log_ignored_ack(pr, comment, ack.problem or "malformed directive")
|
|
575
|
+
continue
|
|
576
|
+
if self.config.dry_run:
|
|
577
|
+
log.info(
|
|
578
|
+
"[dry-run] would honour re-entry ack +%d from %s on %s "
|
|
579
|
+
"(comment %d)",
|
|
580
|
+
ack.rounds, comment.author, pr.slug, comment.id,
|
|
581
|
+
)
|
|
582
|
+
granted += ack.rounds
|
|
583
|
+
continue
|
|
584
|
+
if not self.state.record_grant(
|
|
585
|
+
pr.full_name, pr.number, comment.id, comment.author, ack.rounds
|
|
586
|
+
):
|
|
587
|
+
continue # already counted in `granted`
|
|
588
|
+
granted += ack.rounds
|
|
589
|
+
log.warning(
|
|
590
|
+
"RE-ENTRY GRANT %s — operator %s acked +%d round(s) in comment "
|
|
591
|
+
"%d; effective cap %d → %d",
|
|
592
|
+
pr.slug,
|
|
593
|
+
comment.author,
|
|
594
|
+
ack.rounds,
|
|
595
|
+
comment.id,
|
|
596
|
+
self.config.round_cap + granted - ack.rounds,
|
|
597
|
+
self.config.round_cap + granted,
|
|
598
|
+
)
|
|
599
|
+
return granted
|
|
296
600
|
|
|
297
|
-
def
|
|
601
|
+
def _log_ignored_ack(
|
|
602
|
+
self, pr: PullRequest, comment: IssueComment, why: str
|
|
603
|
+
) -> None:
|
|
604
|
+
"""Report an ack that will not be honoured — once per comment.
|
|
605
|
+
|
|
606
|
+
A capped PR is re-scanned every poll, so an unconditional warning would
|
|
607
|
+
repeat a line a minute for as long as the PR stays capped. The comment
|
|
608
|
+
id is the identity of the thing being refused, and an in-memory set is
|
|
609
|
+
the right scope for it: this is a log line, not a delivery guarantee,
|
|
610
|
+
so a daemon restart re-stating the refusal once is fine (and a ledger
|
|
611
|
+
row per typo is not).
|
|
612
|
+
"""
|
|
613
|
+
key = (pr.full_name, pr.number, comment.id)
|
|
614
|
+
if key in self._ignored_acks:
|
|
615
|
+
return
|
|
616
|
+
self._ignored_acks.add(key)
|
|
617
|
+
log.warning(
|
|
618
|
+
"ignoring re-entry directive on %s (comment %d by %s): %s — the PR "
|
|
619
|
+
"stays capped",
|
|
620
|
+
pr.slug,
|
|
621
|
+
comment.id,
|
|
622
|
+
comment.author,
|
|
623
|
+
why,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def _announce_grants(self, pr: PullRequest) -> None:
|
|
627
|
+
"""Append the activity line of every grant that has not had one yet.
|
|
628
|
+
|
|
629
|
+
Separate from recording the grant on purpose: the cap change is
|
|
630
|
+
authoritative state and must not hinge on a comment API call, while the
|
|
631
|
+
audit line must survive one failing -- so it is retried here on later
|
|
632
|
+
polls until it lands (see grant_activity_kind).
|
|
633
|
+
|
|
634
|
+
Walked OLDEST first (read_grants is newest-first, like its siblings)
|
|
635
|
+
with a running total, so each line reports its OWN transition rather
|
|
636
|
+
than the PR's total: two acks read in one pass -- or, more commonly, a
|
|
637
|
+
retried append landing beside a newer grant, which is the whole reason
|
|
638
|
+
this retries -- would otherwise both claim the same before/after cap
|
|
639
|
+
and go out in reverse order into an append-only log.
|
|
640
|
+
"""
|
|
641
|
+
running = self.config.round_cap
|
|
642
|
+
for row in reversed(self.state.read_grants(pr.full_name, pr.number)):
|
|
643
|
+
before, running = running, running + row["rounds"]
|
|
644
|
+
kind = grant_activity_kind(row["comment_id"])
|
|
645
|
+
if self.state.pinged(pr.full_name, pr.number, kind):
|
|
646
|
+
continue
|
|
647
|
+
line = (
|
|
648
|
+
f"- {_now()} — operator `{row['author']}` — re-entry ack "
|
|
649
|
+
f"(comment {row['comment_id']}) — +{row['rounds']} round(s) — "
|
|
650
|
+
f"effective cap {before} → {running}"
|
|
651
|
+
)
|
|
652
|
+
if self._append_activity(pr, line):
|
|
653
|
+
self.state.record_ping(pr.full_name, pr.number, kind)
|
|
654
|
+
|
|
655
|
+
def _escalation_owed(self, pr: PullRequest, granted: int) -> bool:
|
|
656
|
+
"""Whether a cap-out page is owed, or already delivered.
|
|
657
|
+
|
|
658
|
+
Two ways a page is owed: this head has never been paged (a fresh
|
|
659
|
+
cap-out, or the implementer pushed since -- the decision is about the
|
|
660
|
+
new state), or rounds granted by an operator ack have since been
|
|
661
|
+
consumed without an approve, which is a new decision on an unmoved head
|
|
662
|
+
(capout_kind). Everything else is the same page the operator already
|
|
663
|
+
has, and CR9's escalation stays once-only.
|
|
664
|
+
|
|
665
|
+
A PR with no grant takes the first branch alone, so the pre-#42
|
|
666
|
+
behaviour -- and any state.db written by it -- is untouched: no
|
|
667
|
+
already-escalated PR re-pages just because the daemon was upgraded.
|
|
668
|
+
"""
|
|
669
|
+
if not self.state.escalated(pr.full_name, pr.number, pr.head_sha):
|
|
670
|
+
return True
|
|
671
|
+
if granted == 0:
|
|
672
|
+
return False
|
|
673
|
+
return not self.state.pinged(
|
|
674
|
+
pr.full_name, pr.number, capout_kind(pr.head_sha, granted)
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
def _defer_stale_round(
|
|
678
|
+
self, pr: PullRequest, round_: int, age: float, cap: int
|
|
679
|
+
) -> Decision | None:
|
|
298
680
|
"""The liveness signal under the stale timer: a deferral, or None to
|
|
299
681
|
respawn.
|
|
300
682
|
|
|
@@ -358,7 +740,7 @@ class ReviewWatcher:
|
|
|
358
740
|
appended = self._append_activity(
|
|
359
741
|
pr,
|
|
360
742
|
self._activity_line(
|
|
361
|
-
session, round_, f"deferred — session `{session}` still {life}"
|
|
743
|
+
session, round_, f"deferred — session `{session}` still {life}", cap
|
|
362
744
|
),
|
|
363
745
|
)
|
|
364
746
|
if appended:
|
|
@@ -562,7 +944,13 @@ class ReviewWatcher:
|
|
|
562
944
|
# -- actions -----------------------------------------------------------
|
|
563
945
|
|
|
564
946
|
def _spawn(
|
|
565
|
-
self,
|
|
947
|
+
self,
|
|
948
|
+
pr: PullRequest,
|
|
949
|
+
round_: int,
|
|
950
|
+
task: Task | None,
|
|
951
|
+
cap: int,
|
|
952
|
+
*,
|
|
953
|
+
reenqueued: bool = False,
|
|
566
954
|
) -> Decision:
|
|
567
955
|
if task is None:
|
|
568
956
|
if self.config.on_missing_review_task == ON_MISSING_SKIP:
|
|
@@ -585,7 +973,7 @@ class ReviewWatcher:
|
|
|
585
973
|
name = session_name(pr, round_)
|
|
586
974
|
template = ROUND_1_DIRECTIVE if round_ == 1 else ROUND_K_DIRECTIVE
|
|
587
975
|
directive = template.format(
|
|
588
|
-
assignment=assignment, round=round_, cap=
|
|
976
|
+
assignment=assignment, round=round_, cap=cap, session=name
|
|
589
977
|
)
|
|
590
978
|
|
|
591
979
|
hub, problem = self._ensure_hub(pr)
|
|
@@ -616,7 +1004,7 @@ class ReviewWatcher:
|
|
|
616
1004
|
context = (
|
|
617
1005
|
"re-enqueued — previous session presumed dead" if reenqueued else "spawned"
|
|
618
1006
|
)
|
|
619
|
-
self._append_activity(pr, self._activity_line(name, round_, context))
|
|
1007
|
+
self._append_activity(pr, self._activity_line(name, round_, context, cap))
|
|
620
1008
|
|
|
621
1009
|
return Decision(
|
|
622
1010
|
Action.SPAWNED,
|
|
@@ -699,9 +1087,11 @@ class ReviewWatcher:
|
|
|
699
1087
|
|
|
700
1088
|
return warnings
|
|
701
1089
|
|
|
702
|
-
def _activity_line(self, session: str, round_: int, context: str) -> str:
|
|
703
|
-
|
|
704
|
-
|
|
1090
|
+
def _activity_line(self, session: str, round_: int, context: str, cap: int) -> str:
|
|
1091
|
+
"""One mechanical line. `cap` is the PR's EFFECTIVE cap (config plus
|
|
1092
|
+
granted re-entries) -- printing the config value would render a granted
|
|
1093
|
+
round 11 as "round 11 of 10"."""
|
|
1094
|
+
return f"- {_now()} — `{session}` — round {round_} of {cap} — {context}"
|
|
705
1095
|
|
|
706
1096
|
def _append_activity(self, pr: PullRequest, line: str) -> bool:
|
|
707
1097
|
"""Append one line to THE activity comment on the PR; True if it landed.
|
|
@@ -784,11 +1174,57 @@ class ReviewWatcher:
|
|
|
784
1174
|
return
|
|
785
1175
|
self.state.record_ping(pr.full_name, pr.number, stalled_kind(session))
|
|
786
1176
|
|
|
787
|
-
def _escalate(
|
|
1177
|
+
def _escalate(
|
|
1178
|
+
self,
|
|
1179
|
+
pr: PullRequest,
|
|
1180
|
+
my_reviews: list[Review],
|
|
1181
|
+
rounds: int,
|
|
1182
|
+
cap: int,
|
|
1183
|
+
grant: sqlite3.Row | None = None,
|
|
1184
|
+
) -> None:
|
|
1185
|
+
"""Page the operator, and teach the lever that gets past the page.
|
|
1186
|
+
|
|
1187
|
+
The message carries the re-entry grammar because the cap-out is exactly
|
|
1188
|
+
the moment an operator needs it, and -- when the head has moved past
|
|
1189
|
+
the head the last verdict was written against -- the recommendation to
|
|
1190
|
+
grant a single verification round. A `grant` names the re-entry that
|
|
1191
|
+
was already spent, so a second page cannot read as a repeat of the
|
|
1192
|
+
first.
|
|
1193
|
+
"""
|
|
1194
|
+
last = my_reviews[-1] if my_reviews else None
|
|
1195
|
+
last_state = (last.state if last else "none").lower()
|
|
1196
|
+
reviewed = (last.commit_id if last else "") or ""
|
|
1197
|
+
moved = bool(reviewed) and reviewed != pr.head_sha
|
|
1198
|
+
verification = ""
|
|
1199
|
+
if moved:
|
|
1200
|
+
verification = VERIFICATION_HINT.format(
|
|
1201
|
+
sha=pr.head_sha[:8], reviewed=reviewed[:8]
|
|
1202
|
+
)
|
|
1203
|
+
template = VERDICT_LINE_MOVED if moved else VERDICT_LINE
|
|
1204
|
+
verdict_line = template.format(
|
|
1205
|
+
last_state=last_state, sha=pr.head_sha[:8], reviewed=reviewed[:8]
|
|
1206
|
+
)
|
|
1207
|
+
grant_note = ""
|
|
1208
|
+
if grant is not None:
|
|
1209
|
+
grant_note = GRANT_CONSUMED_NOTE.format(
|
|
1210
|
+
author=grant["author"],
|
|
1211
|
+
rounds=grant["rounds"],
|
|
1212
|
+
comment_id=grant["comment_id"],
|
|
1213
|
+
)
|
|
788
1214
|
body = ESCALATION_COMMENT.format(
|
|
789
|
-
rounds=rounds,
|
|
1215
|
+
rounds=rounds,
|
|
1216
|
+
cap=cap,
|
|
1217
|
+
grant_note=grant_note,
|
|
1218
|
+
verdict_line=verdict_line,
|
|
1219
|
+
verification=verification,
|
|
1220
|
+
max_rounds=MAX_REENTRY_ROUNDS,
|
|
1221
|
+
)
|
|
1222
|
+
log.error(
|
|
1223
|
+
"CAP-OUT %s after %d rounds (effective cap %d) — escalating to operator",
|
|
1224
|
+
pr.slug,
|
|
1225
|
+
rounds,
|
|
1226
|
+
cap,
|
|
790
1227
|
)
|
|
791
|
-
log.error("CAP-OUT %s after %d rounds — escalating to operator", pr.slug, rounds)
|
|
792
1228
|
|
|
793
1229
|
if self.config.dry_run:
|
|
794
1230
|
log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
|
|
@@ -798,7 +1234,14 @@ class ReviewWatcher:
|
|
|
798
1234
|
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
799
1235
|
except CommandError as exc:
|
|
800
1236
|
log.error("could not post escalation comment on %s: %s", pr.slug, exc)
|
|
1237
|
+
# Recorded even when the comment failed: a cap-out is terminal state,
|
|
1238
|
+
# and re-paging it every poll because GitHub was briefly unavailable
|
|
1239
|
+
# would be worse than the one missed comment (the log line above and
|
|
1240
|
+
# the escalations row both survive it).
|
|
801
1241
|
self.state.record_escalation(pr.full_name, pr.number, pr.head_sha)
|
|
1242
|
+
self.state.record_ping(
|
|
1243
|
+
pr.full_name, pr.number, capout_kind(pr.head_sha, cap - self.config.round_cap)
|
|
1244
|
+
)
|
|
802
1245
|
|
|
803
1246
|
# -- polling -----------------------------------------------------------
|
|
804
1247
|
|
|
@@ -4,8 +4,9 @@ Deliberately thin: GitHub is the source of truth for how many rounds have run
|
|
|
4
4
|
(one submitted review per round). This table exists only to stop the daemon
|
|
5
5
|
double-spawning a reviewer while a round is still in flight, to map a live
|
|
6
6
|
session name back to the round it was spawned for (so the reap sweep can tell
|
|
7
|
-
a finished round's session from an in-flight one),
|
|
8
|
-
|
|
7
|
+
a finished round's session from an in-flight one), to remember that a cap-out
|
|
8
|
+
was already escalated, and to count the operator re-entry acks that raise a
|
|
9
|
+
single PR's effective cap. The ledger tolerates sessions dying or being
|
|
9
10
|
killed behind its back: a reap record is bookkeeping, never a precondition.
|
|
10
11
|
|
|
11
12
|
The `poll_snapshots` table is a different animal from the ledger above: it
|
|
@@ -67,6 +68,16 @@ CREATE TABLE IF NOT EXISTS reaps (
|
|
|
67
68
|
reaped_at INTEGER NOT NULL
|
|
68
69
|
);
|
|
69
70
|
|
|
71
|
+
CREATE TABLE IF NOT EXISTS grants (
|
|
72
|
+
repo TEXT NOT NULL,
|
|
73
|
+
number INTEGER NOT NULL,
|
|
74
|
+
comment_id INTEGER NOT NULL,
|
|
75
|
+
author TEXT NOT NULL,
|
|
76
|
+
rounds INTEGER NOT NULL,
|
|
77
|
+
granted_at INTEGER NOT NULL,
|
|
78
|
+
PRIMARY KEY (repo, number, comment_id)
|
|
79
|
+
);
|
|
80
|
+
|
|
70
81
|
CREATE TABLE IF NOT EXISTS pings (
|
|
71
82
|
repo TEXT NOT NULL,
|
|
72
83
|
number INTEGER NOT NULL,
|
|
@@ -269,6 +280,10 @@ class State:
|
|
|
269
280
|
self._db.commit()
|
|
270
281
|
|
|
271
282
|
def escalated(self, repo: str, number: int, head_sha: str) -> bool:
|
|
283
|
+
"""Whether this head has been paged at all. Not the whole dedupe story
|
|
284
|
+
once re-entry grants exist -- a grant consumed without an approve is a
|
|
285
|
+
new decision on the SAME head, deduped through the ping ledger; see
|
|
286
|
+
loop._escalation_owed."""
|
|
272
287
|
row = self._db.execute(
|
|
273
288
|
"SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
|
|
274
289
|
(repo, number, head_sha),
|
|
@@ -283,6 +298,76 @@ class State:
|
|
|
283
298
|
)
|
|
284
299
|
self._db.commit()
|
|
285
300
|
|
|
301
|
+
# -- operator re-entry grants ------------------------------------------
|
|
302
|
+
#
|
|
303
|
+
# One row per HONOURED operator ack comment (issue #42): a capped PR is
|
|
304
|
+
# re-entered only by an explicit, allowlisted, bounded ack, and this table
|
|
305
|
+
# is what makes the grant *counted* rather than inferred. The comment id is
|
|
306
|
+
# the grant's identity, so the same ack is honoured exactly once however
|
|
307
|
+
# many polls read it, and a second grant genuinely requires a second
|
|
308
|
+
# comment. Rows are never pruned: the effective cap of a PR is
|
|
309
|
+
# `round_cap + sum(rounds)` over its grants, so dropping one would silently
|
|
310
|
+
# lower the cap and re-escalate a PR that was already re-entered.
|
|
311
|
+
|
|
312
|
+
def record_grant(
|
|
313
|
+
self, repo: str, number: int, comment_id: int, author: str, rounds: int
|
|
314
|
+
) -> bool:
|
|
315
|
+
"""Record one honoured ack. True when it is NEW (first sighting).
|
|
316
|
+
|
|
317
|
+
INSERT OR IGNORE keyed on the comment id: re-reading the same ack on
|
|
318
|
+
every poll must not re-grant its rounds, and the caller uses the return
|
|
319
|
+
value to log/announce the grant exactly once.
|
|
320
|
+
"""
|
|
321
|
+
cur = self._db.execute(
|
|
322
|
+
"INSERT OR IGNORE INTO grants "
|
|
323
|
+
"(repo, number, comment_id, author, rounds, granted_at) "
|
|
324
|
+
"VALUES (?,?,?,?,?,?)",
|
|
325
|
+
(repo, number, int(comment_id), author, int(rounds), int(time.time())),
|
|
326
|
+
)
|
|
327
|
+
self._db.commit()
|
|
328
|
+
return cur.rowcount > 0
|
|
329
|
+
|
|
330
|
+
def granted_rounds(self, repo: str, number: int) -> int:
|
|
331
|
+
"""Extra rounds this PR has been granted, summed over its acks."""
|
|
332
|
+
row = self._db.execute(
|
|
333
|
+
"SELECT COALESCE(SUM(rounds), 0) AS total FROM grants "
|
|
334
|
+
"WHERE repo=? AND number=?",
|
|
335
|
+
(repo, number),
|
|
336
|
+
).fetchone()
|
|
337
|
+
return int(row["total"]) if row is not None else 0
|
|
338
|
+
|
|
339
|
+
def newest_grant(self, repo: str, number: int) -> sqlite3.Row | None:
|
|
340
|
+
"""The most recently recorded ack for this PR, or None.
|
|
341
|
+
|
|
342
|
+
The escalation names it ("the re-entry granted by @x was consumed"),
|
|
343
|
+
and it is the newest ack by `granted_at`, so a second grant supersedes
|
|
344
|
+
the first in the page's wording. Nothing compares that timestamp with
|
|
345
|
+
the escalation row -- the once-only dedupe is the ping-ledger key; see
|
|
346
|
+
loop.capout_kind for why not a wall-clock comparison.
|
|
347
|
+
"""
|
|
348
|
+
return self._db.execute(
|
|
349
|
+
"SELECT * FROM grants WHERE repo=? AND number=? "
|
|
350
|
+
"ORDER BY granted_at DESC, rowid DESC LIMIT 1",
|
|
351
|
+
(repo, number),
|
|
352
|
+
).fetchone()
|
|
353
|
+
|
|
354
|
+
def read_grants(self, repo: str, number: int) -> list[dict]:
|
|
355
|
+
"""One PR's grant rows, newest first (like every reader here).
|
|
356
|
+
|
|
357
|
+
Deliberately narrow: no unfiltered form and no `limit` until something
|
|
358
|
+
needs them. The console does not read this table yet -- showing "this
|
|
359
|
+
PR was re-entered by @x" in the operator inbox wants rendering as well
|
|
360
|
+
as data, so it lands as its own change rather than as unused
|
|
361
|
+
parameters here.
|
|
362
|
+
"""
|
|
363
|
+
return self._read_rows(
|
|
364
|
+
"SELECT repo, number, comment_id, author, rounds, granted_at "
|
|
365
|
+
"FROM grants WHERE repo=? AND number=? "
|
|
366
|
+
"ORDER BY granted_at DESC, rowid DESC",
|
|
367
|
+
None,
|
|
368
|
+
(repo, number),
|
|
369
|
+
)
|
|
370
|
+
|
|
286
371
|
# -- poll snapshots (the console sidecar's exhaust buffer) -------------
|
|
287
372
|
|
|
288
373
|
def record_snapshot(
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.16.1
|
|
@@ -414,6 +414,10 @@ class Sources:
|
|
|
414
414
|
return {
|
|
415
415
|
"workspace_root": str(c.workspace_root),
|
|
416
416
|
"repos": list(c.repos),
|
|
417
|
+
# Who may re-open a capped PR with a re-entry ack. Part of the
|
|
418
|
+
# operator's own picture: the escalation inbox below is where that
|
|
419
|
+
# lever gets used.
|
|
420
|
+
"operators": list(c.operators),
|
|
417
421
|
"hub_template": c.hub_template,
|
|
418
422
|
"poll_interval": c.poll_interval,
|
|
419
423
|
"round_cap": c.round_cap,
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alissa-tools-github-revloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.1
|
|
4
4
|
Summary: ALISSA-TOOLS-GITHUB-REVLOOP
|
|
5
5
|
Home-page: https://alissa.app
|
|
6
6
|
Author: Fahera
|
|
7
7
|
Author-email: support@alissa.app
|
|
8
|
+
License: Apache-2.0
|
|
8
9
|
Requires-Python: >=3.11
|
|
9
10
|
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
License-File: NOTICE
|
|
10
13
|
Dynamic: author
|
|
11
14
|
Dynamic: author-email
|
|
12
15
|
Dynamic: description
|
|
13
16
|
Dynamic: description-content-type
|
|
14
17
|
Dynamic: home-page
|
|
18
|
+
Dynamic: license
|
|
19
|
+
Dynamic: license-file
|
|
15
20
|
Dynamic: requires-python
|
|
16
21
|
Dynamic: summary
|
|
17
22
|
|
|
@@ -59,3 +64,11 @@ pip install -e ./alissa-tools-github-revloop
|
|
|
59
64
|
distribution version lives in the plain-text `version` file next to the module
|
|
60
65
|
it versions (`src/main/alissa/tools/github/revloop/version`), read by both
|
|
61
66
|
`setup.py` and `version.py`.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
Licensed under the Apache License 2.0 — see the `LICENSE` and `NOTICE` files
|
|
71
|
+
shipped with this distribution.
|
|
72
|
+
|
|
73
|
+
"Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS S. DE
|
|
74
|
+
R.L.; the license does not grant permission to use them.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.15.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|