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 POST with a labels array fails with 422 Validation Failed because a label does not yet exist in the repo — GitHub does not auto-create labels on issue creation.
  • assignees are 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 your sev:*, a11y-automation, and wcag-* labels once via POST /repos/{repo}/labels in a bootstrap step, and treat a 422 on 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 CODEOWNERS entry 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.