Automatic issue triage on Gitea (Jev): kind and area labels, missing information, privacy and clinical-use flags
This commit is contained in:
1 parent
3ed0503bdf
commit
01e60ba817
4 files changed
+204
-2
No files matched your search
@@ -6,7 +6,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thank you for reporting. Please do **not** attach vendor files (IDAT, BPM, CSV, EGT, GTC) or any genetic data of real people.
|
Thank you for reporting. Please do **not** attach vendor files (IDAT, BPM, CSV, EGT, GTC) or any genetic data of real people. An automatic triage reads the text to suggest labels (see CONTRIBUTING.md).
|
||||||
- type: input
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Gitea Actions workflow: automatic first triage of new issues (scripts/jev/issue_triage.py, TypeSafe Jev).
|
||||||
|
# Needs the repository secret TYPESAFE_API_KEY; the built-in token applies labels and posts one comment.
|
||||||
|
name: issue-triage
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened, edited]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
triage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Triage
|
||||||
|
env:
|
||||||
|
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITEA_API: ${{ github.api_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: python3 scripts/jev/issue_triage.py --event "$GITHUB_EVENT_PATH"
|
||||||
+3
-1
@@ -76,7 +76,9 @@ calling the API.
|
|||||||
|
|
||||||
## Reporting problems
|
## Reporting problems
|
||||||
|
|
||||||
Use the issue templates. For a wrong or unexpected result, attach the `run_manifest.json` of the run and the exact
|
Use the issue templates. New issues get an automatic first triage (`.gitea/workflows/issue_triage.yml`): the title
|
||||||
|
and text are sent to the TypeSafe API, which suggests kind and area labels, notes missing information, and flags
|
||||||
|
personal data, vendor file contents or credentials so they can be removed. A maintainer checks every label. For a wrong or unexpected result, attach the `run_manifest.json` of the run and the exact
|
||||||
command; it records every version, option and input checksum. Please do not attach vendor files or any genetic data
|
command; it records every version, option and input checksum. Please do not attach vendor files or any genetic data
|
||||||
of real people. For security issues see `SECURITY.md`.
|
of real people. For security issues see `SECURITY.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Triage of new Gitea issues: kind and area labels, missing information, and privacy flags.
|
||||||
|
|
||||||
|
Runs from .gitea/workflows/issue_triage.yml when an issue is opened or edited. Jev answers, in one request:
|
||||||
|
|
||||||
|
* the kind of issue (Choice: bug, feature, question, security, other) and the area of SimurgArray it concerns
|
||||||
|
(Choice);
|
||||||
|
* three separate yes/no questions: does the text contain genetic data of identifiable people, contents of vendor
|
||||||
|
files, or credentials (Noul each).
|
||||||
|
|
||||||
|
Code checks the template fields (version, command), applies the labels and, when the issue is opened, posts one
|
||||||
|
short comment. Low confidence answers get a ``needs-triage`` label instead of a guess. The issue title and text are
|
||||||
|
sent to the TypeSafe API; CONTRIBUTING.md says so.
|
||||||
|
|
||||||
|
python scripts/jev/issue_triage.py --event event.json --dry-run # print the planned actions only
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from client import choice, noul, system_one
|
||||||
|
|
||||||
|
KINDS = {
|
||||||
|
"bug": "Something fails, crashes, or gives a wrong or unexpected result",
|
||||||
|
"feature": "A request for a new capability or a change in behaviour",
|
||||||
|
"question": "A question about how to install, use or interpret SimurgArray",
|
||||||
|
"security": "A vulnerability or a way to make SimurgArray leak data, run code or write outside its folders",
|
||||||
|
"other": "Anything else, including spam",
|
||||||
|
}
|
||||||
|
AREAS = {
|
||||||
|
"genotype": "IDAT reading, normalization, genotype calling, GTC files, sample summary",
|
||||||
|
"qc": "Control probes, sample QC, contamination, relatedness, the HTML QC report",
|
||||||
|
"vcf_export": "VCF, BCF, PLINK, bedGraph, final reports and other exports",
|
||||||
|
"cn": "Copy number, loss of heterozygosity and mosaicism",
|
||||||
|
"pgx": "Pharmacogenomics: targeted copy number, star alleles, phenotypes, the PGx database",
|
||||||
|
"train": "ArrayTrain cluster file training",
|
||||||
|
"methylation": "Methylation arrays and their QC",
|
||||||
|
"compare": "The simurg compare differential testing tool and nightly runs",
|
||||||
|
"install": "Installation, packaging, Docker, conda, the Rust kernels, platforms",
|
||||||
|
"docs": "Documentation, examples, the website or blog",
|
||||||
|
"other": "None of these areas",
|
||||||
|
}
|
||||||
|
PRIVACY = {
|
||||||
|
"genetic_data": "Does `issue` contain genetic data of identifiable people, such as genotype calls, sequences, or "
|
||||||
|
"sample identifiers linked to real people? Public reference samples (1000 Genomes, GIAB, demo "
|
||||||
|
"data) do not count.",
|
||||||
|
"vendor_content": "Does `issue` paste the contents of vendor files, such as manifest rows, cluster file "
|
||||||
|
"records or IDAT bytes, beyond a few locus names?",
|
||||||
|
"credentials": "Does `issue` contain credentials: passwords, API keys, tokens or private keys?",
|
||||||
|
} # fmt: skip
|
||||||
|
LABELS = { # name: (colour, description)
|
||||||
|
"kind/bug": ("C8453A", "Something is wrong"), "kind/feature": ("2F8F5B", "New capability"),
|
||||||
|
"kind/question": ("A3A7C9", "Question"), "kind/security": ("8C6500", "Security report"),
|
||||||
|
"kind/other": ("5A5E7E", "Other"), "needs-info": ("D4A017", "Version or command missing"),
|
||||||
|
"needs-triage": ("1E2240", "Automatic triage was unsure"),
|
||||||
|
"privacy-check": ("C8453A", "May contain personal data, vendor content or credentials"),
|
||||||
|
} # fmt: skip
|
||||||
|
for _a in AREAS:
|
||||||
|
if _a != "other":
|
||||||
|
LABELS[f"area/{_a}"] = ("1E2240", AREAS[_a])
|
||||||
|
CONFIDENT, FLAG = 0.6, 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def missing_info(body: str, kind: str) -> list[str]:
|
||||||
|
"""Template fields of a bug report that were left empty."""
|
||||||
|
if kind != "bug":
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for field, label in (("SimurgArray version", "the SimurgArray version"), ("Command", "the exact command")):
|
||||||
|
m = re.search(rf"###\s*{field}\s*\n+(.*?)(?=\n###|\Z)", body, re.S)
|
||||||
|
empty = m is None or not m.group(1).strip() or "_No response_" in m.group(1)
|
||||||
|
version_elsewhere = field == "SimurgArray version" and re.search(r"\b\d+\.\d+\.\d+\b", body)
|
||||||
|
if empty and not version_elsewhere:
|
||||||
|
out.append(label)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def decide(title: str, body: str) -> dict[str, Any]:
|
||||||
|
qs = {"kind": choice("What kind of issue is `issue`?", KINDS),
|
||||||
|
"area": choice("Which part of SimurgArray does `issue` concern?", AREAS)} # fmt: skip
|
||||||
|
qs.update({k: noul(v) for k, v in PRIVACY.items()})
|
||||||
|
qs["clinical"] = noul(
|
||||||
|
"Is the author using SimurgArray results to make or check decisions about a patient's diagnosis or treatment?"
|
||||||
|
)
|
||||||
|
ans = system_one({"project": "SimurgArray, open source genotyping array analysis",
|
||||||
|
"issue": {"title": title, "body": body[:6000]}}, qs) # fmt: skip
|
||||||
|
kind, area = ans["kind"], ans["area"]
|
||||||
|
labels: list[str] = []
|
||||||
|
if float(kind["confidence"]) >= CONFIDENT:
|
||||||
|
labels.append(f"kind/{kind['choice']}")
|
||||||
|
if float(area["confidence"]) >= CONFIDENT and area["choice"] != "other":
|
||||||
|
labels.append(f"area/{area['choice']}")
|
||||||
|
if len(labels) < 2:
|
||||||
|
labels.append("needs-triage")
|
||||||
|
flags = [k for k in PRIVACY if float(ans[k]["noul"]) >= FLAG]
|
||||||
|
if flags:
|
||||||
|
labels.append("privacy-check")
|
||||||
|
clinical = float(ans["clinical"]["noul"]) >= FLAG
|
||||||
|
missing = missing_info(body, str(kind["choice"]))
|
||||||
|
if missing:
|
||||||
|
labels.append("needs-info")
|
||||||
|
return {"kind": kind["choice"], "kind_confidence": round(float(kind["confidence"]), 2), "area": area["choice"],
|
||||||
|
"area_confidence": round(float(area["confidence"]), 2), "labels": labels, "privacy": flags,
|
||||||
|
"clinical": clinical, "missing": missing} # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def comment_text(d: dict[str, Any]) -> str:
|
||||||
|
parts = ["Thank you for the report. An automatic first triage added the labels "
|
||||||
|
+ ", ".join(f"`{x}`" for x in d["labels"]) + "; a maintainer will check them."] # fmt: skip
|
||||||
|
if d["kind"] == "security":
|
||||||
|
parts.append("This looks like a security report. Please do not discuss details in public: write to "
|
||||||
|
"[email protected] and consider editing this issue to remove them.") # fmt: skip
|
||||||
|
if d["privacy"]:
|
||||||
|
what = {"genetic_data": "genetic data of real people", "vendor_content": "contents of vendor files",
|
||||||
|
"credentials": "credentials"} # fmt: skip
|
||||||
|
parts.append("The text may contain " + " and ".join(what[f] for f in d["privacy"]) + ". Please edit the "
|
||||||
|
"issue to remove it; issues are public.") # fmt: skip
|
||||||
|
if d.get("clinical"):
|
||||||
|
parts.append(
|
||||||
|
"A reminder: SimurgArray is a research tool. Its results, including pharmacogenomics, must not be used "
|
||||||
|
"for diagnosis or treatment decisions; please rely on a validated clinical test."
|
||||||
|
)
|
||||||
|
if d["missing"]:
|
||||||
|
parts.append("To reproduce the problem we need " + " and ".join(d["missing"]) + " (`simurg --version` "
|
||||||
|
"prints the version; the `run_manifest.json` of the run helps most).") # fmt: skip
|
||||||
|
parts.append("_Triage uses TypeSafe Jev on the issue text; see CONTRIBUTING.md._")
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class Gitea:
|
||||||
|
def __init__(self, api: str, repo: str, token: str) -> None:
|
||||||
|
self.base, self.token = f"{api.rstrip('/')}/repos/{repo}", token
|
||||||
|
|
||||||
|
def req(self, method: str, path: str, data: Any = None) -> Any:
|
||||||
|
body = json.dumps(data).encode() if data is not None else None
|
||||||
|
headers = {"Authorization": f"token {self.token}", "Content-Type": "application/json", "User-Agent": "triage"}
|
||||||
|
r = urllib.request.Request(self.base + path, data=body, method=method, headers=headers)
|
||||||
|
with urllib.request.urlopen(r, timeout=60) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return json.loads(raw) if raw else None
|
||||||
|
|
||||||
|
def label_ids(self, names: list[str]) -> list[int]:
|
||||||
|
have = {lb["name"]: lb["id"] for lb in self.req("GET", "/labels?limit=100")}
|
||||||
|
for n in names:
|
||||||
|
if n not in have:
|
||||||
|
col, desc = LABELS.get(n, ("5A5E7E", ""))
|
||||||
|
have[n] = self.req("POST", "/labels", {"name": n, "color": f"#{col}", "description": desc})["id"]
|
||||||
|
return [have[n] for n in names]
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||||
|
ap.add_argument("--event", type=Path, default=Path(os.environ.get("GITHUB_EVENT_PATH", "event.json")))
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
ev = json.loads(a.event.read_text())
|
||||||
|
issue, action = ev["issue"], ev.get("action", "opened")
|
||||||
|
if issue.get("pull_request"):
|
||||||
|
return 0
|
||||||
|
d = decide(issue.get("title", ""), issue.get("body") or "")
|
||||||
|
print(json.dumps({"issue": issue["number"], "action": action, **d}))
|
||||||
|
if a.dry_run:
|
||||||
|
print(comment_text(d))
|
||||||
|
return 0
|
||||||
|
g = Gitea(os.environ["GITEA_API"], os.environ["REPO"], os.environ["GITEA_TOKEN"])
|
||||||
|
g.req("POST", f"/issues/{issue['number']}/labels", {"labels": g.label_ids(d["labels"])})
|
||||||
|
if action == "opened":
|
||||||
|
g.req("POST", f"/issues/{issue['number']}/comments", {"body": comment_text(d)})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user