mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(ci): retarget label docs at triage.yml and guard that applied labels are declared (#7328)
* fix(ci): retarget label docs at triage.yml and guard label declarations .github/labels.yml still described the auto-labeling setup as the three workflows triage.yml replaced (pr-labeler.yml / pr-triage.yml / issue-triage.yml), and its section headers pointed at the deleted .github/labeler.yml and pr-triage.yml. A reader following those pointers lands on 404s. The same pair had an unguarded failure mode: triage.yml applies labels by name, and GitHub rejects the whole addLabels batch with 422 when any name is missing from the repository. Nothing checked that a label added to the workflow also reached the declarative source of truth. Point the comments at triage.yml and add scripts/check_label_declarations.py, which fails when the workflow applies a label labels.yml does not declare. * fix(ci): trace applied labels and cover the declaration guard --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
4583c46311
commit
cf15f05f4d
12
.github/labels.yml
vendored
12
.github/labels.yml
vendored
@ -1,9 +1,9 @@
|
||||
# Declarative label source of truth for DeerFlow.
|
||||
#
|
||||
# This file is the single source of truth for repository labels used by the
|
||||
# auto-labeling workflows (.github/workflows/pr-labeler.yml, pr-triage.yml,
|
||||
# issue-triage.yml). Auto-labelers can only apply labels that already exist,
|
||||
# so every label referenced by a workflow MUST be declared here.
|
||||
# auto-labeling workflow (.github/workflows/triage.yml). Auto-labelers can only
|
||||
# apply labels that already exist, so every label applied by that workflow MUST
|
||||
# be declared here; scripts/check_label_declarations.py enforces that pair.
|
||||
#
|
||||
# Apply with: uv run --with pyyaml python scripts/sync_labels.py [--repo OWNER/NAME]
|
||||
# CI keeps it in sync via .github/workflows/label-sync.yml (runs on changes here).
|
||||
@ -30,7 +30,7 @@ labels:
|
||||
color: d876e3
|
||||
description: Further information is requested
|
||||
|
||||
# ── Area (auto, by changed paths — see .github/labeler.yml) ───────────────
|
||||
# ── Area (auto, by changed paths — see workflows/triage.yml) ──────────────
|
||||
# Mirrors the "Surface area" section of the pull request template.
|
||||
- name: "area:frontend"
|
||||
color: c5def5
|
||||
@ -60,7 +60,7 @@ labels:
|
||||
color: c5def5
|
||||
description: Dependency manifests / lockfiles
|
||||
|
||||
# ── Size (auto, by additions + deletions — see pr-triage.yml) ─────────────
|
||||
# ── Size (auto, by additions + deletions — see workflows/triage.yml) ──────
|
||||
- name: "size/XS"
|
||||
color: "009900"
|
||||
description: PR changes < 20 lines
|
||||
@ -77,7 +77,7 @@ labels:
|
||||
color: ee5500
|
||||
description: PR changes 700+ lines
|
||||
|
||||
# ── Risk (auto, by changed paths — see pr-triage.yml) ─────────────────────
|
||||
# ── Risk (auto, by changed paths — see workflows/triage.yml) ──────────────
|
||||
- name: "risk:low"
|
||||
color: 0e8a16
|
||||
description: "Low risk: docs / i18n / assets only"
|
||||
|
||||
13
.github/workflows/lint-check.yml
vendored
13
.github/workflows/lint-check.yml
vendored
@ -38,6 +38,19 @@ jobs:
|
||||
--after "${{ github.sha }}"
|
||||
--github-annotations
|
||||
|
||||
label-declarations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Check every label applied by triage.yml is declared in labels.yml
|
||||
run: python scripts/check_label_declarations.py
|
||||
|
||||
lint-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
195
backend/tests/test_label_declarations.py
Normal file
195
backend/tests/test_label_declarations.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""Offline checks of the triage workflow's literal label declarations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SPEC = importlib.util.spec_from_file_location("label_declarations", REPO_ROOT / "scripts/check_label_declarations.py")
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
checker = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(checker)
|
||||
|
||||
|
||||
APPLIED = {
|
||||
"area:frontend",
|
||||
"area:backend",
|
||||
"area:agents",
|
||||
"area:sandbox",
|
||||
"area:skills",
|
||||
"area:mcp",
|
||||
"area:ci",
|
||||
"area:docs",
|
||||
"area:deps",
|
||||
"size/XS",
|
||||
"size/S",
|
||||
"size/M",
|
||||
"size/L",
|
||||
"size/XL",
|
||||
"risk:low",
|
||||
"risk:medium",
|
||||
"risk:high",
|
||||
"needs-validation",
|
||||
"first-time-contributor",
|
||||
"reviewing",
|
||||
"needs-triage",
|
||||
}
|
||||
|
||||
|
||||
def workflow(script: str) -> str:
|
||||
return "jobs:\n triage:\n steps:\n - uses: actions/github-script@v8\n with:\n script: |\n" + "\n".join(" " + line for line in script.splitlines()) + "\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def files(tmp_path, monkeypatch):
|
||||
labels = tmp_path / "labels.yml"
|
||||
triage = tmp_path / "triage.yml"
|
||||
labels.write_text("labels:\n" + "".join(f' - name: "label-{i}"\n' for i in range(20)), encoding="utf-8")
|
||||
triage.write_text(workflow("github.rest.issues.addLabels({labels: ['label-0']});"), encoding="utf-8")
|
||||
monkeypatch.setattr(checker, "LABELS_FILE", labels)
|
||||
monkeypatch.setattr(checker, "TRIAGE_WORKFLOW", triage)
|
||||
return labels, triage
|
||||
|
||||
|
||||
def test_repository_labels_are_declared():
|
||||
assert checker.applied_labels(checker.TRIAGE_WORKFLOW) == APPLIED
|
||||
assert checker.main() == 0
|
||||
|
||||
|
||||
def test_missing_declaration_is_named(files, capsys):
|
||||
labels, triage = files
|
||||
labels.write_text("labels:\n" + "".join(f' - name: "label-{i}"\n' for i in range(1, 21)), encoding="utf-8")
|
||||
assert checker.main() == 1
|
||||
assert " - label-0\n" in capsys.readouterr().err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, 19])
|
||||
def test_declaration_parse_floor(files, count):
|
||||
labels, _ = files
|
||||
labels.write_text("labels:\n" + "".join(f' - name: "label-{i}"\n' for i in range(count)), encoding="utf-8")
|
||||
with pytest.raises(SystemExit, match=f"Only {count} labels parsed"):
|
||||
checker.main()
|
||||
|
||||
|
||||
def test_empty_extraction_fails(files):
|
||||
_, triage = files
|
||||
triage.write_text(workflow("core.info('no label operations');"), encoding="utf-8")
|
||||
with pytest.raises(SystemExit, match="No label literals"):
|
||||
checker.main()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", ["area:infra", "area:i18n", "awaiting-review", "priority:P1", "urgent", "needs approval", "待审核"])
|
||||
def test_all_literal_label_names_are_checked(files, capsys, label):
|
||||
_, triage = files
|
||||
triage.write_text(workflow(f"github.rest.issues.addLabels({{labels: ['label-0', '{label}']}});"), encoding="utf-8")
|
||||
assert checker.main() == 1
|
||||
assert f" - {label}\n" in capsys.readouterr().err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("comment", ["// someday maybe add 'needs-changes' here", "/* someday maybe add 'needs-changes' here */", "// addLabels({labels: ['needs-changes']});"])
|
||||
def test_javascript_comments_are_not_labels(files, comment):
|
||||
_, triage = files
|
||||
triage.write_text(workflow(f"github.rest.issues.addLabels({{labels: ['label-0']}}); {comment}"), encoding="utf-8")
|
||||
assert checker.main() == 0
|
||||
|
||||
|
||||
def test_yaml_comments_are_not_labels(files):
|
||||
_, triage = files
|
||||
triage.write_text("# someday maybe add 'needs-changes' here\n" + workflow("addLabels({labels: ['label-0']});"), encoding="utf-8")
|
||||
assert checker.main() == 0
|
||||
|
||||
|
||||
def test_unrelated_strings_and_logs_are_not_labels(files):
|
||||
_, triage = files
|
||||
triage.write_text(workflow("const unrelated = 'needs-changes';\ncore.info('needs-changes');\ncore.info(`mentions 'needs-changes'`);\naddLabels({labels: ['label-0']});"), encoding="utf-8")
|
||||
assert checker.main() == 0
|
||||
|
||||
|
||||
def test_tracks_arrays_aliases_ternaries_and_mutations(files, capsys):
|
||||
_, triage = files
|
||||
triage.write_text(
|
||||
workflow("""
|
||||
const paths = ['docs/'];
|
||||
const RULES = [['area:i18n', [/^frontend\\//]], ['area:infra', [/^docker\\//]]];
|
||||
const areaLabels = RULES.filter(([, rules]) => rules.length).map(([label]) => label);
|
||||
const sizeLabel = paths.includes('not-a-label') ? 'size/XS' : 'size/M';
|
||||
const desired = new Set([...areaLabels, sizeLabel]);
|
||||
if (paths.length) desired.add('awaiting-review');
|
||||
const toAdd = [...desired].filter(n => n !== 'not-a-label');
|
||||
toAdd.push('needs approval');
|
||||
const alias = toAdd;
|
||||
github.rest.issues.addLabels({labels: alias});
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert checker.applied_labels(triage) == {"area:i18n", "area:infra", "size/XS", "size/M", "awaiting-review", "needs approval"}
|
||||
assert checker.main() == 1
|
||||
assert "not-a-label" not in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_jobs_do_not_share_variable_bindings(files):
|
||||
_, triage = files
|
||||
triage.write_text(workflow("const labels = ['label-0'];\naddLabels({labels});") + workflow("const labels = ['label-1'];\naddLabels({labels});").replace("jobs:\n", "", 1).replace(" triage:\n", " second:\n", 1), encoding="utf-8")
|
||||
assert checker.applied_labels(triage) == {"label-0", "label-1"}
|
||||
|
||||
|
||||
def test_escaped_quotes_and_comment_markers_in_labels(files):
|
||||
_, triage = files
|
||||
triage.write_text(workflow(r"""addLabels({labels: ["owner's // note", 'needs \'review\'', 'area:\u0069' + '18n']});"""), encoding="utf-8")
|
||||
with pytest.raises((ValueError, SystemExit), match="computed|extract"):
|
||||
checker.applied_labels(triage)
|
||||
|
||||
|
||||
def test_escaped_label_literals(files):
|
||||
_, triage = files
|
||||
triage.write_text(workflow(r"""addLabels({"labels": ["owner's // note", 'needs \'review\'', 'area:\u006918n']});"""), encoding="utf-8")
|
||||
assert checker.applied_labels(triage) == {"owner's // note", "needs 'review'", "area:i18n"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script", ["addLabels({labels: computed()});", "addLabels({labels: [`label-${number}`]});"])
|
||||
def test_unresolved_or_computed_values_fail_closed(files, script):
|
||||
_, triage = files
|
||||
triage.write_text(workflow(script), encoding="utf-8")
|
||||
with pytest.raises(SystemExit, match="extract|computed"):
|
||||
checker.main()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", [0, 1])
|
||||
def test_missing_inputs_fail(files, missing):
|
||||
files[missing].unlink()
|
||||
with pytest.raises(SystemExit, match="Missing"):
|
||||
checker.main()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"script",
|
||||
[
|
||||
"const labels = ['label-0', ...unknownLabels]; addLabels({labels});",
|
||||
"const labels = ['label-0', otherLabel]; addLabels({labels});",
|
||||
"const labels = ['label-0']; const alias = labels.map(n => transform(n)); addLabels({labels: alias});",
|
||||
],
|
||||
)
|
||||
def test_partial_extraction_cannot_hide_unresolved_labels(files, script):
|
||||
_, triage = files
|
||||
triage.write_text(workflow(script), encoding="utf-8")
|
||||
with pytest.raises(SystemExit, match="extract|computed"):
|
||||
checker.main()
|
||||
|
||||
|
||||
def test_nested_conditional_labels(files):
|
||||
_, triage = files
|
||||
triage.write_text(workflow("const label = first ? second ? 'label-0' : 'label-1' : 'label-2'; addLabels({labels: [label]});"), encoding="utf-8")
|
||||
assert checker.applied_labels(triage) == {"label-0", "label-1", "label-2"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", sorted(APPLIED))
|
||||
def test_every_real_applied_label_is_enforced(files, capsys, label):
|
||||
labels, triage = files
|
||||
manifest = (REPO_ROOT / ".github/labels.yml").read_text(encoding="utf-8")
|
||||
labels.write_text("\n".join(line for line in manifest.splitlines() if line.strip() not in {f'- name: "{label}"', f"- name: {label}"}), encoding="utf-8")
|
||||
triage.write_text((REPO_ROOT / ".github/workflows/triage.yml").read_text(encoding="utf-8"), encoding="utf-8")
|
||||
assert checker.main() == 1
|
||||
assert f" - {label}\n" in capsys.readouterr().err
|
||||
@ -23,21 +23,21 @@ For those launches, export the effective `DEER_FLOW_HOME` when collecting a bund
|
||||
and ensure the checkout `.env` does not override it. Tests clear all three runtime
|
||||
path variables and compare storage defaults with the launcher's actual shell blocks.
|
||||
|
||||
## Dependency Check Diagnostics
|
||||
## Dependency and Label Checks
|
||||
|
||||
`check_extension.py` checks wheels with Gateway's asset validator.
|
||||
CI builds and checks all bundled examples in `extension-package-check.yml`.
|
||||
Tests: `backend/tests/test_extension_check_script.py`.
|
||||
`check_extension.py` uses Gateway's wheel asset validator; CI checks examples
|
||||
in `extension-package-check.yml`. Tests: `backend/tests/test_extension_check_script.py`.
|
||||
|
||||
`check.py` decodes captured output as UTF-8 with replacement and runs pnpm with
|
||||
`PYTHONIOENCODING=utf-8:backslashreplace`. Preserve Unicode diagnostics and exit
|
||||
status in `make check`, independently of the host locale.
|
||||
Tests: `backend/tests/test_check_script.py`.
|
||||
`check.py`: decode UTF-8 with replacement; pnpm uses
|
||||
`PYTHONIOENCODING=utf-8:backslashreplace`. Keep Unicode/exit status in
|
||||
`make check` in any locale. Tests: `backend/tests/test_check_script.py`.
|
||||
|
||||
`doctor.py` and `setup_wizard.py` configure stdout with `errors="replace"` at
|
||||
the start of `main`, preserving its encoding so legacy terminals and pipes can
|
||||
finish reports and setup. Guard `reconfigure` for embedded/captured streams.
|
||||
Offline subprocess coverage: `backend/tests/test_setup_script_encoding.py`.
|
||||
`doctor.py`/`setup_wizard.py`: at `main` start, keep stdout's encoding
|
||||
with `errors="replace"` for legacy terminals/pipes. Guard `reconfigure` on
|
||||
embedded/captured streams. Offline tests: `backend/tests/test_setup_script_encoding.py`.
|
||||
|
||||
`check_label_declarations.py`: stdlib-only guard; reject unsupported
|
||||
expressions. Tests: `backend/tests/test_label_declarations.py`.
|
||||
|
||||
## Manual Claude OAuth Export
|
||||
|
||||
|
||||
273
scripts/check_label_declarations.py
Normal file
273
scripts/check_label_declarations.py
Normal file
@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that every label the triage workflow applies is declared in labels.yml.
|
||||
|
||||
``.github/labels.yml`` is the declarative source of truth for repository labels,
|
||||
and ``.github/workflows/triage.yml`` applies labels by name at runtime. GitHub
|
||||
rejects an ``addLabels`` call with **422** when *any* requested label does not
|
||||
exist, and drops the whole batch -- so a label added to the workflow but
|
||||
forgotten here makes labelling stop working silently instead of failing loudly.
|
||||
|
||||
This is the static guard for that pair: it extracts the label literals the
|
||||
triage workflow can apply and asserts each one is declared in labels.yml.
|
||||
|
||||
The reader follows quoted literals through arrays, conditional values, variable
|
||||
aliases, Sets and add/push mutations in each workflow script. Filter predicates,
|
||||
log messages, YAML comments and JavaScript comments are not label sources. The
|
||||
current AREA_RULES first-element map is supported; other map transformations,
|
||||
computed calls/strings and unresolved values fail closed. This is not a general
|
||||
JavaScript evaluator. The read-only ``skip-validation`` control is not applied.
|
||||
|
||||
Standard library only -- the check has to stay cheap enough to run on every PR.
|
||||
Regression coverage: backend/tests/test_label_declarations.py.
|
||||
|
||||
Usage:
|
||||
python scripts/check_label_declarations.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
LABELS_FILE = REPO_ROOT / ".github" / "labels.yml"
|
||||
TRIAGE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "triage.yml"
|
||||
|
||||
# Only workflow JavaScript is inspected; YAML and JavaScript comments are not
|
||||
# label sources. Regex literals must be tokens too, so their escaped slashes
|
||||
# cannot be mistaken for the start of a comment.
|
||||
SCRIPT = re.compile(r"^(?P<indent> *)script:\s*[|>][-+]?\s*(?:#.*)?$")
|
||||
JS_TOKEN = re.compile(
|
||||
r"(?P<comment>//[^\n]*|/\*.*?\*/)"
|
||||
r"|(?P<string>'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\")"
|
||||
r"|(?P<template>`(?:\\.|[^`\\])*`)"
|
||||
r"|(?P<regex>/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n])+/[a-z]*)"
|
||||
r"|(?P<word>[$A-Za-z_][$\w]*)"
|
||||
r"|(?P<symbol>\.\.\.|===|!==|=>|==|!=|\S)",
|
||||
re.DOTALL,
|
||||
)
|
||||
DECLARED_NAME = re.compile(r"^\s*-\s+name:\s*(?P<value>.+?)\s*$")
|
||||
PAIRS = {"(": ")", "[": "]", "{": "}"}
|
||||
|
||||
|
||||
class Token(NamedTuple):
|
||||
kind: str
|
||||
value: str
|
||||
|
||||
|
||||
def workflow_scripts(text: str) -> list[str]:
|
||||
scripts: list[str] = []
|
||||
lines = text.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
match = SCRIPT.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
indent = len(match.group("indent"))
|
||||
body: list[str] = []
|
||||
for child in lines[index + 1 :]:
|
||||
if child.strip() and len(child) - len(child.lstrip(" ")) <= indent:
|
||||
break
|
||||
body.append(child)
|
||||
scripts.append("\n".join(body))
|
||||
return scripts
|
||||
|
||||
|
||||
def group_end(tokens: list[Token], start: int) -> int:
|
||||
stack: list[str] = []
|
||||
for index in range(start, len(tokens)):
|
||||
token = tokens[index]
|
||||
if token.kind != "symbol":
|
||||
continue
|
||||
if token.value in PAIRS:
|
||||
stack.append(PAIRS[token.value])
|
||||
elif token.value in PAIRS.values():
|
||||
if not stack or token.value != stack.pop():
|
||||
break
|
||||
if not stack:
|
||||
return index
|
||||
raise ValueError("Cannot extract labels from an unbalanced JavaScript expression")
|
||||
|
||||
|
||||
def expression_end(tokens: list[Token], start: int, separators: set[str]) -> int:
|
||||
index = start
|
||||
while index < len(tokens):
|
||||
token = tokens[index]
|
||||
if token.kind == "symbol":
|
||||
if token.value in separators:
|
||||
break
|
||||
if token.value in PAIRS:
|
||||
index = group_end(tokens, index)
|
||||
index += 1
|
||||
return index
|
||||
|
||||
|
||||
def literal_values(tokens: list[Token], bindings: dict[str, list[list[Token]]], seen: frozenset[str] = frozenset()) -> set[str]:
|
||||
"""Follow literal-producing bindings, arrays, Sets and add/push mutations.
|
||||
|
||||
This is a static reader for the workflow's data expressions, not a JavaScript
|
||||
evaluator. Conditions and filter predicates do not produce label values.
|
||||
Computed calls, concatenation and templates fail rather than claiming they
|
||||
have been checked.
|
||||
"""
|
||||
if not tokens:
|
||||
return set()
|
||||
if tokens[0].value == "(" and group_end(tokens, 0) == len(tokens) - 1:
|
||||
return literal_values(tokens[1:-1], bindings, seen)
|
||||
comma = expression_end(tokens, 0, {","})
|
||||
if comma < len(tokens):
|
||||
return literal_values(tokens[:comma], bindings, seen) | literal_values(tokens[comma + 1 :], bindings, seen)
|
||||
question = expression_end(tokens, 0, {"?"})
|
||||
if question < len(tokens):
|
||||
colon = question + 1
|
||||
nested = 0
|
||||
while colon < len(tokens):
|
||||
token = tokens[colon]
|
||||
if token.kind == "symbol" and token.value in PAIRS:
|
||||
colon = group_end(tokens, colon)
|
||||
elif token.value == "?":
|
||||
nested += 1
|
||||
elif token.value == ":":
|
||||
if nested == 0:
|
||||
break
|
||||
nested -= 1
|
||||
colon += 1
|
||||
if colon == len(tokens):
|
||||
raise ValueError("Cannot extract labels from an incomplete conditional")
|
||||
return literal_values(tokens[question + 1 : colon], bindings, seen) | literal_values(tokens[colon + 1 :], bindings, seen)
|
||||
|
||||
values: set[str] = set()
|
||||
index = 0
|
||||
while index < len(tokens):
|
||||
token = tokens[index]
|
||||
if token.kind == "string":
|
||||
values.add(ast.literal_eval(token.value.replace(r"\/", "/")))
|
||||
elif token.kind == "template" or token.value == "+":
|
||||
raise ValueError("Cannot extract computed label strings; use quoted label literals")
|
||||
elif token.value == "." and index + 2 < len(tokens) and tokens[index + 1].value == "filter" and tokens[index + 2].value == "(":
|
||||
index = group_end(tokens, index + 2)
|
||||
elif token.value == "." and index + 2 < len(tokens) and tokens[index + 1].value == "map" and tokens[index + 2].value == "(":
|
||||
end = group_end(tokens, index + 2)
|
||||
callback = tokens[index + 3 : end]
|
||||
# AREA_RULES.map(([label]) => label) preserves the first literal
|
||||
# in each row. Arbitrary transformations need a real JS evaluator.
|
||||
shape = [item.value for item in callback]
|
||||
if len(shape) != 7 or shape[:2] != ["(", "["] or shape[3:6] != ["]", ")", "=>"] or shape[2] != shape[6]:
|
||||
raise ValueError("Cannot extract labels from a computed map transformation")
|
||||
index = end
|
||||
elif token.value in PAIRS and token.kind == "symbol":
|
||||
end = group_end(tokens, index)
|
||||
values |= literal_values(tokens[index + 1 : end], bindings, seen)
|
||||
index = end
|
||||
elif token.kind == "word" and (index == 0 or tokens[index - 1].value != "."):
|
||||
if index + 1 < len(tokens) and tokens[index + 1].value == "(" and token.value != "Set":
|
||||
raise ValueError(f"Cannot extract computed label call {token.value}()")
|
||||
if token.value in {"new", "Set"}:
|
||||
pass
|
||||
elif token.value not in bindings or token.value in seen:
|
||||
raise ValueError(f"Cannot extract unresolved label value {token.value}")
|
||||
else:
|
||||
for expression in bindings[token.value]:
|
||||
values |= literal_values(expression, bindings, seen | {token.value})
|
||||
index += 1
|
||||
return values
|
||||
|
||||
|
||||
def script_labels(script: str) -> set[str]:
|
||||
tokens = [Token(match.lastgroup, match.group()) for match in JS_TOKEN.finditer(script) if match.lastgroup != "comment"]
|
||||
bindings: dict[str, list[list[Token]]] = {}
|
||||
for index, token in enumerate(tokens):
|
||||
if token.kind != "word":
|
||||
continue
|
||||
if index + 1 < len(tokens) and tokens[index + 1].value == "=" and (index == 0 or tokens[index - 1].value != "."):
|
||||
end = expression_end(tokens, index + 2, {";"})
|
||||
bindings.setdefault(token.value, []).append(tokens[index + 2 : end])
|
||||
if index + 3 < len(tokens) and tokens[index + 1].value == "." and tokens[index + 2].value in {"push", "add", "unshift"} and tokens[index + 3].value == "(":
|
||||
end = group_end(tokens, index + 3)
|
||||
bindings.setdefault(token.value, []).append(tokens[index + 4 : end])
|
||||
|
||||
labels: set[str] = set()
|
||||
for index, token in enumerate(tokens[:-1]):
|
||||
if token.value != "addLabels" or tokens[index + 1].value != "(":
|
||||
continue
|
||||
end = group_end(tokens, index + 1)
|
||||
found = False
|
||||
for argument in range(index + 2, end):
|
||||
key = tokens[argument]
|
||||
if key.value != "labels" and not (key.kind == "string" and ast.literal_eval(key.value) == "labels"):
|
||||
continue
|
||||
if tokens[argument + 1].value == ":":
|
||||
stop = expression_end(tokens, argument + 2, {",", "}"})
|
||||
expression = tokens[argument + 2 : stop]
|
||||
elif tokens[argument + 1].value in {",", "}"} and key.kind == "word":
|
||||
expression = [key]
|
||||
else:
|
||||
continue
|
||||
values = literal_values(expression, bindings)
|
||||
if not values:
|
||||
raise ValueError("Cannot extract label literals from addLabels; use a statically defined labels value")
|
||||
labels |= values
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
raise ValueError("Cannot extract labels: addLabels has no supported labels argument")
|
||||
return labels
|
||||
|
||||
|
||||
def declared_labels(path: Path) -> set[str]:
|
||||
names: set[str] = set()
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
match = DECLARED_NAME.match(line)
|
||||
if match:
|
||||
names.add(match.group("value").strip().strip("'\""))
|
||||
return names
|
||||
|
||||
|
||||
def applied_labels(path: Path) -> set[str]:
|
||||
labels: set[str] = set()
|
||||
for script in workflow_scripts(path.read_text(encoding="utf-8")):
|
||||
labels |= script_labels(script)
|
||||
return labels
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not LABELS_FILE.is_file():
|
||||
sys.exit(f"Missing {LABELS_FILE}")
|
||||
if not TRIAGE_WORKFLOW.is_file():
|
||||
sys.exit(f"Missing {TRIAGE_WORKFLOW}")
|
||||
|
||||
declared = declared_labels(LABELS_FILE)
|
||||
try:
|
||||
applied = applied_labels(TRIAGE_WORKFLOW)
|
||||
except (ValueError, SyntaxError) as error:
|
||||
sys.exit(f"Cannot extract labels from {TRIAGE_WORKFLOW}: {error}")
|
||||
|
||||
# Guard the guards: a change that makes either extraction match nothing would
|
||||
# otherwise turn this check into a no-op that always passes.
|
||||
if len(declared) < 20:
|
||||
sys.exit(f"Only {len(declared)} labels parsed from {LABELS_FILE} -- the extraction is broken.")
|
||||
if not applied:
|
||||
sys.exit(f"No label literals found in {TRIAGE_WORKFLOW} -- the extraction is broken.")
|
||||
|
||||
missing = sorted(applied - declared)
|
||||
if missing:
|
||||
print(
|
||||
f"{len(missing)} label(s) applied by {TRIAGE_WORKFLOW.name} are not declared in {LABELS_FILE.name}:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for name in missing:
|
||||
print(f" - {name}", file=sys.stderr)
|
||||
print(
|
||||
"\nGitHub drops the whole addLabels batch with 422 when a label is missing, so add these under the matching section of labels.yml.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"OK -- {len(applied)} labels applied by {TRIAGE_WORKFLOW.name} are all declared.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
x
Reference in New Issue
Block a user