From 31ca315e07c55cdd1f14c6d35b8e030046619c63 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 6 Aug 2026 10:42:03 -0500 Subject: [PATCH 1/4] fix(ci): make issue triage labeling deterministic Signed-off-by: phernandez --- .github/workflows/claude-issue-triage.yml | 31 +++-- scripts/edit-issue-labels.sh | 91 +++++++++---- tests/test_claude_issue_triage.py | 156 ++++++++++++++++++++++ 3 files changed, 241 insertions(+), 37 deletions(-) create mode 100644 tests/test_claude_issue_triage.py diff --git a/.github/workflows/claude-issue-triage.yml b/.github/workflows/claude-issue-triage.yml index 9b4087ab4..a20561802 100644 --- a/.github/workflows/claude-issue-triage.yml +++ b/.github/workflows/claude-issue-triage.yml @@ -45,7 +45,8 @@ jobs: **Scope Boundary:** - Do not implement a fix, edit repository files, run tests, create a branch, commit, or push. - - The only permitted write actions are adding issue labels and posting issue comments. + - The only permitted write actions are setting triage-owned issue labels and posting + issue comments. - Even when the issue includes a root cause, suggested fix, or acceptance criteria, stop after triage. **Issue Analysis:** @@ -77,22 +78,30 @@ jobs: - Complex: Major feature work, architectural changes **Actions to Take:** - 1. Add appropriate labels using: - `./scripts/edit-issue-labels.sh --add-label label1 --add-label label2` + 1. After finishing the analysis, call the triage helper exactly once: + `./scripts/edit-issue-labels.sh --type TYPE --component COMPONENT` 2. Check for duplicates using: `gh search issues` 3. If duplicate found, comment mentioning the original issue 4. For feature requests, ask clarifying questions if needed 5. For bugs, request reproduction steps if missing - **Available Labels:** - - Type: bug, enhancement, feature, documentation, question, mcp-tool - - Priority: critical, high, medium, low - - Component: cli, mcp, database, cloud, docs, testing - - Complexity: simple, medium, complex - - Status: needs-reproduction, needs-clarification, duplicate + **Triage Helper Contract:** + - TYPE must be exactly one of: `bug`, `enhancement`, `documentation`, `question`. + Map feature requests and improvements to `enhancement`. + - COMPONENT must be `cloud` only when the issue directly concerns Cloud behavior; + otherwise use `none`. Record other components in prose because they do not have + repository labels. + - Priority and complexity are prose assessments only. They do not have repository + labels. + - If an issue is a duplicate, link the original in the comment. Do not add a + `duplicate` label or close the issue. + - Do not probe labels, try candidate calls, or use the helper for discovery. It is a + mutating command. One final call replaces only triage-owned labels and preserves + every label outside that owned set. - Read the issue carefully, apply appropriate labels, post any necessary triage comment, - and then stop. Do not begin implementation work. + Read the issue carefully, apply the triage classification, and post a concise comment. + Do not include tool logs, label-taxonomy discussion, or a task checklist. Then stop; + do not begin implementation work. claude_args: | --permission-mode dontAsk --allowedTools "Bash(./scripts/edit-issue-labels.sh:*),Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh search issues:*),Read,Grep,Glob" diff --git a/scripts/edit-issue-labels.sh b/scripts/edit-issue-labels.sh index c21faccce..73fb53adb 100755 --- a/scripts/edit-issue-labels.sh +++ b/scripts/edit-issue-labels.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Limit automated issue triage to labels on the issue that triggered the workflow. +# Apply one semantic triage classification to the issue that triggered the workflow. set -euo pipefail issue_number=$(jq -r '.issue.number // empty' "${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH not set}") @@ -9,49 +9,88 @@ if ! [[ "$issue_number" =~ ^[0-9]+$ ]]; then exit 1 fi -labels=() +type_label="" +component="" while [[ $# -gt 0 ]]; do case "$1" in - --add-label) + --type) + if [[ $# -lt 2 ]]; then + echo "Error: --type requires a value" >&2 + exit 1 + fi + if [[ -n "$type_label" ]]; then + echo "Error: --type may be provided only once" >&2 + exit 1 + fi + type_label="$2" + shift 2 + ;; + --component) if [[ $# -lt 2 ]]; then - echo "Error: --add-label requires a label" >&2 + echo "Error: --component requires a value" >&2 + exit 1 + fi + if [[ -n "$component" ]]; then + echo "Error: --component may be provided only once" >&2 exit 1 fi - labels+=("$2") + component="$2" shift 2 ;; *) - echo "Error: only --add-label is accepted" >&2 + echo "Error: only --type and --component are accepted" >&2 exit 1 ;; esac done -if [[ ${#labels[@]} -eq 0 ]]; then - echo "Error: at least one label is required" >&2 - exit 1 -fi +case "$type_label" in + bug|enhancement|documentation|question) ;; + "") + echo "Error: --type is required" >&2 + exit 1 + ;; + *) + echo "Error: unsupported triage type: $type_label" >&2 + exit 1 + ;; +esac -valid_labels=$(gh label list --limit 500 --json name --jq '.[].name') -filtered_labels=() -for label in "${labels[@]}"; do - if grep -qxF "$label" <<<"$valid_labels"; then - filtered_labels+=("$label") - else - echo "Ignoring unknown label: $label" >&2 - fi -done - -if [[ ${#filtered_labels[@]} -eq 0 ]]; then - exit 0 -fi +case "$component" in + cloud|none) ;; + "") + echo "Error: --component is required" >&2 + exit 1 + ;; + *) + echo "Error: unsupported triage component: $component" >&2 + exit 1 + ;; +esac repository=${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set} labels_url="repos/$repository/issues/$issue_number/labels" -api_args=(--method POST "$labels_url") -for label in "${filtered_labels[@]}"; do + +# The triage bot owns only the four type labels and the cloud component label. Preserve every +# label outside that owned set while replacing prior triage output. +labels=() +while IFS= read -r label; do + case "$label" in + bug|enhancement|documentation|question|cloud) ;; + "") ;; + *) labels+=("$label") ;; + esac +done < <(gh api "repos/$repository/issues/$issue_number" --jq '.labels[].name') + +labels+=("$type_label") +if [[ "$component" == "cloud" ]]; then + labels+=("cloud") +fi + +api_args=(--method PUT "$labels_url") +for label in "${labels[@]}"; do api_args+=(-f "labels[]=$label") done gh api "${api_args[@]}" --silent -echo "Added: ${filtered_labels[*]}" +echo "Set triage labels: type=$type_label component=$component" diff --git a/tests/test_claude_issue_triage.py b/tests/test_claude_issue_triage.py new file mode 100644 index 000000000..45d7925fd --- /dev/null +++ b/tests/test_claude_issue_triage.py @@ -0,0 +1,156 @@ +import json +import os +import subprocess +from pathlib import Path + +import pytest +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TRIAGE_SCRIPT = REPO_ROOT / "scripts" / "edit-issue-labels.sh" +TRIAGE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "claude-issue-triage.yml" + + +def _run_triage_helper( + tmp_path: Path, + *arguments: str, + current_labels: tuple[str, ...] = (), +) -> tuple[subprocess.CompletedProcess[str], list[str]]: + event_path = tmp_path / "event.json" + event_path.write_text(json.dumps({"issue": {"number": 1205}}), encoding="utf-8") + + gh_arguments_path = tmp_path / "gh-arguments.txt" + bin_path = tmp_path / "bin" + bin_path.mkdir() + gh_path = bin_path / "gh" + gh_path.write_text( + """#!/usr/bin/env bash +set -euo pipefail + +if [[ " $* " == *" --method PUT "* ]]; then + printf '%s\n' "$@" > "${GH_ARGUMENTS_PATH:?}" +else + printf '%s\n' "${GH_CURRENT_LABELS:-}" +fi +""", + encoding="utf-8", + ) + gh_path.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "GH_ARGUMENTS_PATH": str(gh_arguments_path), + "GH_CURRENT_LABELS": "\n".join(current_labels), + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": "basicmachines-co/basic-memory", + "PATH": f"{bin_path}{os.pathsep}{env['PATH']}", + } + ) + result = subprocess.run( + [str(TRIAGE_SCRIPT), *arguments], + cwd=REPO_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + gh_arguments = ( + gh_arguments_path.read_text(encoding="utf-8").splitlines() + if gh_arguments_path.exists() + else [] + ) + return result, gh_arguments + + +def test_triage_helper_replaces_owned_labels_and_preserves_other_labels(tmp_path: Path) -> None: + result, gh_arguments = _run_triage_helper( + tmp_path, + "--type", + "enhancement", + "--component", + "none", + current_labels=("bug", "cloud", "production", "arch-review"), + ) + + assert result.returncode == 0, result.stderr + assert gh_arguments == [ + "api", + "--method", + "PUT", + "repos/basicmachines-co/basic-memory/issues/1205/labels", + "-f", + "labels[]=production", + "-f", + "labels[]=arch-review", + "-f", + "labels[]=enhancement", + "--silent", + ] + + +def test_triage_helper_keeps_only_one_type_and_cloud_component(tmp_path: Path) -> None: + result, gh_arguments = _run_triage_helper( + tmp_path, + "--type", + "question", + "--component", + "cloud", + current_labels=( + "bug", + "enhancement", + "documentation", + "question", + "cloud", + "production", + ), + ) + + assert result.returncode == 0, result.stderr + assert "labels[]=bug" not in gh_arguments + assert "labels[]=enhancement" not in gh_arguments + assert "labels[]=documentation" not in gh_arguments + assert gh_arguments.count("labels[]=question") == 1 + assert gh_arguments.count("labels[]=cloud") == 1 + assert "labels[]=production" in gh_arguments + + +@pytest.mark.parametrize( + "arguments, expected_error", + [ + (("--add-label", "bug"), "only --type and --component are accepted"), + (("--component", "none"), "--type is required"), + (("--type", "bug"), "--component is required"), + ( + ("--type", "bug", "--type", "enhancement", "--component", "none"), + "--type may be provided only once", + ), + (("--type", "feature", "--component", "none"), "unsupported triage type"), + (("--type", "bug", "--component", "database"), "unsupported triage component"), + ], +) +def test_triage_helper_rejects_probe_and_unsupported_arguments( + tmp_path: Path, + arguments: tuple[str, ...], + expected_error: str, +) -> None: + result, gh_arguments = _run_triage_helper(tmp_path, *arguments) + + assert result.returncode != 0 + assert expected_error in result.stderr + assert gh_arguments == [] + + +def test_triage_workflow_defines_one_semantic_mutation() -> None: + workflow_text = TRIAGE_WORKFLOW.read_text(encoding="utf-8") + workflow = yaml.safe_load(workflow_text) + action_step = workflow["jobs"]["triage"]["steps"][1] + prompt = action_step["with"]["prompt"] + + assert action_step["uses"] == "anthropics/claude-code-action@v1" + assert "call the triage helper exactly once" in prompt + assert "--type TYPE --component COMPONENT" in prompt + assert "Do not probe labels" in prompt + assert "Priority and complexity are prose assessments only" in prompt + assert "--add-label" not in workflow_text From 76f0f02db2e64cf97d5edf2d0c1e450c4ac642bd Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 6 Aug 2026 10:54:13 -0500 Subject: [PATCH 2/4] fix(ci): abort triage when label read fails Signed-off-by: phernandez --- scripts/edit-issue-labels.sh | 7 ++++++- tests/test_claude_issue_triage.py | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/edit-issue-labels.sh b/scripts/edit-issue-labels.sh index 73fb53adb..446af26bb 100755 --- a/scripts/edit-issue-labels.sh +++ b/scripts/edit-issue-labels.sh @@ -73,6 +73,11 @@ labels_url="repos/$repository/issues/$issue_number/labels" # The triage bot owns only the four type labels and the cloud component label. Preserve every # label outside that owned set while replacing prior triage output. +if ! current_labels=$(gh api "repos/$repository/issues/$issue_number" --jq '.labels[].name'); then + echo "Error: unable to read current issue labels" >&2 + exit 1 +fi + labels=() while IFS= read -r label; do case "$label" in @@ -80,7 +85,7 @@ while IFS= read -r label; do "") ;; *) labels+=("$label") ;; esac -done < <(gh api "repos/$repository/issues/$issue_number" --jq '.labels[].name') +done <<< "$current_labels" labels+=("$type_label") if [[ "$component" == "cloud" ]]; then diff --git a/tests/test_claude_issue_triage.py b/tests/test_claude_issue_triage.py index 45d7925fd..d89672a7b 100644 --- a/tests/test_claude_issue_triage.py +++ b/tests/test_claude_issue_triage.py @@ -16,6 +16,7 @@ def _run_triage_helper( tmp_path: Path, *arguments: str, current_labels: tuple[str, ...] = (), + fail_label_read: bool = False, ) -> tuple[subprocess.CompletedProcess[str], list[str]]: event_path = tmp_path / "event.json" event_path.write_text(json.dumps({"issue": {"number": 1205}}), encoding="utf-8") @@ -30,6 +31,8 @@ def _run_triage_helper( if [[ " $* " == *" --method PUT "* ]]; then printf '%s\n' "$@" > "${GH_ARGUMENTS_PATH:?}" +elif [[ "${GH_FAIL_LABEL_READ:-false}" == "true" ]]; then + exit 1 else printf '%s\n' "${GH_CURRENT_LABELS:-}" fi @@ -43,6 +46,7 @@ def _run_triage_helper( { "GH_ARGUMENTS_PATH": str(gh_arguments_path), "GH_CURRENT_LABELS": "\n".join(current_labels), + "GH_FAIL_LABEL_READ": "true" if fail_label_read else "false", "GITHUB_EVENT_PATH": str(event_path), "GITHUB_REPOSITORY": "basicmachines-co/basic-memory", "PATH": f"{bin_path}{os.pathsep}{env['PATH']}", @@ -116,6 +120,24 @@ def test_triage_helper_keeps_only_one_type_and_cloud_component(tmp_path: Path) - assert "labels[]=production" in gh_arguments +def test_triage_helper_does_not_update_labels_when_current_labels_cannot_be_read( + tmp_path: Path, +) -> None: + result, gh_arguments = _run_triage_helper( + tmp_path, + "--type", + "bug", + "--component", + "none", + current_labels=("production",), + fail_label_read=True, + ) + + assert result.returncode != 0 + assert "unable to read current issue labels" in result.stderr + assert gh_arguments == [] + + @pytest.mark.parametrize( "arguments, expected_error", [ From ee06e942f7990b1016f275fd1679c6e3c02a0c32 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 6 Aug 2026 11:00:41 -0500 Subject: [PATCH 3/4] fix(ci): preserve concurrent issue labels Signed-off-by: phernandez --- scripts/edit-issue-labels.sh | 29 +++++--- tests/test_claude_issue_triage.py | 109 +++++++++++++++++++++--------- 2 files changed, 97 insertions(+), 41 deletions(-) diff --git a/scripts/edit-issue-labels.sh b/scripts/edit-issue-labels.sh index 446af26bb..1be269f17 100755 --- a/scripts/edit-issue-labels.sh +++ b/scripts/edit-issue-labels.sh @@ -71,29 +71,38 @@ esac repository=${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set} labels_url="repos/$repository/issues/$issue_number/labels" -# The triage bot owns only the four type labels and the cloud component label. Preserve every -# label outside that owned set while replacing prior triage output. +# The triage bot owns only the four type labels and the cloud component label. Read current +# labels only to find obsolete bot-owned values; targeted mutations leave every other label +# untouched, including labels added concurrently after this read. if ! current_labels=$(gh api "repos/$repository/issues/$issue_number" --jq '.labels[].name'); then echo "Error: unable to read current issue labels" >&2 exit 1 fi -labels=() +obsolete_labels=() while IFS= read -r label; do case "$label" in - bug|enhancement|documentation|question|cloud) ;; - "") ;; - *) labels+=("$label") ;; + "$type_label") ;; + cloud) + if [[ "$component" != "cloud" ]]; then + obsolete_labels+=("$label") + fi + ;; + bug|enhancement|documentation|question) obsolete_labels+=("$label") ;; esac done <<< "$current_labels" -labels+=("$type_label") +for label in "${obsolete_labels[@]}"; do + gh api --method DELETE "$labels_url/$label" --silent +done + +desired_labels=("$type_label") if [[ "$component" == "cloud" ]]; then - labels+=("cloud") + desired_labels+=("cloud") fi -api_args=(--method PUT "$labels_url") -for label in "${labels[@]}"; do +api_args=(--method POST "$labels_url") +for label in "${desired_labels[@]}"; do api_args+=(-f "labels[]=$label") done diff --git a/tests/test_claude_issue_triage.py b/tests/test_claude_issue_triage.py index d89672a7b..663b55840 100644 --- a/tests/test_claude_issue_triage.py +++ b/tests/test_claude_issue_triage.py @@ -17,7 +17,7 @@ def _run_triage_helper( *arguments: str, current_labels: tuple[str, ...] = (), fail_label_read: bool = False, -) -> tuple[subprocess.CompletedProcess[str], list[str]]: +) -> tuple[subprocess.CompletedProcess[str], list[list[str]]]: event_path = tmp_path / "event.json" event_path.write_text(json.dumps({"issue": {"number": 1205}}), encoding="utf-8") @@ -29,8 +29,8 @@ def _run_triage_helper( """#!/usr/bin/env bash set -euo pipefail -if [[ " $* " == *" --method PUT "* ]]; then - printf '%s\n' "$@" > "${GH_ARGUMENTS_PATH:?}" +if [[ " $* " == *" --method DELETE "* || " $* " == *" --method POST "* ]]; then + printf '%s\n' "__CALL__" "$@" >> "${GH_ARGUMENTS_PATH:?}" elif [[ "${GH_FAIL_LABEL_READ:-false}" == "true" ]]; then exit 1 else @@ -60,16 +60,23 @@ def _run_triage_helper( capture_output=True, text=True, ) - gh_arguments = ( + gh_calls: list[list[str]] = [] + for line in ( gh_arguments_path.read_text(encoding="utf-8").splitlines() if gh_arguments_path.exists() else [] - ) - return result, gh_arguments + ): + if line == "__CALL__": + gh_calls.append([]) + else: + gh_calls[-1].append(line) + return result, gh_calls -def test_triage_helper_replaces_owned_labels_and_preserves_other_labels(tmp_path: Path) -> None: - result, gh_arguments = _run_triage_helper( +def test_triage_helper_updates_only_owned_labels_without_replacing_other_labels( + tmp_path: Path, +) -> None: + result, gh_calls = _run_triage_helper( tmp_path, "--type", "enhancement", @@ -79,23 +86,35 @@ def test_triage_helper_replaces_owned_labels_and_preserves_other_labels(tmp_path ) assert result.returncode == 0, result.stderr - assert gh_arguments == [ - "api", - "--method", - "PUT", - "repos/basicmachines-co/basic-memory/issues/1205/labels", - "-f", - "labels[]=production", - "-f", - "labels[]=arch-review", - "-f", - "labels[]=enhancement", - "--silent", + assert gh_calls == [ + [ + "api", + "--method", + "DELETE", + "repos/basicmachines-co/basic-memory/issues/1205/labels/bug", + "--silent", + ], + [ + "api", + "--method", + "DELETE", + "repos/basicmachines-co/basic-memory/issues/1205/labels/cloud", + "--silent", + ], + [ + "api", + "--method", + "POST", + "repos/basicmachines-co/basic-memory/issues/1205/labels", + "-f", + "labels[]=enhancement", + "--silent", + ], ] def test_triage_helper_keeps_only_one_type_and_cloud_component(tmp_path: Path) -> None: - result, gh_arguments = _run_triage_helper( + result, gh_calls = _run_triage_helper( tmp_path, "--type", "question", @@ -112,18 +131,46 @@ def test_triage_helper_keeps_only_one_type_and_cloud_component(tmp_path: Path) - ) assert result.returncode == 0, result.stderr - assert "labels[]=bug" not in gh_arguments - assert "labels[]=enhancement" not in gh_arguments - assert "labels[]=documentation" not in gh_arguments - assert gh_arguments.count("labels[]=question") == 1 - assert gh_arguments.count("labels[]=cloud") == 1 - assert "labels[]=production" in gh_arguments + assert gh_calls == [ + [ + "api", + "--method", + "DELETE", + "repos/basicmachines-co/basic-memory/issues/1205/labels/bug", + "--silent", + ], + [ + "api", + "--method", + "DELETE", + "repos/basicmachines-co/basic-memory/issues/1205/labels/enhancement", + "--silent", + ], + [ + "api", + "--method", + "DELETE", + "repos/basicmachines-co/basic-memory/issues/1205/labels/documentation", + "--silent", + ], + [ + "api", + "--method", + "POST", + "repos/basicmachines-co/basic-memory/issues/1205/labels", + "-f", + "labels[]=question", + "-f", + "labels[]=cloud", + "--silent", + ], + ] def test_triage_helper_does_not_update_labels_when_current_labels_cannot_be_read( tmp_path: Path, ) -> None: - result, gh_arguments = _run_triage_helper( + result, gh_calls = _run_triage_helper( tmp_path, "--type", "bug", @@ -135,7 +182,7 @@ def test_triage_helper_does_not_update_labels_when_current_labels_cannot_be_read assert result.returncode != 0 assert "unable to read current issue labels" in result.stderr - assert gh_arguments == [] + assert gh_calls == [] @pytest.mark.parametrize( @@ -157,11 +204,11 @@ def test_triage_helper_rejects_probe_and_unsupported_arguments( arguments: tuple[str, ...], expected_error: str, ) -> None: - result, gh_arguments = _run_triage_helper(tmp_path, *arguments) + result, gh_calls = _run_triage_helper(tmp_path, *arguments) assert result.returncode != 0 assert expected_error in result.stderr - assert gh_arguments == [] + assert gh_calls == [] def test_triage_workflow_defines_one_semantic_mutation() -> None: From 1c15d00736c321a9837bf88655648c89fa87b09f Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 6 Aug 2026 11:06:12 -0500 Subject: [PATCH 4/4] fix(ci): make triage updates failure safe Signed-off-by: phernandez --- .github/workflows/claude-issue-triage.yml | 5 +- scripts/edit-issue-labels.sh | 12 ++-- tests/test_claude_issue_triage.py | 70 +++++++++++++++++------ 3 files changed, 63 insertions(+), 24 deletions(-) diff --git a/.github/workflows/claude-issue-triage.yml b/.github/workflows/claude-issue-triage.yml index a20561802..11e015c4f 100644 --- a/.github/workflows/claude-issue-triage.yml +++ b/.github/workflows/claude-issue-triage.yml @@ -56,7 +56,6 @@ jobs: - Enhancement (improvement to existing feature) - Documentation (docs improvement) - Question/Support (user help) - - MCP tool issue (specific to MCP functionality) 2. **Priority Assessment:** - Critical: Security issues, data loss, complete breakage @@ -88,6 +87,8 @@ jobs: **Triage Helper Contract:** - TYPE must be exactly one of: `bug`, `enhancement`, `documentation`, `question`. Map feature requests and improvements to `enhancement`. + - MCP identifies a component, not a TYPE. Classify an MCP issue by its behavior using + one of the four TYPE values and record the MCP component in prose. - COMPONENT must be `cloud` only when the issue directly concerns Cloud behavior; otherwise use `none`. Record other components in prose because they do not have repository labels. @@ -96,7 +97,7 @@ jobs: - If an issue is a duplicate, link the original in the comment. Do not add a `duplicate` label or close the issue. - Do not probe labels, try candidate calls, or use the helper for discovery. It is a - mutating command. One final call replaces only triage-owned labels and preserves + mutating command. One final call updates only triage-owned labels and preserves every label outside that owned set. Read the issue carefully, apply the triage classification, and post a concise comment. diff --git a/scripts/edit-issue-labels.sh b/scripts/edit-issue-labels.sh index 1be269f17..262808f2c 100755 --- a/scripts/edit-issue-labels.sh +++ b/scripts/edit-issue-labels.sh @@ -92,10 +92,6 @@ while IFS= read -r label; do esac done <<< "$current_labels" -for label in "${obsolete_labels[@]}"; do - gh api --method DELETE "$labels_url/$label" --silent -done - desired_labels=("$type_label") if [[ "$component" == "cloud" ]]; then desired_labels+=("cloud") @@ -106,5 +102,13 @@ for label in "${desired_labels[@]}"; do api_args+=(-f "labels[]=$label") done +# Add the desired classification before removing obsolete values. A failed additive request +# therefore leaves the prior classification intact; a later rerun can converge after a delete +# failure without losing unrelated labels. gh api "${api_args[@]}" --silent + +for label in "${obsolete_labels[@]}"; do + gh api --method DELETE "$labels_url/$label" --silent +done + echo "Set triage labels: type=$type_label component=$component" diff --git a/tests/test_claude_issue_triage.py b/tests/test_claude_issue_triage.py index 663b55840..fa482109f 100644 --- a/tests/test_claude_issue_triage.py +++ b/tests/test_claude_issue_triage.py @@ -17,6 +17,7 @@ def _run_triage_helper( *arguments: str, current_labels: tuple[str, ...] = (), fail_label_read: bool = False, + fail_label_add: bool = False, ) -> tuple[subprocess.CompletedProcess[str], list[list[str]]]: event_path = tmp_path / "event.json" event_path.write_text(json.dumps({"issue": {"number": 1205}}), encoding="utf-8") @@ -29,7 +30,12 @@ def _run_triage_helper( """#!/usr/bin/env bash set -euo pipefail -if [[ " $* " == *" --method DELETE "* || " $* " == *" --method POST "* ]]; then +if [[ " $* " == *" --method POST "* ]]; then + printf '%s\n' "__CALL__" "$@" >> "${GH_ARGUMENTS_PATH:?}" + if [[ "${GH_FAIL_LABEL_ADD:-false}" == "true" ]]; then + exit 1 + fi +elif [[ " $* " == *" --method DELETE "* ]]; then printf '%s\n' "__CALL__" "$@" >> "${GH_ARGUMENTS_PATH:?}" elif [[ "${GH_FAIL_LABEL_READ:-false}" == "true" ]]; then exit 1 @@ -46,6 +52,7 @@ def _run_triage_helper( { "GH_ARGUMENTS_PATH": str(gh_arguments_path), "GH_CURRENT_LABELS": "\n".join(current_labels), + "GH_FAIL_LABEL_ADD": "true" if fail_label_add else "false", "GH_FAIL_LABEL_READ": "true" if fail_label_read else "false", "GITHUB_EVENT_PATH": str(event_path), "GITHUB_REPOSITORY": "basicmachines-co/basic-memory", @@ -90,24 +97,24 @@ def test_triage_helper_updates_only_owned_labels_without_replacing_other_labels( [ "api", "--method", - "DELETE", - "repos/basicmachines-co/basic-memory/issues/1205/labels/bug", + "POST", + "repos/basicmachines-co/basic-memory/issues/1205/labels", + "-f", + "labels[]=enhancement", "--silent", ], [ "api", "--method", "DELETE", - "repos/basicmachines-co/basic-memory/issues/1205/labels/cloud", + "repos/basicmachines-co/basic-memory/issues/1205/labels/bug", "--silent", ], [ "api", "--method", - "POST", - "repos/basicmachines-co/basic-memory/issues/1205/labels", - "-f", - "labels[]=enhancement", + "DELETE", + "repos/basicmachines-co/basic-memory/issues/1205/labels/cloud", "--silent", ], ] @@ -135,33 +142,33 @@ def test_triage_helper_keeps_only_one_type_and_cloud_component(tmp_path: Path) - [ "api", "--method", - "DELETE", - "repos/basicmachines-co/basic-memory/issues/1205/labels/bug", + "POST", + "repos/basicmachines-co/basic-memory/issues/1205/labels", + "-f", + "labels[]=question", + "-f", + "labels[]=cloud", "--silent", ], [ "api", "--method", "DELETE", - "repos/basicmachines-co/basic-memory/issues/1205/labels/enhancement", + "repos/basicmachines-co/basic-memory/issues/1205/labels/bug", "--silent", ], [ "api", "--method", "DELETE", - "repos/basicmachines-co/basic-memory/issues/1205/labels/documentation", + "repos/basicmachines-co/basic-memory/issues/1205/labels/enhancement", "--silent", ], [ "api", "--method", - "POST", - "repos/basicmachines-co/basic-memory/issues/1205/labels", - "-f", - "labels[]=question", - "-f", - "labels[]=cloud", + "DELETE", + "repos/basicmachines-co/basic-memory/issues/1205/labels/documentation", "--silent", ], ] @@ -185,6 +192,31 @@ def test_triage_helper_does_not_update_labels_when_current_labels_cannot_be_read assert gh_calls == [] +def test_triage_helper_does_not_delete_prior_labels_when_add_fails(tmp_path: Path) -> None: + result, gh_calls = _run_triage_helper( + tmp_path, + "--type", + "enhancement", + "--component", + "none", + current_labels=("bug", "cloud", "production"), + fail_label_add=True, + ) + + assert result.returncode != 0 + assert gh_calls == [ + [ + "api", + "--method", + "POST", + "repos/basicmachines-co/basic-memory/issues/1205/labels", + "-f", + "labels[]=enhancement", + "--silent", + ] + ] + + @pytest.mark.parametrize( "arguments, expected_error", [ @@ -222,4 +254,6 @@ def test_triage_workflow_defines_one_semantic_mutation() -> None: assert "--type TYPE --component COMPONENT" in prompt assert "Do not probe labels" in prompt assert "Priority and complexity are prose assessments only" in prompt + assert "MCP identifies a component, not a TYPE" in prompt + assert "MCP tool issue (specific to MCP functionality)" not in prompt assert "--add-label" not in workflow_text