chore(automation): ensure status/need-triage is applied and never cleared automatically (#16657)

This commit is contained in:
Bryan Morgan
2026-01-14 20:58:50 -05:00
committed by GitHub
parent 4f324b548e
commit 467e869326
8 changed files with 268 additions and 98 deletions

138
.github/scripts/backfill-need-triage.cjs vendored Normal file
View File

@@ -0,0 +1,138 @@
/* eslint-disable */
/* global require, console, process */
/**
* Script to backfill the 'status/need-triage' label to all open issues
* that are NOT currently labeled with '🔒 maintainer only' or 'help wanted'.
*/
const { execFileSync } = require('child_process');
const isDryRun = process.argv.includes('--dry-run');
const REPO = 'google-gemini/gemini-cli';
/**
* Executes a GitHub CLI command safely using an argument array to prevent command injection.
* @param {string[]} args
* @returns {string|null}
*/
function runGh(args) {
try {
// Using execFileSync with an array of arguments is safe as it doesn't use a shell.
// We set a large maxBuffer (10MB) to handle repositories with many issues.
return execFileSync('gh', args, {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
} catch (error) {
const stderr = error.stderr ? ` Stderr: ${error.stderr.trim()}` : '';
console.error(
`❌ Error running gh ${args.join(' ')}: ${error.message}${stderr}`,
);
return null;
}
}
async function main() {
console.log('🔐 GitHub CLI security check...');
const authStatus = runGh(['auth', 'status']);
if (authStatus === null) {
console.error('❌ GitHub CLI (gh) is not installed or not authenticated.');
process.exit(1);
}
if (isDryRun) {
console.log('🧪 DRY RUN MODE ENABLED - No changes will be made.\n');
}
console.log(`🔍 Fetching and filtering open issues from ${REPO}...`);
// We use the /issues endpoint with pagination to bypass the 1000-result limit.
// The jq filter ensures we exclude PRs, maintainer-only, help-wanted, and existing status/need-triage.
const jqFilter =
'.[] | select(.pull_request == null) | select([.labels[].name] as $l | (any($l[]; . == "🔒 maintainer only") | not) and (any($l[]; . == "help wanted") | not) and (any($l[]; . == "status/need-triage") | not)) | {number: .number, title: .title}';
const output = runGh([
'api',
`repos/${REPO}/issues?state=open&per_page=100`,
'--paginate',
'--jq',
jqFilter,
]);
if (output === null) {
process.exit(1);
}
const issues = output
.split('\n')
.filter((line) => line.trim())
.map((line) => {
try {
return JSON.parse(line);
} catch (_e) {
console.error(`⚠️ Failed to parse line: ${line}`);
return null;
}
})
.filter(Boolean);
console.log(`✅ Found ${issues.length} issues matching criteria.`);
if (issues.length === 0) {
console.log('✨ No issues need backfilling.');
return;
}
let successCount = 0;
let failCount = 0;
if (isDryRun) {
for (const issue of issues) {
console.log(
`[DRY RUN] Would label issue #${issue.number}: ${issue.title}`,
);
}
successCount = issues.length;
} else {
console.log(`🏷️ Applying labels to ${issues.length} issues...`);
for (const issue of issues) {
const issueNumber = String(issue.number);
console.log(`🏷️ Labeling issue #${issueNumber}: ${issue.title}`);
const result = runGh([
'issue',
'edit',
issueNumber,
'--add-label',
'status/need-triage',
'--repo',
REPO,
]);
if (result !== null) {
successCount++;
} else {
failCount++;
}
}
}
console.log(`\n📊 Summary:`);
console.log(` - Success: ${successCount}`);
console.log(` - Failed: ${failCount}`);
if (failCount > 0) {
console.error(`\n❌ Backfill completed with ${failCount} errors.`);
process.exit(1);
} else {
console.log(`\n🎉 ${isDryRun ? 'Dry run' : 'Backfill'} complete!`);
}
}
main().catch((error) => {
console.error('❌ Unexpected error:', error);
process.exit(1);
});

View File

@@ -1,4 +1,8 @@
#!/usr/bin/env bash
# @license
# Copyright 2026 Google LLC
# SPDX-License-Identifier: Apache-2.0
set -euo pipefail
# Initialize a comma-separated string to hold PR numbers that need a comment
@@ -10,7 +14,7 @@ ISSUE_LABELS_CACHE_FLAT=""
# Function to get area and priority labels from an issue (with caching)
get_issue_labels() {
local ISSUE_NUM=$1
local ISSUE_NUM="${1}"
if [[ -z "${ISSUE_NUM}" || "${ISSUE_NUM}" == "null" || "${ISSUE_NUM}" == "" ]]; then
return
fi
@@ -18,10 +22,13 @@ get_issue_labels() {
# Check cache
case " ${ISSUE_LABELS_CACHE_FLAT} " in
*" ${ISSUE_NUM}:"*)
local suffix="${ISSUE_LABELS_CACHE_FLAT#* ${ISSUE_NUM}:}"
local suffix="${ISSUE_LABELS_CACHE_FLAT#* " ${ISSUE_NUM}:"}"
echo "${suffix%% *}"
return
;;
*)
# Cache miss, proceed to fetch
;;
esac
echo " 📥 Fetching area and priority labels from issue #${ISSUE_NUM}" >&2
@@ -33,19 +40,19 @@ get_issue_labels() {
fi
local labels
labels=$(echo "${gh_output}" | grep -E "^(area|priority)/" | tr '\n' ',' | sed 's/,$//' || echo "")
labels=$(echo "${gh_output}" | grep -E '^(area|priority)/' | tr '\n' ',' | sed 's/,$//' || echo "")
# Save to flat cache
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT} ${ISSUE_NUM}:${labels}"
echo "$labels"
echo "${labels}"
}
# Function to process a single PR with pre-fetched data
process_pr_optimized() {
local PR_NUMBER=$1
local IS_DRAFT=$2
local ISSUE_NUMBER=$3
local CURRENT_LABELS=$4 # Comma-separated labels
local PR_NUMBER="${1}"
local IS_DRAFT="${2}"
local ISSUE_NUMBER="${3}"
local CURRENT_LABELS="${4}" # Comma-separated labels
echo "🔄 Processing PR #${PR_NUMBER}"
@@ -84,7 +91,7 @@ process_pr_optimized() {
ISSUE_LABELS=$(get_issue_labels "${ISSUE_NUMBER}")
if [[ -n "${ISSUE_LABELS}" ]]; then
local IFS_OLD=$IFS
local IFS_OLD="${IFS}"
IFS=','
for label in ${ISSUE_LABELS}; do
if [[ -n "${label}" ]] && [[ ",${CURRENT_LABELS}," != *",${label},"* ]]; then
@@ -94,8 +101,8 @@ process_pr_optimized() {
LABELS_TO_ADD="${LABELS_TO_ADD},${label}"
fi
fi
done
IFS=$IFS_OLD
done
IFS="${IFS_OLD}"
fi
if [[ -z "${LABELS_TO_ADD}" && -z "${LABELS_TO_REMOVE}" ]]; then
@@ -135,7 +142,7 @@ JQ_EXTRACT_FIELDS='{
labels: [.labels[].name] | join(",")
}'
JQ_TSV_FORMAT='"\((.number | tostring))\t\(.isDraft)\t\((.issue // "null") | tostring)\t\(.labels)"'
JQ_TSV_FORMAT='"\((.number | tostring))\t\(.isDraft)\t\((.issue // \"null\") | tostring)\t\(.labels)"' # Corrected escaping for quotes within the string literal
if [[ -n "${PR_NUMBER:-}" ]]; then
echo "🔄 Processing single PR #${PR_NUMBER}"
@@ -144,9 +151,9 @@ if [[ -n "${PR_NUMBER:-}" ]]; then
exit 1
}
line=$(echo "$PR_DATA" | jq -r "$JQ_EXTRACT_FIELDS | $JQ_TSV_FORMAT")
IFS=$'\t' read -r pr_num is_draft issue_num current_labels <<< "$line"
process_pr_optimized "$pr_num" "$is_draft" "$issue_num" "$current_labels"
line=$(echo "${PR_DATA}" | jq -r "${JQ_EXTRACT_FIELDS} | ${JQ_TSV_FORMAT}")
IFS=$'\t' read -r pr_num is_draft issue_num current_labels <<< "${line}"
process_pr_optimized "${pr_num}" "${is_draft}" "${issue_num}" "${current_labels}"
else
echo "📥 Getting all open pull requests..."
PR_DATA_ALL=$(gh pr list --repo "${GITHUB_REPOSITORY}" --state open --limit 1000 --json number,closingIssuesReferences,isDraft,body,labels 2>/dev/null) || {
@@ -157,11 +164,15 @@ else
PR_COUNT=$(echo "${PR_DATA_ALL}" | jq '. | length')
echo "📊 Found ${PR_COUNT} open PRs to process"
# Use a temporary file to avoid masking exit codes in process substitution
tmp_file=$(mktemp)
echo "${PR_DATA_ALL}" | jq -r ".[] | ${JQ_EXTRACT_FIELDS} | ${JQ_TSV_FORMAT}" > "${tmp_file}"
while read -r line; do
[[ -z "$line" ]] && continue
IFS=$'\t' read -r pr_num is_draft issue_num current_labels <<< "$line"
process_pr_optimized "$pr_num" "$is_draft" "$issue_num" "$current_labels"
done < <(echo "${PR_DATA_ALL}" | jq -r ".[] | $JQ_EXTRACT_FIELDS | $JQ_TSV_FORMAT")
[[ -z "${line}" ]] && continue
IFS=$'\t' read -r pr_num is_draft issue_num current_labels <<< "${line}"
process_pr_optimized "${pr_num}" "${is_draft}" "${issue_num}" "${current_labels}"
done < "${tmp_file}"
rm -f "${tmp_file}"
fi
if [[ -z "${PRS_NEEDING_COMMENT}" ]]; then
@@ -170,4 +181,4 @@ else
echo "prs_needing_comment=[${PRS_NEEDING_COMMENT}]" >> "${GITHUB_OUTPUT}"
fi
echo "✅ PR triage completed"
echo "✅ PR triage completed"

View File

@@ -95,7 +95,8 @@ jobs:
id: 'generate_token'
env:
APP_ID: '${{ secrets.APP_ID }}'
if: "${{ env.APP_ID != '' }}"
if: |-
${{ env.APP_ID != '' }}
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
with:
app-id: '${{ secrets.APP_ID }}'
@@ -305,22 +306,6 @@ jobs:
});
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`);
// Remove the 'status/need-triage' label
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: 'status/need-triage'
});
core.info(`Successfully removed 'status/need-triage' label.`);
} catch (error) {
// If the label doesn't exist, the API call will throw a 404. We can ignore this.
if (error.status !== 404) {
core.warning(`Failed to remove 'status/need-triage': ${error.message}`);
}
}
- name: 'Post Issue Analysis Failure Comment'
if: |-
${{ failure() && steps.gemini_issue_analysis.outcome == 'failure' }}

View File

@@ -40,7 +40,8 @@ jobs:
permission-issues: 'write'
- name: 'Get issue from event'
if: "github.event_name == 'issues'"
if: |-
${{ github.event_name == 'issues' }}
id: 'get_issue_from_event'
env:
ISSUE_EVENT: '${{ toJSON(github.event.issue) }}'
@@ -51,7 +52,8 @@ jobs:
echo "✅ Found issue #${{ github.event.issue.number }} from event to triage! 🎯"
- name: 'Find untriaged issues'
if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'"
if: |-
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
id: 'find_issues'
env:
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
@@ -161,7 +163,6 @@ jobs:
9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below.
- After identifying appropriate labels to an issue, add "status/need-triage" label to labels_to_remove in the output.
11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation.
12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
@@ -262,24 +263,6 @@ jobs:
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`);
}
if (entry.labels_to_remove && entry.labels_to_remove.length > 0) {
for (const label of entry.labels_to_remove) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: label
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
core.info(`Successfully removed labels for #${issueNumber}: ${entry.labels_to_remove.join(', ')}`);
}
if (entry.explanation) {
await github.rest.issues.createComment({
owner: context.repo.owner,

View File

@@ -0,0 +1,46 @@
name: '🏷️ Issue Opened Labeler'
on:
issues:
types:
- 'opened'
jobs:
label-issue:
runs-on: 'ubuntu-latest'
if: |-
${{ github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli' }}
steps:
- name: 'Generate GitHub App Token'
id: 'generate_token'
env:
APP_ID: '${{ secrets.APP_ID }}'
if: |-
${{ env.APP_ID != '' }}
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
with:
app-id: '${{ secrets.APP_ID }}'
private-key: '${{ secrets.PRIVATE_KEY }}'
- name: 'Add need-triage label'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
script: |-
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const hasLabel = issue.labels.some(l => l.name === 'status/need-triage');
if (!hasLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['status/need-triage']
});
} else {
core.info('Issue already has status/need-triage label. Skipping.');
}

View File

@@ -4,37 +4,39 @@
# Example: ./scripts/batch_triage.sh google-gemini/maintainers-gemini-cli
set -e
set -o pipefail
REPO="${1:-google-gemini/gemini-cli}"
WORKFLOW="gemini-automated-issue-triage.yml"
echo "🔍 Searching for open issues in '$REPO' that need triage (missing 'area/' label)..."
echo "🔍 Searching for open issues in '${REPO}' that need triage (missing 'area/' label)..."
# Fetch open issues with number, title, and labels
# We fetch up to 1000 issues.
ISSUES_JSON=$(gh issue list --repo "$REPO" --state open --limit 1000 --json number,title,labels)
ISSUES_JSON=$(gh issue list --repo "${REPO}" --state open --limit 1000 --json number,title,labels)
# Filter issues that DO NOT have a label starting with 'area/'
TARGET_ISSUES=$(echo "$ISSUES_JSON" | jq '[.[] | select(.labels | map(.name) | any(startswith("area/")) | not)]')
TARGET_ISSUES=$(echo "${ISSUES_JSON}" | jq '[.[] | select(.labels | map(.name) | any(startswith("area/")) | not)]')
COUNT=$(echo "$TARGET_ISSUES" | jq '. | length')
# Avoid masking return value
COUNT=$(jq '. | length' <<< "${TARGET_ISSUES}")
if [ "$COUNT" -eq 0 ]; then
echo "✅ No issues found needing triage in '$REPO'."
if [[ "${COUNT}" -eq 0 ]]; then
echo "✅ No issues found needing triage in '${REPO}'."
exit 0
fi
echo "🚀 Found $COUNT issues to triage."
echo "🚀 Found ${COUNT} issues to triage."
# Loop through and trigger workflow
echo "$TARGET_ISSUES" | jq -r '.[] | "\(.number)|\(.title)"' | while IFS="|" read -r number title; do
echo "▶️ Triggering triage for #$number: $title"
echo "${TARGET_ISSUES}" | jq -r '.[] | "\(.number)|\(.title)"' | while IFS="|" read -r number title; do
echo "▶️ Triggering triage for #${number}: ${title}"
# Trigger the workflow dispatch event
gh workflow run "$WORKFLOW" --repo "$REPO" -f issue_number="$number"
gh workflow run "${WORKFLOW}" --repo "${REPO}" -f issue_number="${number}"
# Sleep briefly to be nice to the API
sleep 1
done
echo "🎉 All triage workflows triggered!"
echo "🎉 All triage workflows triggered!"

View File

@@ -3,40 +3,42 @@
# Usage: ./scripts/relabel_issues.sh <old-label> <new-label> [repository]
set -e
set -o pipefail
OLD_LABEL="$1"
NEW_LABEL="$2"
OLD_LABEL="${1}"
NEW_LABEL="${2}"
REPO="${3:-google-gemini/gemini-cli}"
if [ -z "$OLD_LABEL" ] || [ -z "$NEW_LABEL" ]; then
if [[ -z "${OLD_LABEL}" ]] || [[ -z "${NEW_LABEL}" ]]; then
echo "Usage: $0 <old-label> <new-label> [repository]"
echo "Example: $0 'area/models' 'area/agent'"
exit 1
fi
echo "🔍 Searching for open issues in '$REPO' with label '$OLD_LABEL'..."
echo "🔍 Searching for open issues in '${REPO}' with label '${OLD_LABEL}'..."
# Fetch issues with the old label
ISSUES=$(gh issue list --repo "$REPO" --label "$OLD_LABEL" --state open --limit 1000 --json number,title)
ISSUES=$(gh issue list --repo "${REPO}" --label "${OLD_LABEL}" --state open --limit 1000 --json number,title)
COUNT=$(echo "$ISSUES" | jq '. | length')
# Avoid masking return value
COUNT=$(jq '. | length' <<< "${ISSUES}")
if [ "$COUNT" -eq 0 ]; then
echo "✅ No issues found with label '$OLD_LABEL'."
if [[ "${COUNT}" -eq 0 ]]; then
echo "✅ No issues found with label '${OLD_LABEL}'."
exit 0
fi
echo "found $COUNT issues to relabel."
echo "found ${COUNT} issues to relabel."
# Iterate and update
echo "$ISSUES" | jq -r '.[] | "\(.number) \(.title)"' | while read -r number title; do
echo "🔄 Processing #$number: $title"
echo " - Removing: $OLD_LABEL"
echo " + Adding: $NEW_LABEL"
echo "${ISSUES}" | jq -r '.[] | "\(.number) \(.title)"' | while read -r number title; do
echo "🔄 Processing #${number}: ${title}"
echo " - Removing: ${OLD_LABEL}"
echo " + Adding: ${NEW_LABEL}"
gh issue edit "$number" --repo "$REPO" --add-label "$NEW_LABEL" --remove-label "$OLD_LABEL"
gh issue edit "${number}" --repo "${REPO}" --add-label "${NEW_LABEL}" --remove-label "${OLD_LABEL}"
echo " ✅ Done."
done
echo "🎉 All issues relabeled!"
echo "🎉 All issues relabeled!"

View File

@@ -30,9 +30,10 @@
set -e -E
# Load environment variables from .env if it exists
if [ -f ".env" ]; then
if [[ -f ".env" ]]; then
echo "Loading environment variables from .env file..."
set -a # Automatically export all variables
# shellcheck source=/dev/null
source .env
set +a
fi
@@ -49,32 +50,32 @@ STREAM_MODE=false
# Parse command line arguments
while [[ "$#" -gt 0 ]]; do
case $1 in
--payload) PAYLOAD_FILE="$2"; shift ;;
--model) MODEL_ID="$2"; shift ;;
--payload) PAYLOAD_FILE="${2}"; shift ;;
--model) MODEL_ID="${2}"; shift ;;
--stream) STREAM_MODE=true ;;
*) echo "Unknown parameter passed: $1"; usage ;;
*) echo "Unknown parameter passed: ${1}"; usage ;;
esac
shift
done
# Validate inputs
if [ -z "$PAYLOAD_FILE" ] || [ -z "$MODEL_ID" ]; then
if [[ -z "${PAYLOAD_FILE}" ]] || [[ -z "${MODEL_ID}" ]]; then
echo "Error: Missing required arguments."
usage
fi
if [ -z "$GEMINI_API_KEY" ]; then
if [[ -z "${GEMINI_API_KEY}" ]]; then
echo "Error: GEMINI_API_KEY environment variable is not set."
exit 1
fi
if [ ! -f "$PAYLOAD_FILE" ]; then
echo "Error: Payload file '$PAYLOAD_FILE' does not exist."
if [[ ! -f "${PAYLOAD_FILE}" ]]; then
echo "Error: Payload file '${PAYLOAD_FILE}' does not exist."
exit 1
fi
# API Endpoint definition
if [ "$STREAM_MODE" = true ]; then
if [[ "${STREAM_MODE}" = true ]]; then
GENERATE_CONTENT_API="streamGenerateContent"
echo "Mode: Streaming"
else
@@ -82,16 +83,18 @@ else
echo "Mode: Non-streaming (Default)"
fi
echo "Sending request to model: $MODEL_ID"
echo "Using payload from: $PAYLOAD_FILE"
echo "Sending request to model: ${MODEL_ID}"
echo "Using payload from: ${PAYLOAD_FILE}"
echo "----------------------------------------"
# Make the cURL request. If non-streaming, pipe through jq for readability if available.
if [ "$STREAM_MODE" = false ] && command -v jq &> /dev/null; then
curl -s -X POST \
if [[ "${STREAM_MODE}" = false ]] && command -v jq &> /dev/null; then
# Invoke curl separately to avoid masking its return value
output=$(curl -s -X POST \
-H "Content-Type: application/json" \
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL_ID}:${GENERATE_CONTENT_API}?key=${GEMINI_API_KEY}" \
-d "@${PAYLOAD_FILE}" | jq .
-d "@${PAYLOAD_FILE}")
echo "${output}" | jq .
else
curl -X POST \
-H "Content-Type: application/json" \