issue-gate 1.0.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.
- issue_gate-1.0.0.dist-info/METADATA +126 -0
- issue_gate-1.0.0.dist-info/RECORD +8 -0
- issue_gate-1.0.0.dist-info/WHEEL +5 -0
- issue_gate-1.0.0.dist-info/entry_points.txt +2 -0
- issue_gate-1.0.0.dist-info/licenses/LICENSE +21 -0
- issue_gate-1.0.0.dist-info/top_level.txt +1 -0
- issuegate/__init__.py +2 -0
- issuegate/__main__.py +357 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: issue-gate
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: GitHub issue forms that require a reproduction — stop low-effort issues at the door.
|
|
5
|
+
Author: Danilo Fortunato
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://danyblitz-bit.github.io/issue-gate/
|
|
8
|
+
Project-URL: Repository, https://github.com/danyblitz-bit/issue-gate
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/danyblitz-bit/issue-gate/issues
|
|
10
|
+
Keywords: github,issues,issue-forms,triage,maintainers,open-source,cli
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Issue Gate
|
|
21
|
+
|
|
22
|
+
GitHub issue forms and a chooser config that make "doesn't work" impossible to file without a reproduction. No dependencies, no API keys, no bot.
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
GitHub already ships this gate, for free: YAML issue forms plus `.github/ISSUE_TEMPLATE/config.yml` with `blank_issues_enabled: false`, which removes the blank-issue bypass. The problem is that nobody sets it up, and the default bug form GitHub ships has **Steps To Reproduce marked `required: false`** — so a useless report walks straight through the form and lands on a maintainer anyway.
|
|
27
|
+
|
|
28
|
+
Issue Gate writes those same native files with the fields that actually cost maintainer time marked required, prefills the environment block for the ecosystems your repo uses, and orders the contact links so questions get routed to docs, upstream, or Discussions instead of the tracker.
|
|
29
|
+
|
|
30
|
+
Nothing to install on the contributor's side. No bot. No AI.
|
|
31
|
+
|
|
32
|
+
## Used on
|
|
33
|
+
|
|
34
|
+
Running on our own public repos, merged as PRs:
|
|
35
|
+
|
|
36
|
+
- [danyblitz-bit/data-scraper](https://github.com/danyblitz-bit/data-scraper/pull/1) (Rust)
|
|
37
|
+
- [danyblitz-bit/site-auditor](https://github.com/danyblitz-bit/site-auditor/pull/1) (Python)
|
|
38
|
+
- [danyblitz-bit/pdf-metaclean](https://github.com/danyblitz-bit/pdf-metaclean/pull/1) (Python)
|
|
39
|
+
- [danyblitz-bit/photo-metaclean](https://github.com/danyblitz-bit/photo-metaclean/pull/1) (Python)
|
|
40
|
+
|
|
41
|
+
## Install & Run
|
|
42
|
+
|
|
43
|
+
No dependencies — Python 3.10+.
|
|
44
|
+
|
|
45
|
+
Landing page: <https://danyblitz-bit.github.io/issue-gate/> · machine-readable summary: [`llms.txt`](docs/llms.txt)
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
git clone https://github.com/danyblitz-bit/issue-gate.git
|
|
49
|
+
cd issue-gate
|
|
50
|
+
|
|
51
|
+
# See the diff, write nothing
|
|
52
|
+
python __main__.py /path/to/repo --dry-run
|
|
53
|
+
|
|
54
|
+
# A Node library, questions routed to Discussions
|
|
55
|
+
python __main__.py ~/code/my-lib --discussions
|
|
56
|
+
|
|
57
|
+
# Add contact links, then write
|
|
58
|
+
python __main__.py ~/code/my-lib \
|
|
59
|
+
--docs https://mylib.dev/docs \
|
|
60
|
+
--upstream https://github.com/vendor/base-lib \
|
|
61
|
+
--discussions
|
|
62
|
+
|
|
63
|
+
# Print the generated files in full
|
|
64
|
+
python __main__.py ~/code/my-lib --verbose
|
|
65
|
+
|
|
66
|
+
# Verify the tool works on your machine
|
|
67
|
+
python __main__.py --self-check
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Then push and open `/issues/new/choose` on the repo to see the gate.
|
|
71
|
+
|
|
72
|
+
## Options
|
|
73
|
+
|
|
74
|
+
| Flag | Description |
|
|
75
|
+
|------|-------------|
|
|
76
|
+
| `--docs URL` | Add a Documentation contact link (first in the chooser) |
|
|
77
|
+
| `--upstream URL` | Add an Upstream project contact link (for wrappers) |
|
|
78
|
+
| `--discussions` | Add a Discussions contact link, URL taken from `git origin` |
|
|
79
|
+
| `--dry-run` | Print a unified diff and write nothing |
|
|
80
|
+
| `--verbose` | Print the full generated files |
|
|
81
|
+
| `--self-check` | Run a built-in correctness test |
|
|
82
|
+
| `--report` | Open a pre-filled email to report an issue |
|
|
83
|
+
|
|
84
|
+
## What you get
|
|
85
|
+
|
|
86
|
+
| File | Purpose |
|
|
87
|
+
|------|---------|
|
|
88
|
+
| `.github/ISSUE_TEMPLATE/config.yml` | `blank_issues_enabled: false` plus contact links, ordered docs → upstream → Discussions |
|
|
89
|
+
| `.github/ISSUE_TEMPLATE/bug.yml` | Repro steps, expected behaviour and environment are `required: true` |
|
|
90
|
+
| `.github/ISSUE_TEMPLATE/feature.yml` | Problem before solution, alternatives optional |
|
|
91
|
+
| `.github/ISSUE_TEMPLATE/question.yml` | Only when there is no Discussions link, so nothing is left unrouted |
|
|
92
|
+
|
|
93
|
+
**It never overwrites.** An existing file is reported and left alone; merge by hand.
|
|
94
|
+
|
|
95
|
+
Two manual steps GitHub does not do for you: **create the labels** (`needs-triage`, `bug`, `enhancement`, `question` — a form cannot add a label that does not exist) and open `/issues/new/choose` to confirm the chooser renders.
|
|
96
|
+
|
|
97
|
+
## Why `required: true` and not a bot
|
|
98
|
+
|
|
99
|
+
GitHub enforces a required form field at submit time, before the issue exists. A triage bot has to run afterwards, costs money per issue, and is exactly the "hostile gate" maintainers complain about — a comment bot telling someone their issue is invalid. The form just asks once, politely, and GitHub does the rest.
|
|
100
|
+
|
|
101
|
+
## Support the Project
|
|
102
|
+
|
|
103
|
+
Issue Gate is free and open source. Like it?
|
|
104
|
+
|
|
105
|
+
- [Get your site audited — €49](https://danyblitz.gumroad.com/l/jsuyla) — I run the audit and send you the fixes
|
|
106
|
+
- [Buy me a coffee](https://danyblitz.gumroad.com/l/hrvpiu) — one-time support
|
|
107
|
+
|
|
108
|
+
## Report a bug
|
|
109
|
+
|
|
110
|
+
Found a bug or something weird? Run:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python __main__.py --report
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
This opens a pre-filled email. Send it and I'll get notified automatically.
|
|
117
|
+
|
|
118
|
+
You can also email **danyblitz@googlemail.com** directly. Use the subject format:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
[TOOL-REPORT] issue-gate <what happened>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
issue_gate-1.0.0.dist-info/licenses/LICENSE,sha256=AnUxVykA1JgoTKXdxS0A0FWJRfN_T1HGzqPRFMHaUk0,1073
|
|
2
|
+
issuegate/__init__.py,sha256=yNZLSwJrAA63HTvkZPXhd0k5Xrz_V2Oqy6y5f-d2Nm0,117
|
|
3
|
+
issuegate/__main__.py,sha256=TSdXlOHpTFKJ01H-LZuHiyFvpgmWWQ-eTfVBQX58OKg,14184
|
|
4
|
+
issue_gate-1.0.0.dist-info/METADATA,sha256=l4zuU3V0CQKRgEQ-IKOVzvkKJcDz4o7ofaJtFM-D0AU,5366
|
|
5
|
+
issue_gate-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
issue_gate-1.0.0.dist-info/entry_points.txt,sha256=clKurHNwYwKbi6g7aFNoWflD4RCwTm4Yde87Cz1wNp8,54
|
|
7
|
+
issue_gate-1.0.0.dist-info/top_level.txt,sha256=kkrVytE5YGfBi3HGDjdiBmfojX5omMOaP_jgN-pXadg,10
|
|
8
|
+
issue_gate-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Danilo Fortunato
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
issuegate
|
issuegate/__init__.py
ADDED
issuegate/__main__.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Issue Gate — generate GitHub issue forms that require a real reproduction.
|
|
2
|
+
|
|
3
|
+
GitHub already ships the gate: YAML issue forms plus a chooser config that can
|
|
4
|
+
hide the blank-issue bypass. The default GitHub bug form leaves "Steps To
|
|
5
|
+
Reproduce" as required: false, so "doesn't work" reports still land on the
|
|
6
|
+
maintainer. This tool emits the same native files with the fields that cost
|
|
7
|
+
maintainer time marked required, plus contact links ordered by usefulness.
|
|
8
|
+
No dependencies, no API, no AI.
|
|
9
|
+
"""
|
|
10
|
+
import difflib
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
import argparse
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
TEMPLATE_DIR = Path(".github") / "ISSUE_TEMPLATE"
|
|
17
|
+
|
|
18
|
+
# (marker files that mean "this ecosystem", environment field to prefill)
|
|
19
|
+
STACKS = (
|
|
20
|
+
(("package.json",), "Node"),
|
|
21
|
+
(("pyproject.toml", "requirements.txt", "setup.py"), "Python"),
|
|
22
|
+
(("Cargo.toml",), "Rust"),
|
|
23
|
+
(("go.mod",), "Go"),
|
|
24
|
+
(("pom.xml", "build.gradle", "build.gradle.kts"), "JVM"),
|
|
25
|
+
(("Gemfile",), "Ruby"),
|
|
26
|
+
(("composer.json",), "PHP"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
LABELS = "needs-triage"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def label_hint() -> str:
|
|
33
|
+
return ", ".join([LABELS, "bug", "enhancement", "question"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def detect_env(repo: Path) -> list[str]:
|
|
37
|
+
fields = ["OS"]
|
|
38
|
+
for markers, label in STACKS:
|
|
39
|
+
if any((repo / marker).exists() for marker in markers):
|
|
40
|
+
fields.append(label)
|
|
41
|
+
return fields
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def git_origin(repo: Path) -> str:
|
|
45
|
+
"""Best-effort https GitHub URL for the repo, from .git/config (no subprocess)."""
|
|
46
|
+
config = repo / ".git" / "config"
|
|
47
|
+
if not config.exists():
|
|
48
|
+
return ""
|
|
49
|
+
match = re.search(r"url\s*=\s*(\S+)", config.read_text(encoding="utf-8", errors="ignore"))
|
|
50
|
+
if not match:
|
|
51
|
+
return ""
|
|
52
|
+
url = match.group(1).strip()
|
|
53
|
+
url = re.sub(r"^git@github\.com:", "https://github.com/", url)
|
|
54
|
+
url = re.sub(r"\.git$", "", url)
|
|
55
|
+
return url if url.startswith("https://github.com/") else ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def bug_form(env: list[str]) -> str:
|
|
59
|
+
value = "".join(f" - {field}:\n" for field in env)
|
|
60
|
+
return f"""name: Bug report
|
|
61
|
+
description: Something is broken and I can show you how to reproduce it
|
|
62
|
+
title: "[BUG] "
|
|
63
|
+
labels: ["bug", "{LABELS}"]
|
|
64
|
+
body:
|
|
65
|
+
- type: checkboxes
|
|
66
|
+
attributes:
|
|
67
|
+
label: Before you open this
|
|
68
|
+
options:
|
|
69
|
+
- label: I searched the existing issues for this problem
|
|
70
|
+
required: true
|
|
71
|
+
- label: This is not a security report
|
|
72
|
+
required: true
|
|
73
|
+
- type: textarea
|
|
74
|
+
attributes:
|
|
75
|
+
label: What happened?
|
|
76
|
+
description: The behaviour you got, with the error or wrong output quoted.
|
|
77
|
+
validations:
|
|
78
|
+
required: true
|
|
79
|
+
- type: textarea
|
|
80
|
+
attributes:
|
|
81
|
+
label: Steps to reproduce
|
|
82
|
+
description: Numbered steps, starting from a fresh checkout.
|
|
83
|
+
placeholder: |
|
|
84
|
+
1. Install with ...
|
|
85
|
+
2. Run ...
|
|
86
|
+
3. See error ...
|
|
87
|
+
validations:
|
|
88
|
+
required: true
|
|
89
|
+
- type: textarea
|
|
90
|
+
attributes:
|
|
91
|
+
label: What did you expect?
|
|
92
|
+
validations:
|
|
93
|
+
required: true
|
|
94
|
+
- type: textarea
|
|
95
|
+
attributes:
|
|
96
|
+
label: Environment
|
|
97
|
+
render: markdown
|
|
98
|
+
value: |
|
|
99
|
+
{value} validations:
|
|
100
|
+
required: true
|
|
101
|
+
- type: textarea
|
|
102
|
+
attributes:
|
|
103
|
+
label: Logs or traceback
|
|
104
|
+
description: Optional. Drag files straight into this box.
|
|
105
|
+
render: text
|
|
106
|
+
validations:
|
|
107
|
+
required: false
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def feature_form() -> str:
|
|
112
|
+
return f"""name: Feature request
|
|
113
|
+
description: Propose a change, starting from the problem it solves
|
|
114
|
+
title: "[FEATURE] "
|
|
115
|
+
labels: ["enhancement", "{LABELS}"]
|
|
116
|
+
body:
|
|
117
|
+
- type: checkboxes
|
|
118
|
+
attributes:
|
|
119
|
+
label: Before you open this
|
|
120
|
+
options:
|
|
121
|
+
- label: I searched the existing issues for this idea
|
|
122
|
+
required: true
|
|
123
|
+
- type: textarea
|
|
124
|
+
attributes:
|
|
125
|
+
label: The problem
|
|
126
|
+
description: What you cannot do today, and who it affects.
|
|
127
|
+
validations:
|
|
128
|
+
required: true
|
|
129
|
+
- type: textarea
|
|
130
|
+
attributes:
|
|
131
|
+
label: Proposed change
|
|
132
|
+
description: The smallest change that would fix it.
|
|
133
|
+
validations:
|
|
134
|
+
required: true
|
|
135
|
+
- type: textarea
|
|
136
|
+
attributes:
|
|
137
|
+
label: Alternatives you considered
|
|
138
|
+
description: Optional. Workarounds or designs you ruled out, and why.
|
|
139
|
+
validations:
|
|
140
|
+
required: false
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def question_form() -> str:
|
|
145
|
+
return f"""name: Question
|
|
146
|
+
description: Usage help or "how do I..."
|
|
147
|
+
labels: ["question", "{LABELS}"]
|
|
148
|
+
body:
|
|
149
|
+
- type: checkboxes
|
|
150
|
+
attributes:
|
|
151
|
+
label: Before you open this
|
|
152
|
+
options:
|
|
153
|
+
- label: I searched the existing issues and the documentation
|
|
154
|
+
required: true
|
|
155
|
+
- type: input
|
|
156
|
+
attributes:
|
|
157
|
+
label: Short question
|
|
158
|
+
validations:
|
|
159
|
+
required: true
|
|
160
|
+
- type: textarea
|
|
161
|
+
attributes:
|
|
162
|
+
label: What I tried
|
|
163
|
+
validations:
|
|
164
|
+
required: true
|
|
165
|
+
- type: textarea
|
|
166
|
+
attributes:
|
|
167
|
+
label: Config or code
|
|
168
|
+
description: Optional. The smallest snippet that shows the problem.
|
|
169
|
+
render: text
|
|
170
|
+
validations:
|
|
171
|
+
required: false
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def chooser_config(docs: str, upstream: str, discussions: str) -> str:
|
|
176
|
+
links = []
|
|
177
|
+
if docs:
|
|
178
|
+
links.append(f" - name: Documentation\n url: {docs}\n about: Read this first, it answers most questions.\n")
|
|
179
|
+
if upstream:
|
|
180
|
+
links.append(
|
|
181
|
+
f" - name: Upstream project\n url: {upstream}\n about: This project wraps another one, check upstream docs first.\n"
|
|
182
|
+
)
|
|
183
|
+
if discussions:
|
|
184
|
+
links.append(
|
|
185
|
+
f" - name: GitHub Discussions\n url: {discussions}\n about: Ask and answer questions here instead of opening an issue.\n"
|
|
186
|
+
)
|
|
187
|
+
body = "".join(links)
|
|
188
|
+
return f"""# Generated by issue-gate. Order matters: the first useful link wins.
|
|
189
|
+
blank_issues_enabled: false
|
|
190
|
+
contact_links:
|
|
191
|
+
{body}"""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build_files(repo: Path, docs: str, upstream: str, discussions_flag: bool) -> dict[str, str]:
|
|
195
|
+
files = {"bug.yml": bug_form(detect_env(repo)), "feature.yml": feature_form()}
|
|
196
|
+
discussions = ""
|
|
197
|
+
if discussions_flag:
|
|
198
|
+
origin = git_origin(repo)
|
|
199
|
+
if origin:
|
|
200
|
+
discussions = f"{origin}/discussions"
|
|
201
|
+
else:
|
|
202
|
+
print(" [WARN] no GitHub origin found, skipping the Discussions link (add it by hand).")
|
|
203
|
+
config = chooser_config(docs, upstream, discussions)
|
|
204
|
+
if not discussions:
|
|
205
|
+
files["question.yml"] = question_form()
|
|
206
|
+
files["config.yml"] = config
|
|
207
|
+
return files
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def apply_files(repo: Path, files: dict[str, str], dry_run: bool) -> tuple[list[str], list[str]]:
|
|
211
|
+
written, skipped = [], []
|
|
212
|
+
for name, content in sorted(files.items()):
|
|
213
|
+
target = repo / TEMPLATE_DIR / name
|
|
214
|
+
before = target.read_text(encoding="utf-8") if target.exists() else ""
|
|
215
|
+
if before:
|
|
216
|
+
skipped.append(name)
|
|
217
|
+
continue
|
|
218
|
+
if dry_run:
|
|
219
|
+
written.append(name)
|
|
220
|
+
continue
|
|
221
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
target.write_text(content, encoding="utf-8", newline="\n")
|
|
223
|
+
written.append(name)
|
|
224
|
+
return written, skipped
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def show_diff(repo: Path, files: dict[str, str]) -> None:
|
|
228
|
+
for name, content in sorted(files.items()):
|
|
229
|
+
target = repo / TEMPLATE_DIR / name
|
|
230
|
+
before = target.read_text(encoding="utf-8") if target.exists() else ""
|
|
231
|
+
if before == content:
|
|
232
|
+
continue
|
|
233
|
+
target_rel = "/".join(TEMPLATE_DIR.parts + (name,)) # posix: a pasted diff must apply in a PR
|
|
234
|
+
diff = difflib.unified_diff(
|
|
235
|
+
before.splitlines(keepends=True),
|
|
236
|
+
content.splitlines(keepends=True),
|
|
237
|
+
fromfile=f"a/{target_rel}" if before else "/dev/null",
|
|
238
|
+
tofile=f"b/{target_rel}",
|
|
239
|
+
)
|
|
240
|
+
sys.stdout.writelines(diff)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def self_check() -> int:
|
|
244
|
+
import tempfile
|
|
245
|
+
|
|
246
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
247
|
+
repo = Path(tmp)
|
|
248
|
+
(repo / "package.json").write_text("{}", encoding="utf-8")
|
|
249
|
+
(repo / ".github").mkdir()
|
|
250
|
+
|
|
251
|
+
files = build_files(repo, docs="", upstream="", discussions_flag=False)
|
|
252
|
+
assert label_hint() == "needs-triage, bug, enhancement, question", f"label hint mangled: {label_hint()}"
|
|
253
|
+
assert "blank_issues_enabled: false" in files["config.yml"], "blank issues are not disabled"
|
|
254
|
+
assert "config.yml" in files and files["config.yml"].endswith("contact_links:\n"), "empty chooser config"
|
|
255
|
+
assert files["bug.yml"].count("required: true") >= 5, "bug form does not gate on repro"
|
|
256
|
+
assert " - Node:\n" in files["bug.yml"], "ecosystem field missing for a Node repo"
|
|
257
|
+
assert " - Python:\n" not in files["bug.yml"], "phantom field for an absent ecosystem"
|
|
258
|
+
assert "question.yml" in files, "no fallback form and no Discussions link"
|
|
259
|
+
assert "title: \"[BUG] \"" in files["bug.yml"], "title prefix missing"
|
|
260
|
+
|
|
261
|
+
pyrepo = Path(tmp) / "py"
|
|
262
|
+
(pyrepo / ".git").mkdir(parents=True)
|
|
263
|
+
(pyrepo / "pyproject.toml").write_text("", encoding="utf-8")
|
|
264
|
+
(pyrepo / ".git" / "config").write_text(
|
|
265
|
+
'[remote "origin"]\n\turl = git@github.com:acme/lib.git\n', encoding="utf-8"
|
|
266
|
+
)
|
|
267
|
+
pyfiles = build_files(pyrepo, docs="https://example.com/docs", upstream="https://example.com/up", discussions_flag=True)
|
|
268
|
+
assert " - Python:\n" in pyfiles["bug.yml"], "Python field missing"
|
|
269
|
+
assert "question.yml" not in pyfiles, "redundant question form with a Discussions link"
|
|
270
|
+
assert "url: https://github.com/acme/lib/discussions" in pyfiles["config.yml"], "discussions URL not derived from origin"
|
|
271
|
+
assert "blank issues" not in pyfiles["config.yml"], "comment leaked into the chooser"
|
|
272
|
+
assert pyfiles["config.yml"].index("Documentation") < pyfiles["config.yml"].index("Upstream"), "links out of order"
|
|
273
|
+
assert pyfiles["config.yml"].index("Upstream") < pyfiles["config.yml"].index("Discussions"), "links out of order"
|
|
274
|
+
|
|
275
|
+
# No clobber: an existing file is never overwritten.
|
|
276
|
+
existing = repo / TEMPLATE_DIR / "bug.yml"
|
|
277
|
+
existing.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
existing.write_text("name: mine\n", encoding="utf-8")
|
|
279
|
+
written, skipped = apply_files(repo, files, dry_run=False)
|
|
280
|
+
assert "bug.yml" in skipped, "existing bug.yml was not skipped"
|
|
281
|
+
assert existing.read_text(encoding="utf-8") == "name: mine\n", "existing file was clobbered"
|
|
282
|
+
assert set(written) == {"config.yml", "feature.yml", "question.yml"}, f"unexpected writes: {written}"
|
|
283
|
+
|
|
284
|
+
# Dry run leaves the disk alone.
|
|
285
|
+
before = sorted(p.name for p in (repo / TEMPLATE_DIR).iterdir())
|
|
286
|
+
apply_files(repo, files, dry_run=True)
|
|
287
|
+
assert sorted(p.name for p in (repo / TEMPLATE_DIR).iterdir()) == before, "dry run wrote to disk"
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
import yaml
|
|
291
|
+
except ImportError:
|
|
292
|
+
print("self-check OK: forms generated, gate required, existing files kept (PyYAML absent, parse not verified)")
|
|
293
|
+
else:
|
|
294
|
+
for name, content in files.items():
|
|
295
|
+
assert isinstance(yaml.safe_load(content), dict), f"{name} is not valid YAML"
|
|
296
|
+
print("self-check OK: forms generated, gate required, existing files kept, YAML parses")
|
|
297
|
+
return 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def report_to_email(message: str):
|
|
301
|
+
import webbrowser
|
|
302
|
+
from urllib.parse import quote
|
|
303
|
+
|
|
304
|
+
subject = "[TOOL-REPORT] issue-gate bug or issue"
|
|
305
|
+
webbrowser.open(f"mailto:danyblitz@googlemail.com?subject={quote(subject)}&body={quote(message)}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def main():
|
|
309
|
+
parser = argparse.ArgumentParser(
|
|
310
|
+
prog="issue-gate",
|
|
311
|
+
description="Generate GitHub issue forms and a chooser config that require a real reproduction.",
|
|
312
|
+
)
|
|
313
|
+
parser.add_argument("repo", nargs="?", default=".", help="Path to the repository (default: current directory)")
|
|
314
|
+
parser.add_argument("--docs", default="", metavar="URL", help="Documentation URL for a chooser contact link")
|
|
315
|
+
parser.add_argument("--upstream", default="", metavar="URL", help="Upstream project URL for a chooser contact link")
|
|
316
|
+
parser.add_argument("--discussions", action="store_true", help="Route questions to GitHub Discussions (URL from git origin)")
|
|
317
|
+
parser.add_argument("--dry-run", action="store_true", help="Print the diff and write nothing")
|
|
318
|
+
parser.add_argument("--verbose", action="store_true", help="Print the full generated files")
|
|
319
|
+
parser.add_argument("--self-check", action="store_true", help="Run internal correctness check")
|
|
320
|
+
parser.add_argument("--report", action="store_true", help="Open a pre-filled email to report an issue")
|
|
321
|
+
args = parser.parse_args()
|
|
322
|
+
|
|
323
|
+
if args.self_check:
|
|
324
|
+
sys.exit(self_check())
|
|
325
|
+
|
|
326
|
+
repo = Path(args.repo).resolve()
|
|
327
|
+
if not (repo / ".git").exists() and not (repo / TEMPLATE_DIR).exists():
|
|
328
|
+
parser.error(f"{repo} does not look like a repository (no .git and no {TEMPLATE_DIR})")
|
|
329
|
+
|
|
330
|
+
files = build_files(repo, args.docs, args.upstream, args.discussions)
|
|
331
|
+
written, skipped = apply_files(repo, files, args.dry_run)
|
|
332
|
+
|
|
333
|
+
print(f"\n=== {repo.name} ===")
|
|
334
|
+
if args.verbose or args.dry_run:
|
|
335
|
+
show_diff(repo, files)
|
|
336
|
+
print()
|
|
337
|
+
for name in written:
|
|
338
|
+
print(f" {'would write' if args.dry_run else 'wrote'} {TEMPLATE_DIR / name}")
|
|
339
|
+
for name in skipped:
|
|
340
|
+
print(f" [SKIP] {TEMPLATE_DIR / name}: already exists, left untouched")
|
|
341
|
+
if not written:
|
|
342
|
+
print(" Nothing to do.")
|
|
343
|
+
|
|
344
|
+
env = detect_env(repo)
|
|
345
|
+
print("\n Environment fields prefilled: " + ", ".join(env))
|
|
346
|
+
print(" Create these labels or the forms will open without them: " + label_hint())
|
|
347
|
+
if "Discussions" not in files["config.yml"]:
|
|
348
|
+
print(" No Discussions link: a Question form was added so nothing is left unrouted.")
|
|
349
|
+
print(" Test it: open /issues/new/choose on the repo after pushing.")
|
|
350
|
+
|
|
351
|
+
if args.report:
|
|
352
|
+
report_to_email(f"repo: {repo}\nwritten: {written}\nskipped: {skipped}\nenv: {env}")
|
|
353
|
+
print("\n Email draft opened — send it and I'll get notified automatically.")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
if __name__ == "__main__":
|
|
357
|
+
main()
|