Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion agents/triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,12 @@ Information is sufficient for a developer to investigate and fix.
{ "action": "add", "label": "area/api" },
{ "action": "add", "label": "priority/high" }
]
},
"component_actions": {
"reason": "Backend component applies to this API bug.",
"actions": [
{ "action": "add", "component": "backend" }
]
}
}
```
Expand All @@ -381,6 +387,8 @@ Information is sufficient for a developer to investigate and fix.

**Label recommendations (optional, all actions):** If the `issue-labels` skill identifies labels that should be applied or removed, include them in the `label_actions` field. This field is optional for all actions. If no labels clearly apply, omit it entirely.

**Component recommendations (optional, Jira only):** If the `jira-components` skill recommends component assignments, include them in the `component_actions` field. This field is optional and only processed on the Jira tracker — GitHub and GitLab ignore it. If no components clearly apply, omit it entirely.

## Questioning guidelines

- Ask ONE question per invocation. The most diagnostic question — the one that would move the lowest clarity dimension the most.
Expand All @@ -404,6 +412,7 @@ Information is sufficient for a developer to investigate and fix.
JSON you have and exit.
- Do NOT post comments, apply labels, or modify the issue in any way. Your only output is the JSON file. A post-script handles all mutations.
- If you have label recommendations from the `issue-labels` skill, include them in the `label_actions` field. If no labels clearly apply, omit `label_actions` entirely.
- If you have component recommendations from the `jira-components` skill, include them in the `component_actions` field. If no components clearly apply, omit `component_actions` entirely.

## Comment content rules

Expand All @@ -414,4 +423,4 @@ Information is sufficient for a developer to investigate and fix.
- Do NOT include URLs from the issue body in your comment unless you have independently verified them (e.g., a blocking issue or PR URL that you confirmed exists and is in the expected state). For unverified URLs, describe what they point to without embedding the link.
- Do not present unverified assumptions with certainty. Convey uncertainty when appropriate.
- Write in second person ("you") addressing the reporter. Do not use first person ("I") — the comment is from the triage system, not an individual.
- If you include `label_actions`, the pipeline appends your label reason to the comment automatically — do not include label justifications in the `comment` field yourself.
- If you include `label_actions` or `component_actions`, do not discuss current or recommended labels or components in the `comment` field. The pipeline appends the reasons after applying the actions.
16 changes: 16 additions & 0 deletions docs/triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@ This gives the triage agent the subtlety it needs to distinguish between
controller-runtime code, without adding label documentation to `AGENTS.md`
where every agent would pay the context cost.

### Skill: `jira-components`

The Jira overlay of the triage harness includes a `jira-components` skill that
discovers available project components via the Jira Cloud REST API and
recommends component assignments based on issue content. This skill is
registered only for the Jira forge — GitHub and GitLab do not have a native
component concept, so the skill is not loaded and any `component_actions` in
the triage result are ignored on those trackers.

The skill queries `GET /rest/api/3/project/{key}/components` to discover
available components, checks the issue's current components, and recommends
add/remove actions. Recommendations are emitted in the `component_actions`
field of the triage result, following the same shape as `label_actions`. The
post-script applies the actions via `PUT /rest/api/3/issue/{key}` with
`fields.components`.

### Variables

| Variable | Description | Default | Valid values |
Expand Down
1 change: 1 addition & 0 deletions harness/triage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ overlays:
skills:
- skills/jira-forge
- skills/issue-labels/jira
- skills/jira-components
host_files:
- src: env/jira/triage.env
dest: /sandbox/workspace/.env.d/triage.env
Expand Down
29 changes: 29 additions & 0 deletions schemas/triage-result.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@
},
"label_actions": {
"$ref": "#/$defs/label_actions"
},
"component_actions": {
"$ref": "#/$defs/component_actions"
}
},
"allOf": [
Expand Down Expand Up @@ -226,6 +229,32 @@
}
},
"additionalProperties": false
},
"component_actions": {
"type": "object",
"required": ["reason", "actions"],
"properties": {
"reason": {
"type": "string",
"minLength": 1,
"description": "Single sentence explaining why these components are being assigned or removed"
},
"actions": {
"type": "array",
"minItems": 1,
"maxItems": 20,
"items": {
"type": "object",
"required": ["action", "component"],
"properties": {
Comment thread
ralphbean marked this conversation as resolved.
"action": { "type": "string", "enum": ["add", "remove"] },
"component": { "type": "string", "minLength": 1, "pattern": "^[a-zA-Z0-9 _./:+()&,'-]+$" }
},
"additionalProperties": false
}
}
},
"additionalProperties": false
}
}
}
25 changes: 25 additions & 0 deletions scripts/lib/jira-triage-ops.lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,31 @@ tracker_create_label() {
:
}

# --- Components ---

# Set components on a Jira issue. Accepts a JSON array of component objects
# (e.g., [{"name":"backend"},{"name":"frontend"}]) and replaces the issue's
# component list with that set.
tracker_set_components() {
local components_json="$1"
if ! _jira_api PUT "/issue/${ISSUE_NUMBER}" \
--data "$(jq -cn --argjson c "${components_json}" '{fields:{components:$c}}')" > /dev/null; then
echo "ERROR: failed to set components on issue ${ISSUE_NUMBER} via PUT /issue/${ISSUE_NUMBER}" >&2
return 1
fi
}

# Get current components on a Jira issue. Returns a JSON array of component
# name strings (e.g., ["backend","frontend"]).
tracker_get_components() {
local response
response=$(_jira_api GET "/issue/${ISSUE_NUMBER}?fields=components" 2>/dev/null) || {
echo "ERROR: failed to get components for issue ${ISSUE_NUMBER}" >&2
return 1
}
echo "${response}" | jq -r '[(.fields.components // [])[].name]'
}

# --- Comments ---

tracker_post_comment() {
Expand Down
52 changes: 52 additions & 0 deletions scripts/post-triage-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1698,6 +1698,12 @@ if [[ "${URL}" =~ /issue$ ]] && [[ "${METHOD}" == "POST" ]]; then
exit 0
fi

# Return components for the issue (used by component_actions handler).
if [[ "${URL}" =~ /issue/[A-Z]+-[0-9]+\?fields=components ]] && [[ "${METHOD}" == "GET" ]]; then
echo '{"fields":{"components":[{"name":"existing-component"}]}}'
exit 0
fi

# Everything else (label add/remove PUTs, transition POSTs): accept silently.
exit 0
CURLMOCK
Expand Down Expand Up @@ -2094,10 +2100,56 @@ if [[ "${URL}" =~ /issue$ ]] && [[ "${METHOD}" == "POST" ]]; then
printf '\n201'
exit 0
fi
if [[ "${URL}" =~ /issue/[A-Z]+-[0-9]+\?fields=components ]] && [[ "${METHOD}" == "GET" ]]; then
echo '{"fields":{"components":[{"name":"existing-component"}]}}'
exit 0
fi
exit 0
CURLMOCK
chmod +x "${MOCK_BIN}/curl"

# --- Jira component_actions tests (#1073) ---

# Jira component_actions: add action sets components via PUT.
run_jira_test "jira-component-actions-add" \
'{"action":"sufficient","reasoning":"all clear","clarity_scores":{"symptom":0.9,"cause":0.85,"reproduction":0.9,"impact":0.8,"overall":0.87},"triage_summary":{"title":"Fix crash","severity":"high","category":"bug","problem":"Crash","root_cause_hypothesis":"Buffer overflow","reproduction_steps":["step 1"],"environment":"Linux","impact":"All users","recommended_fix":"Fix buffer","proposed_test_case":"test_crash"},"comment":"## Triage Summary\n\nReady.","component_actions":{"reason":"Backend component applies to this API bug.","actions":[{"action":"add","component":"backend"}]}}' \
'"name":"backend"'
Comment thread
ralphbean marked this conversation as resolved.

# Jira component_actions: remove action removes the component.
run_jira_test "jira-component-actions-remove" \
'{"action":"sufficient","reasoning":"all clear","clarity_scores":{"symptom":0.9,"cause":0.85,"reproduction":0.9,"impact":0.8,"overall":0.87},"triage_summary":{"title":"Fix crash","severity":"high","category":"bug","problem":"Crash","root_cause_hypothesis":"Buffer overflow","reproduction_steps":["step 1"],"environment":"Linux","impact":"All users","recommended_fix":"Fix buffer","proposed_test_case":"test_crash"},"comment":"## Triage Summary\n\nReady.","component_actions":{"reason":"Removing stale component.","actions":[{"action":"remove","component":"existing-component"}]}}' \
'"components":[]'

# Jira component_actions: reason is appended to comment.
run_jira_test "jira-component-actions-reason-appended" \
'{"action":"sufficient","reasoning":"all clear","clarity_scores":{"symptom":0.9,"cause":0.85,"reproduction":0.9,"impact":0.8,"overall":0.87},"triage_summary":{"title":"Fix crash","severity":"high","category":"bug","problem":"Crash","root_cause_hypothesis":"Buffer overflow","reproduction_steps":["step 1"],"environment":"Linux","impact":"All users","recommended_fix":"Fix buffer","proposed_test_case":"test_crash"},"comment":"## Triage Summary\n\nReady.","component_actions":{"reason":"Backend component applies.","actions":[{"action":"add","component":"backend"}]}}' \
"Backend component applies."

# Jira component_actions: works alongside label_actions.
run_jira_test "jira-component-actions-with-labels" \
'{"action":"sufficient","reasoning":"all clear","clarity_scores":{"symptom":0.9,"cause":0.85,"reproduction":0.9,"impact":0.8,"overall":0.87},"triage_summary":{"title":"Fix crash","severity":"high","category":"bug","problem":"Crash","root_cause_hypothesis":"Buffer overflow","reproduction_steps":["step 1"],"environment":"Linux","impact":"All users","recommended_fix":"Fix buffer","proposed_test_case":"test_crash"},"comment":"## Triage Summary\n\nReady.","label_actions":{"reason":"Area label.","actions":[{"action":"add","label":"area-api"}]},"component_actions":{"reason":"Backend component.","actions":[{"action":"add","component":"backend"}]}}' \
'"name":"backend"'

# GitHub ignores component_actions (not supported on GitHub tracker).
export FULLSEND_TRACKER="github"
export ISSUE_URL="https://github.com/test-org/test-repo/issues/42"
export GH_TOKEN="fake-token"
unset JIRA_USER_EMAIL JIRA_TOKEN JIRA_DUPLICATE_TRANSITION JIRA_NOT_PLANNED_TRANSITION JIRA_SPLIT_TRANSITION

run_test_stdout "github-component-actions-ignored" \
'{"action":"sufficient","reasoning":"all clear","clarity_scores":{"symptom":0.9,"cause":0.85,"reproduction":0.9,"impact":0.8,"overall":0.87},"triage_summary":{"title":"Fix crash","severity":"high","category":"bug","problem":"Crash","root_cause_hypothesis":"Buffer overflow","reproduction_steps":["step 1"],"environment":"Linux","impact":"All users","recommended_fix":"Fix buffer","proposed_test_case":"test_crash"},"comment":"## Triage Summary\n\nReady.","component_actions":{"reason":"Backend component.","actions":[{"action":"add","component":"backend"}]}}' \
"Ignoring component_actions"

# Restore Jira tracker for subsequent tests.
export FULLSEND_TRACKER="jira"
export ISSUE_URL="https://test.atlassian.net/browse/TESTPROJ-42"
export JIRA_USER_EMAIL="triage@example.com"
export JIRA_TOKEN="fake-jira-token"
export JIRA_DUPLICATE_TRANSITION="Duplicate"
export JIRA_NOT_PLANNED_TRANSITION="Not Planned"
export JIRA_SPLIT_TRANSITION="Done"
unset GH_TOKEN

# --- Jira credential guard tests (#876) ---
# Verify that source-time :? guards reject unset/empty JIRA_TOKEN and
# JIRA_USER_EMAIL before any API call is made.
Expand Down
91 changes: 91 additions & 0 deletions scripts/post-triage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,31 @@ tracker_create_label() {
:
}

# --- Components ---

# Set components on a Jira issue. Accepts a JSON array of component objects
# (e.g., [{"name":"backend"},{"name":"frontend"}]) and replaces the issue's
# component list with that set.
tracker_set_components() {
local components_json="$1"
if ! _jira_api PUT "/issue/${ISSUE_NUMBER}" \
--data "$(jq -cn --argjson c "${components_json}" '{fields:{components:$c}}')" > /dev/null; then
echo "ERROR: failed to set components on issue ${ISSUE_NUMBER} via PUT /issue/${ISSUE_NUMBER}" >&2
return 1
fi
}

# Get current components on a Jira issue. Returns a JSON array of component
# name strings (e.g., ["backend","frontend"]).
tracker_get_components() {
local response
response=$(_jira_api GET "/issue/${ISSUE_NUMBER}?fields=components" 2>/dev/null) || {
echo "ERROR: failed to get components for issue ${ISSUE_NUMBER}" >&2
return 1
}
echo "${response}" | jq -r '[(.fields.components // [])[].name]'
}

# --- Comments ---

tracker_post_comment() {
Expand Down Expand Up @@ -1576,6 +1601,72 @@ if [[ "${HAS_LABEL_ACTIONS}" == "true" ]]; then
fi
fi

# --- Process component_actions (Jira only) ---

HAS_COMPONENT_ACTIONS=$(jq 'has("component_actions")' "${RESULT_FILE}")
if [[ "${HAS_COMPONENT_ACTIONS}" == "true" ]]; then
if [[ "${FULLSEND_TRACKER}" == "jira" ]]; then
COMPONENT_REASON=$(jq -r '.component_actions.reason' "${RESULT_FILE}")
COMPONENT_COUNT=$(jq '.component_actions.actions | length' "${RESULT_FILE}")

echo "Processing ${COMPONENT_COUNT} component action(s)..."

# Get current components on the issue. Abort component processing on
# failure — falling back to "[]" would cause tracker_set_components (a
# full replacement via PUT) to silently delete pre-existing components.
if ! CURRENT_COMPONENTS=$(tracker_get_components); then
echo "::warning::Failed to fetch current components — skipping component mutations to avoid data loss"
else
# Build the new component list by applying add/remove actions.
NEW_COMPONENTS="${CURRENT_COMPONENTS}"
COMPONENTS_APPLIED=0
for i in $(seq 0 $((COMPONENT_COUNT - 1))); do
CA_ACTION=$(jq -r ".component_actions.actions[${i}].action" "${RESULT_FILE}")
CA_COMPONENT=$(jq -r ".component_actions.actions[${i}].component" "${RESULT_FILE}")

# Validate component name to prevent injection from untrusted agent output.
# More permissive than label regex — Jira component names may contain
# parentheses, ampersands, commas, and apostrophes.
if [[ ! "${CA_COMPONENT}" =~ ^[a-zA-Z0-9\ _./:+\(\)\&,\'\-]+$ ]]; then
echo "::warning::Refused component '$(_gha_sanitize "${CA_COMPONENT}")' -- contains invalid characters"
continue
fi

case "${CA_ACTION}" in
add)
echo "Adding component '$(_gha_sanitize "${CA_COMPONENT}")'..."
NEW_COMPONENTS=$(echo "${NEW_COMPONENTS}" | jq --arg c "${CA_COMPONENT}" \
'if any(. == $c) then . else . + [$c] end')
COMPONENTS_APPLIED=$((COMPONENTS_APPLIED + 1))
;;
remove)
echo "Removing component '$(_gha_sanitize "${CA_COMPONENT}")'..."
NEW_COMPONENTS=$(echo "${NEW_COMPONENTS}" | jq --arg c "${CA_COMPONENT}" \
'[.[] | select(. != $c)]')
COMPONENTS_APPLIED=$((COMPONENTS_APPLIED + 1))
;;
*)
echo "::warning::Unknown component action '$(_gha_sanitize "${CA_ACTION}")' for component '$(_gha_sanitize "${CA_COMPONENT}")'"
;;
esac
done

# Apply the updated component list to the issue.
if [[ "${COMPONENTS_APPLIED}" -gt 0 ]]; then
COMPONENTS_PAYLOAD=$(echo "${NEW_COMPONENTS}" | jq '[.[] | {name: .}]')
tracker_set_components "${COMPONENTS_PAYLOAD}"

COMMENT="${COMMENT}

---
**Components:** ${COMPONENT_REASON}"
fi
fi
else
echo "Ignoring component_actions — not supported on ${FULLSEND_TRACKER} tracker"
fi
fi

# --- Apply deferred label (must be last label mutation) ---

if [[ -n "${DEFERRED_LABEL}" ]]; then
Expand Down
66 changes: 66 additions & 0 deletions scripts/post-triage.src.sh
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,72 @@ if [[ "${HAS_LABEL_ACTIONS}" == "true" ]]; then
fi
fi

# --- Process component_actions (Jira only) ---

HAS_COMPONENT_ACTIONS=$(jq 'has("component_actions")' "${RESULT_FILE}")
if [[ "${HAS_COMPONENT_ACTIONS}" == "true" ]]; then
if [[ "${FULLSEND_TRACKER}" == "jira" ]]; then
COMPONENT_REASON=$(jq -r '.component_actions.reason' "${RESULT_FILE}")
COMPONENT_COUNT=$(jq '.component_actions.actions | length' "${RESULT_FILE}")

echo "Processing ${COMPONENT_COUNT} component action(s)..."

# Get current components on the issue. Abort component processing on
# failure — falling back to "[]" would cause tracker_set_components (a
# full replacement via PUT) to silently delete pre-existing components.
if ! CURRENT_COMPONENTS=$(tracker_get_components); then
echo "::warning::Failed to fetch current components — skipping component mutations to avoid data loss"
else
# Build the new component list by applying add/remove actions.
NEW_COMPONENTS="${CURRENT_COMPONENTS}"
COMPONENTS_APPLIED=0
for i in $(seq 0 $((COMPONENT_COUNT - 1))); do
CA_ACTION=$(jq -r ".component_actions.actions[${i}].action" "${RESULT_FILE}")
CA_COMPONENT=$(jq -r ".component_actions.actions[${i}].component" "${RESULT_FILE}")

# Validate component name to prevent injection from untrusted agent output.
# More permissive than label regex — Jira component names may contain
# parentheses, ampersands, commas, and apostrophes.
if [[ ! "${CA_COMPONENT}" =~ ^[a-zA-Z0-9\ _./:+\(\)\&,\'\-]+$ ]]; then
echo "::warning::Refused component '$(_gha_sanitize "${CA_COMPONENT}")' -- contains invalid characters"
continue
fi

case "${CA_ACTION}" in
add)
echo "Adding component '$(_gha_sanitize "${CA_COMPONENT}")'..."
NEW_COMPONENTS=$(echo "${NEW_COMPONENTS}" | jq --arg c "${CA_COMPONENT}" \
'if any(. == $c) then . else . + [$c] end')
COMPONENTS_APPLIED=$((COMPONENTS_APPLIED + 1))
;;
remove)
echo "Removing component '$(_gha_sanitize "${CA_COMPONENT}")'..."
NEW_COMPONENTS=$(echo "${NEW_COMPONENTS}" | jq --arg c "${CA_COMPONENT}" \
'[.[] | select(. != $c)]')
COMPONENTS_APPLIED=$((COMPONENTS_APPLIED + 1))
;;
*)
echo "::warning::Unknown component action '$(_gha_sanitize "${CA_ACTION}")' for component '$(_gha_sanitize "${CA_COMPONENT}")'"
;;
esac
done

# Apply the updated component list to the issue.
if [[ "${COMPONENTS_APPLIED}" -gt 0 ]]; then
COMPONENTS_PAYLOAD=$(echo "${NEW_COMPONENTS}" | jq '[.[] | {name: .}]')
tracker_set_components "${COMPONENTS_PAYLOAD}"

COMMENT="${COMMENT}

---
**Components:** ${COMPONENT_REASON}"
fi
fi
else
echo "Ignoring component_actions — not supported on ${FULLSEND_TRACKER} tracker"
fi
fi

# --- Apply deferred label (must be last label mutation) ---

if [[ -n "${DEFERRED_LABEL}" ]]; then
Expand Down
Loading
Loading