Auto-Creating GitHub Issues from Violation JSON
To create a GitHub issue from a violation record idempotently, embed the finding’s fingerprint as an invisible HTML-comment marker in the issue body, use the Search API to find any issue containing that marker, and only POST /repos/{owner}/{repo}/issues when the search returns nothing — otherwise patch the issue, reopening it if it was closed. This page gives the GitHub Issues REST API calls, the hidden-marker idempotency search, and label and assignee derivation from CODEOWNERS, in Python requests.
This is the GitHub binding of the tracker-agnostic router in remediation routing: violation JSON to issue tracker, part of the Enterprise WCAG Audit Architecture & Standards Mapping section. The parent guide owns the fingerprint and the create/update/close decision; this page assumes a fingerprint already exists and focuses on GitHub’s specifics — GitHub has no priority field and no arbitrary label registry, so the mapping and idempotency mechanism differ from the Jira path.
When This Applies
Reach for this when the routing logic is correct but the GitHub write is duplicating or mis-labeling:
- A new issue appears on every scan because GitHub Issues has no native custom field to hold a dedup key, so nothing was searched before creating.
- A
POSTwith alabelsarray fails with422 Validation Failedbecause a label does not yet exist in the repo — GitHub does not auto-create labels on issue creation. assigneesare silently dropped because the login is not a collaborator with access to the repo.- A defect that was fixed and its issue closed reappears as a brand-new issue instead of reopening the original.
If the violation record is unvalidated or the fingerprint is unstable, resolve that in the parent router first; this page assumes both are already correct.
Minimal Reproducible Example
The naive create posts labels that may not exist and never searches for a prior issue, so it both risks a 422 and duplicates on the next run.
import requests
# BROKEN: no dedup search, and 'priority:high' label may not exist -> 422.
resp = requests.post(
"https://api.github.com/repos/acme/storefront/issues",
headers={"Authorization": "Bearer GH_TOKEN",
"Accept": "application/vnd.github+json"},
json={
"title": "color-contrast on /pricing",
"body": "Contrast 3.1:1 fails WCAG 1.4.3", # no fingerprint marker
"labels": ["a11y", "priority:high"], # 422 if label absent
},
)
resp.raise_for_status()Correct Implementation
Encode the fingerprint as a zero-visibility HTML comment in the body, search for it, and branch on the result. GitHub renders <!-- ... --> as nothing, so the marker is invisible to humans but fully searchable.
import requests
API = "https://api.github.com"
REPO = "acme/storefront"
SESSION = requests.Session()
SESSION.headers.update({
"Authorization": "Bearer GH_TOKEN", # App/bot token, never a human PAT
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
})
# GitHub has no priority field, so severity becomes a label. Labels must
# pre-exist in the repo — provision them once, out of band.
TIER_LABEL = {"T1-critical": "sev:critical", "T2-serious": "sev:serious",
"T3-moderate": "sev:moderate", "T4-minor": "sev:minor"}
def marker(fingerprint: str) -> str:
"""Invisible, searchable dedup key embedded in the issue body."""
return f"<!-- a11y-fingerprint:{fingerprint} -->"
def find_issue(fingerprint: str) -> dict | None:
"""Search open AND closed issues for the hidden marker."""
q = f'repo:{REPO} in:body "{marker(fingerprint)}"'
r = SESSION.get(f"{API}/search/issues", params={"q": q, "per_page": 1})
r.raise_for_status()
items = r.json()["items"]
return items[0] if items else None
def body_for(finding: dict, fingerprint: str, sla_due: str) -> str:
return "\n".join([
f'**Rule:** `{finding["rule_id"]}` **WCAG:** {finding["wcag_sc"]}',
f'**Element:** `{finding["stable_target"]}`',
f'**Route:** {finding["url"]}',
f'**SLA due:** {sla_due[:10]}',
"",
marker(fingerprint), # <-- the searchable dedup key, invisible when rendered
])
def create_or_reopen(finding, fingerprint, tier, sla_due, owners: list[str]) -> int:
hit = find_issue(fingerprint)
labels = ["a11y-automation", TIER_LABEL[tier], f'wcag-{finding["wcag_sc"].replace(".", "-")}']
if hit:
number = hit["number"]
patch = {"labels": labels}
if hit["state"] == "closed":
patch["state"] = "open" # reopen a flapping defect, don't duplicate
SESSION.patch(f"{API}/repos/{REPO}/issues/{number}", json=patch)
return number
r = SESSION.post(f"{API}/repos/{REPO}/issues", json={
"title": f'{finding["rule_id"]}: {finding["wcag_sc"]} on {finding["url"]}',
"body": body_for(finding, fingerprint, sla_due),
"labels": labels,
# Assignees from CODEOWNERS; GitHub drops any login without repo access.
"assignees": owners,
})
r.raise_for_status()
return r.json()["number"]Deriving owners from CODEOWNERS closes the loop between the failing element and the team that owns it. Parse the repo’s CODEOWNERS, match the component’s source path against the glob patterns, strip the leading @, and pass the resulting logins as assignees. Because GitHub silently ignores an assignee that lacks repo access, filter the list against the collaborators endpoint first so an unresolvable owner surfaces as a routing gap rather than a silent drop.
Pipeline Integration Note
This binding is the GitHub leaf of the reconciliation loop. The parent router computes the fingerprint and produces the create/update/close plan, calling create_or_reopen per finding and issuing a close PATCH ({"state": "closed", "state_reason": "completed"}) for any open automated issue whose fingerprint is absent from the run. Findings arrive deduplicated and component-tagged from the error categorization and triage pipelines, so the source path used for the CODEOWNERS match is already attached. Severity labels and the SLA date come from the tier model in severity tiers and SLA tracking for accessibility violations. The Search API is rate-limited far more tightly than the core API (30 requests/minute), so batch one search per run where possible and honor the Retry-After header rather than searching once per finding.
Gotchas
- Labels must exist before you attach them. Unlike Jira labels, GitHub repo labels are a managed set; posting a non-existent label returns
422. Provision yoursev:*,a11y-automation, andwcag-*labels once viaPOST /repos/{repo}/labelsin a bootstrap step, and treat a422on create as a missing-label signal, not a transient error. - Search indexing lags issue creation. The Search API is eventually consistent — an issue created seconds ago may not yet appear in
search/issues. Within one run, keep created fingerprints in an in-memory set so back-to-back findings for the same defect dedupe locally instead of racing the index. - Assignees need repo access or they vanish. A
CODEOWNERSentry pointing at a team or a user without collaborator access is accepted by the API but produces an unassigned issue. Resolve teams to members and filter against actual collaborators before assigning, so ownership is real rather than aspirational.
Frequently Asked Questions
How do I make GitHub issue creation idempotent without a custom field?
Embed the fingerprint in the issue body as an HTML comment, which GitHub renders as nothing, then query the Search API with in:body for that exact marker before creating. GitHub Issues has no arbitrary custom-field storage, so the body marker is the durable place to keep a machine-readable dedup key that a human never sees.
Why does my create call fail with 422 Validation Failed on labels?
GitHub does not auto-create labels when you attach them to an issue; a label that does not already exist in the repo makes the whole request fail. Provision the label set once with the labels endpoint in a bootstrap step, and treat a 422 mentioning a label as a missing-label condition rather than retrying blindly.
How do I assign the issue to the right team automatically?
Parse the repo’s CODEOWNERS file, match the failing component’s source path against its glob patterns, and use the resolved logins as assignees. Filter that list against the collaborators endpoint first, because GitHub silently drops any assignee without repo access, which would otherwise leave the issue unowned with no error.
Should a returning defect reopen the closed issue or open a new one?
Reopen it. Have the search cover closed issues as well as open ones, and when a closed issue carries the matching fingerprint marker, PATCH its state back to open instead of creating a fresh issue. That keeps the prior discussion and fix attempt attached and prevents the backlog from filling with duplicates of a flapping defect.