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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ issuegate = issuegate.__main__:main
@@ -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
@@ -0,0 +1,2 @@
1
+ """Issue Gate — GitHub issue forms and chooser config that require a real reproduction."""
2
+ __version__ = "1.0.0"
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()