Skip to content

Commit 3c50af9

Browse files
committed
Add generated GitHub issue templates
1 parent 05cb63f commit 3c50af9

13 files changed

Lines changed: 574 additions & 2 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ The generator writes:
8282
- `.github/workflows/source-sync.yml`
8383
- `.github/workflows/tests.yml`
8484
- `.github/workflows/publish.yml`
85+
- `.github/ISSUE_TEMPLATE/config.yml`
86+
- `.github/ISSUE_TEMPLATE/bug_report.yml`
87+
- `.github/ISSUE_TEMPLATE/documentation_issue.yml`
88+
- `.github/ISSUE_TEMPLATE/feature_request.yml`
8589
- `<package-name>/__init__.py`
8690
- `<package-name>/manager.py`
8791
- `<package-name>/abstraction/__init__.py`
@@ -116,6 +120,8 @@ The generated `source-sync` workflow stays thin: it checks out this repository a
116120

117121
If a project defines `[app.sync]` in its MSRA source, the generated sync step can preserve repo-specific runtime artifacts and ignore generated noise. For the FixPrice layout that means `tests/__snapshots__` stays in `main`, while `**/__pycache__` and `**/*.pyc` are removed before commit.
118122

123+
If a project defines `[app.issue_templates]` in its MSRA source, the generator also writes GitHub issue forms into `.github/ISSUE_TEMPLATE/` using the same source-driven contract.
124+
119125
Before validation, the generated workflow installs the target project's `requirements-dev.txt` so the validation step runs with the generated dependencies available.
120126

121127
The source and target branches come from the repo-specific sync config, so the generated workflow itself stays thin.

docs/msra-app.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
| `timeout_ms` | Глобальный таймаут по умолчанию | Становится базовым лимитом для runtime-операций и используется, если локальный блок не переопределяет timeout. |
1919
| `browser` | Браузер по умолчанию | Определяет, какой движок запустит runtime для warmup и browser-backed функций. Если не указан, runtime использует `camoufox` по умолчанию. |
2020
| `@DisallowHeadless` | Запрещает headless-запуск | Меняет дефолт `headless` на `False`, заставляет runtime выбрасывать ошибку, если caller всё же просит `headless=True`, и переключает generated test pipeline на headed запуск под `xvfb-run`. |
21+
| `issue_templates` | GitHub issue forms | Если в source MSRA присутствует `[app.issue_templates]`, generator пишет `.github/ISSUE_TEMPLATE/` с `config.yml`, `bug_report.yml`, `documentation_issue.yml` и `feature_request.yml`. |
2122
| `@Humanize` | Включает humanized-режим браузера | Передаёт `humanize=` в `AsyncCamoufox`, снижает “ботоподобность” поведения и требует `browser="camoufox"`. Разрешена либо как пустая аннотация, либо с положительным числом интенсивности, например `@Humanize(0.5)`. |
2223
| `@BlockImages` | Блокирует загрузку изображений | Передаёт `block_images=` в `AsyncCamoufox`, ускоряет старт и экономит трафик. Аннотация работает только с `browser="camoufox"`. |
2324

@@ -56,6 +57,21 @@
5657
- `min_required_python` задаёт нижнюю границу `requires-python`; generator также расширяет его в Python version classifiers, поднимая верхнюю границу по официальному `python.org/api/v2/downloads/release/` и беря предпоследний стабильный `3.x` family как включительный предел.
5758
- Для шаблонов, где это нужно, generator подставляет значения вроде текущего года и списка copyright holders из `authors`.
5859

60+
## `issue_templates`
61+
62+
Если `[app.issue_templates]` присутствует в source MSRA, generator пишет GitHub issue forms в `.github/ISSUE_TEMPLATE/`.
63+
64+
Ожидаются:
65+
66+
- `blank_issues_enabled`
67+
- `assignee`
68+
- `contact_links`
69+
- `bug_report`
70+
- `documentation_issue`
71+
- `feature_request`
72+
73+
Контракт целиком source-driven: если блок есть, генератор требует все перечисленные части и падает runtime error, если чего-то не хватает.
74+
5975
## Аннотации
6076

6177
`@Humanize` включает humanized-режим браузера и принимает пустую форму или положительное число интенсивности, например `@Humanize(0.5)`.

msra_codegen/issue_templates.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import shutil
5+
from pathlib import Path
6+
from typing import Any
7+
8+
from .file_utils import write_text
9+
from .template_engine import render_template
10+
11+
12+
def build_github_issue_templates_context(project: dict[str, Any]) -> dict[str, Any] | None:
13+
app = project.get("app", {})
14+
issue_templates = app.get("issue_templates")
15+
if not issue_templates:
16+
return None
17+
if not isinstance(issue_templates, dict):
18+
raise RuntimeError("app.issue_templates must be a table.")
19+
20+
blank_issues_enabled = issue_templates.get("blank_issues_enabled")
21+
if not isinstance(blank_issues_enabled, bool):
22+
raise RuntimeError("app.issue_templates.blank_issues_enabled must be a boolean.")
23+
24+
assignee = str(issue_templates.get("assignee", "")).strip()
25+
if not assignee:
26+
raise RuntimeError("app.issue_templates.assignee must be a non-empty string.")
27+
28+
contact_links = issue_templates.get("contact_links")
29+
if not isinstance(contact_links, list) or not contact_links:
30+
raise RuntimeError("app.issue_templates.contact_links must be a non-empty list.")
31+
32+
normalized_contact_links: list[dict[str, str]] = []
33+
for index, link in enumerate(contact_links):
34+
if not isinstance(link, dict):
35+
raise RuntimeError(f"app.issue_templates.contact_links[{index}] must be a table.")
36+
name = str(link.get("name", "")).strip()
37+
url = str(link.get("url", "")).strip()
38+
about = str(link.get("about", "")).strip()
39+
if not name or not url or not about:
40+
raise RuntimeError(f"app.issue_templates.contact_links[{index}] must define name, url, and about.")
41+
normalized_contact_links.append(
42+
{
43+
"name": name,
44+
"url": url,
45+
"about": about,
46+
}
47+
)
48+
49+
templates: dict[str, dict[str, Any]] = {}
50+
for template_name in ("bug_report", "documentation_issue", "feature_request"):
51+
template_value = issue_templates.get(template_name)
52+
if not isinstance(template_value, dict):
53+
raise RuntimeError(f"app.issue_templates.{template_name} must be a table.")
54+
55+
name = str(template_value.get("name", "")).strip()
56+
description = str(template_value.get("description", "")).strip()
57+
title = str(template_value.get("title", "")).strip()
58+
labels = template_value.get("labels")
59+
if not isinstance(labels, list) or not labels:
60+
raise RuntimeError(f"app.issue_templates.{template_name}.labels must be a non-empty list.")
61+
normalized_labels: list[str] = []
62+
for index, label in enumerate(labels):
63+
text = str(label).strip()
64+
if not text:
65+
raise RuntimeError(f"app.issue_templates.{template_name}.labels[{index}] must be a non-empty string.")
66+
normalized_labels.append(text)
67+
if not name or not description or not title:
68+
raise RuntimeError(f"app.issue_templates.{template_name} must define name, description, and title.")
69+
70+
templates[template_name] = {
71+
"name": name,
72+
"description": description,
73+
"title": title,
74+
"labels": normalized_labels,
75+
}
76+
77+
return {
78+
"blank_issues_enabled": blank_issues_enabled,
79+
"assignee": assignee,
80+
"contact_links": normalized_contact_links,
81+
"yaml_value": lambda value: json.dumps(value, ensure_ascii=False),
82+
**templates,
83+
}
84+
85+
86+
def generate_github_issue_templates_project(project: dict[str, Any], output_dir: Path) -> None:
87+
issue_templates_root = output_dir / ".github" / "ISSUE_TEMPLATE"
88+
context = build_github_issue_templates_context(project)
89+
if issue_templates_root.exists():
90+
shutil.rmtree(issue_templates_root)
91+
if context is None:
92+
return
93+
94+
issue_templates_root.mkdir(parents=True, exist_ok=True)
95+
write_text(
96+
issue_templates_root / "config.yml",
97+
render_template("github/issue_templates/config.yml.tpl", context),
98+
)
99+
write_text(
100+
issue_templates_root / "bug_report.yml",
101+
render_template("github/issue_templates/bug_report.yml.tpl", context),
102+
)
103+
write_text(
104+
issue_templates_root / "documentation_issue.yml",
105+
render_template("github/issue_templates/documentation_issue.yml.tpl", context),
106+
)
107+
write_text(
108+
issue_templates_root / "feature_request.yml",
109+
render_template("github/issue_templates/feature_request.yml.tpl", context),
110+
)

msra_codegen/package_writer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
write_group_package,
1717
)
1818
from .github_workflows import generate_github_workflows_project
19+
from .issue_templates import generate_github_issue_templates_project
1920
from .core_naming import abstraction_module_name_from_path, normalize_abstraction_path, normalize_script_path
2021
from .file_utils import write_text
2122
from .python_formatting import format_python_tree
@@ -151,6 +152,7 @@ def generate_project(
151152
shutil.rmtree(legacy_license_dir)
152153
write_root_license(output_dir, project)
153154
generate_github_workflows_project(project, output_dir, package_name)
155+
generate_github_issue_templates_project(project, output_dir)
154156

155157
from .docs_generator import generate_docs_project
156158

msra_codegen/project_model.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,17 @@ def get_assignment(table: dict[str, Any] | None, key: str, default: Any = None)
201201
"ignored_generated_patterns": ignored_generated_patterns,
202202
}
203203

204+
issue_templates_table = get_table(["app", "issue_templates"])
205+
issue_templates_child_tables = [
206+
table
207+
for table in tables
208+
if len(table["path"]) >= 3 and table["path"][0] == "app" and table["path"][1] == "issue_templates"
209+
]
210+
if issue_templates_child_tables and issue_templates_table is None:
211+
raise RuntimeError("app.issue_templates child tables require the root app.issue_templates table.")
212+
if issue_templates_table:
213+
app["issue_templates"] = build_issue_templates_spec(tables, get_assignment)
214+
204215
variable_tables = [
205216
table
206217
for table in tables
@@ -449,6 +460,95 @@ def build_headers_spec(table: dict[str, Any] | None, get_assignment) -> dict[str
449460
}
450461

451462

463+
def build_issue_templates_spec(tables: list[dict[str, Any]], get_assignment) -> dict[str, Any]:
464+
root_table = None
465+
for table in tables:
466+
if table["path"] == ["app", "issue_templates"]:
467+
root_table = table
468+
break
469+
if root_table is None:
470+
raise RuntimeError("app.issue_templates is missing.")
471+
472+
blank_issues_enabled_value = get_assignment(root_table, "blank_issues_enabled")
473+
if blank_issues_enabled_value is None:
474+
raise RuntimeError('app.issue_templates.blank_issues_enabled is required.')
475+
blank_issues_enabled = bool(get_plain_value(blank_issues_enabled_value))
476+
477+
assignee = str(get_plain_value(get_assignment(root_table, "assignee", ""))).strip()
478+
if not assignee:
479+
raise RuntimeError('app.issue_templates.assignee is required and must be a non-empty string.')
480+
481+
contact_links_value = get_plain_value(get_assignment(root_table, "contact_links", []))
482+
if not isinstance(contact_links_value, list):
483+
raise TypeError('app.issue_templates.contact_links must be a list of objects.')
484+
contact_links: list[dict[str, Any]] = []
485+
for index, item in enumerate(contact_links_value):
486+
if not isinstance(item, dict):
487+
raise TypeError(f'app.issue_templates.contact_links[{index}] must be an inline table.')
488+
name = str(get_plain_value(item.get("name", ""))).strip()
489+
url = str(get_plain_value(item.get("url", ""))).strip()
490+
about = str(get_plain_value(item.get("about", ""))).strip()
491+
if not name:
492+
raise RuntimeError(f'app.issue_templates.contact_links[{index}].name is required and must be non-empty.')
493+
if not url:
494+
raise RuntimeError(f'app.issue_templates.contact_links[{index}].url is required and must be non-empty.')
495+
if not about:
496+
raise RuntimeError(f'app.issue_templates.contact_links[{index}].about is required and must be non-empty.')
497+
contact_links.append(
498+
{
499+
"name": name,
500+
"url": url,
501+
"about": about,
502+
}
503+
)
504+
505+
if not contact_links:
506+
raise RuntimeError("app.issue_templates.contact_links must contain at least one entry.")
507+
508+
forms = {}
509+
for form_name in ("bug_report", "documentation_issue", "feature_request"):
510+
form_table = None
511+
for table in tables:
512+
if table["path"] == ["app", "issue_templates", form_name]:
513+
form_table = table
514+
break
515+
if form_table is None:
516+
raise RuntimeError(f'app.issue_templates.{form_name} is required.')
517+
name = str(get_plain_value(get_assignment(form_table, "name", ""))).strip()
518+
description = str(get_plain_value(get_assignment(form_table, "description", ""))).strip()
519+
title = str(get_plain_value(get_assignment(form_table, "title", ""))).strip()
520+
labels_value = get_plain_value(get_assignment(form_table, "labels", []))
521+
if not isinstance(labels_value, list):
522+
raise TypeError(f'app.issue_templates.{form_name}.labels must be a list of strings.')
523+
labels: list[str] = []
524+
for index, label in enumerate(labels_value):
525+
text = str(get_plain_value(label)).strip()
526+
if not text:
527+
raise RuntimeError(f'app.issue_templates.{form_name}.labels[{index}] must be a non-empty string.')
528+
labels.append(text)
529+
if not name:
530+
raise RuntimeError(f'app.issue_templates.{form_name}.name is required and must be non-empty.')
531+
if not description:
532+
raise RuntimeError(f'app.issue_templates.{form_name}.description is required and must be non-empty.')
533+
if not title:
534+
raise RuntimeError(f'app.issue_templates.{form_name}.title is required and must be non-empty.')
535+
if not labels:
536+
raise RuntimeError(f'app.issue_templates.{form_name}.labels must contain at least one entry.')
537+
forms[form_name] = {
538+
"name": name,
539+
"description": description,
540+
"title": title,
541+
"labels": labels,
542+
}
543+
544+
return {
545+
"blank_issues_enabled": blank_issues_enabled,
546+
"assignee": assignee,
547+
"contact_links": contact_links,
548+
**forms,
549+
}
550+
551+
452552
def build_url_param_spec(table: dict[str, Any], get_assignment) -> dict[str, Any]:
453553
list_style = get_plain_value(get_assignment(table, "list_style"))
454554
if not isinstance(list_style, dict):
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: {{ yaml_value(bug_report.name) }}
2+
description: {{ yaml_value(bug_report.description) }}
3+
title: {{ yaml_value(bug_report.title) }}
4+
labels: {{ yaml_value(bug_report.labels) }}
5+
assignees: [{{ yaml_value(assignee) }}]
6+
7+
body:
8+
- type: markdown
9+
attributes:
10+
value: |
11+
**Thanks for taking the time to report a bug!**
12+
13+
- type: checkboxes
14+
id: area
15+
attributes:
16+
label: Affected area(s)
17+
description: Check all that apply.
18+
options:
19+
- label: core
20+
- label: anti-bot
21+
- label: python interface
22+
validations:
23+
required: true
24+
25+
- type: textarea
26+
id: steps
27+
attributes:
28+
label: What did you do?
29+
description: Step-by-step commands or actions to reproduce the issue.
30+
render: plaintext
31+
validations:
32+
required: true
33+
34+
- type: textarea
35+
id: actual
36+
attributes:
37+
label: What happened?
38+
description: Paste error messages or describe the incorrect behaviour. Logs can be attached below.
39+
render: plaintext
40+
validations:
41+
required: true
42+
43+
- type: textarea
44+
id: expected
45+
attributes:
46+
label: What did you expect to happen?
47+
render: plaintext
48+
validations:
49+
required: true
50+
51+
- type: textarea
52+
id: logs
53+
attributes:
54+
label: Logs / screenshots
55+
description: Drag & drop log files or screenshots here.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
blank_issues_enabled: {{ "true" if blank_issues_enabled else "false" }}
2+
3+
contact_links:
4+
{% for link in contact_links %}
5+
- name: {{ yaml_value(link.name) }}
6+
url: {{ yaml_value(link.url) }}
7+
about: {{ yaml_value(link.about) }}
8+
{% endfor %}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: {{ yaml_value(documentation_issue.name) }}
2+
description: {{ yaml_value(documentation_issue.description) }}
3+
title: {{ yaml_value(documentation_issue.title) }}
4+
labels: {{ yaml_value(documentation_issue.labels) }}
5+
assignees: [{{ yaml_value(assignee) }}]
6+
7+
body:
8+
- type: markdown
9+
attributes:
10+
value: |
11+
**Help us keep the docs sharp!**
12+
13+
- type: input
14+
id: url
15+
attributes:
16+
label: Link to the problematic page
17+
placeholder: "https://example.com/docs/..."
18+
validations:
19+
required: true
20+
21+
- type: textarea
22+
id: problem
23+
attributes:
24+
label: What’s broken or unclear?
25+
render: markdown
26+
validations:
27+
required: true
28+
29+
- type: textarea
30+
id: screenshots
31+
attributes:
32+
label: Screenshots (optional)
33+
description: Drag & drop images if they help illustrate the issue.

0 commit comments

Comments
 (0)