diff --git a/.github/workflows/claude-issue-triage.yml b/.github/workflows/claude-issue-triage.yml index 9b4087ab4..11e015c4f 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:** @@ -55,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 @@ -77,22 +77,32 @@ 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`. + - 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. + - 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 updates 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..262808f2c 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,106 @@ 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: --add-label requires a label" >&2 + echo "Error: --type requires a value" >&2 exit 1 fi - labels+=("$2") + 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: --component requires a value" >&2 + exit 1 + fi + if [[ -n "$component" ]]; then + echo "Error: --component may be provided only once" >&2 + exit 1 + fi + 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 +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 + +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" + +# 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 -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 +obsolete_labels=() +while IFS= read -r label; do + case "$label" in + "$type_label") ;; + cloud) + if [[ "$component" != "cloud" ]]; then + obsolete_labels+=("$label") + fi + ;; + bug|enhancement|documentation|question) obsolete_labels+=("$label") ;; + esac +done <<< "$current_labels" -if [[ ${#filtered_labels[@]} -eq 0 ]]; then - exit 0 +desired_labels=("$type_label") +if [[ "$component" == "cloud" ]]; then + desired_labels+=("cloud") fi -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 +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 -echo "Added: ${filtered_labels[*]}" + +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 new file mode 100644 index 000000000..fa482109f --- /dev/null +++ b/tests/test_claude_issue_triage.py @@ -0,0 +1,259 @@ +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, ...] = (), + 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") + + 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 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 +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), + "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", + "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_calls: list[list[str]] = [] + for line in ( + gh_arguments_path.read_text(encoding="utf-8").splitlines() + if gh_arguments_path.exists() + else [] + ): + if line == "__CALL__": + gh_calls.append([]) + else: + gh_calls[-1].append(line) + return result, gh_calls + + +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", + "--component", + "none", + current_labels=("bug", "cloud", "production", "arch-review"), + ) + + assert result.returncode == 0, result.stderr + assert gh_calls == [ + [ + "api", + "--method", + "POST", + "repos/basicmachines-co/basic-memory/issues/1205/labels", + "-f", + "labels[]=enhancement", + "--silent", + ], + [ + "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", + ], + ] + + +def test_triage_helper_keeps_only_one_type_and_cloud_component(tmp_path: Path) -> None: + result, gh_calls = _run_triage_helper( + tmp_path, + "--type", + "question", + "--component", + "cloud", + current_labels=( + "bug", + "enhancement", + "documentation", + "question", + "cloud", + "production", + ), + ) + + assert result.returncode == 0, result.stderr + assert gh_calls == [ + [ + "api", + "--method", + "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/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", + ], + ] + + +def test_triage_helper_does_not_update_labels_when_current_labels_cannot_be_read( + tmp_path: Path, +) -> None: + result, gh_calls = _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_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", + [ + (("--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_calls = _run_triage_helper(tmp_path, *arguments) + + assert result.returncode != 0 + assert expected_error in result.stderr + assert gh_calls == [] + + +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 "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