diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..612083c73 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,48 @@ +name: ADK Stale Issue Auditor (Go) + +on: + workflow_dispatch: + + schedule: + # This runs at 6:00 AM UTC (10 PM PST) + - cron: '0 6 * * *' + +jobs: + audit-stale-issues: + if: github.repository == 'google/adk-go' + runs-on: ubuntu-latest + timeout-minutes: 60 + + permissions: + issues: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.24' # Use a stable Go version + + - name: Initialize and Sync Dependencies + working-directory: contributing/samples/stale-bot-agent + run: | + # Initialize module only if go.mod is missing or invalid + if [ ! -f go.mod ] || ! grep -q "module" go.mod; then + go mod init stale-bot-agent + fi + go mod tidy + + - name: Run Stale Auditor Agent + working-directory: contributing/samples/stale-bot-agent + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OWNER: ${{ github.repository_owner }} + REPO: adk-go + CONCURRENCY_LIMIT: 3 + GEMINI_MODEL: gemini-2.5-flash + run: | + go run . \ No newline at end of file diff --git a/contributing/samples/stale-bot-agent/PROMPT_INSTRUCTION.txt b/contributing/samples/stale-bot-agent/PROMPT_INSTRUCTION.txt new file mode 100644 index 000000000..c56c76a8b --- /dev/null +++ b/contributing/samples/stale-bot-agent/PROMPT_INSTRUCTION.txt @@ -0,0 +1,73 @@ +You are a highly intelligent repository auditor for '{OWNER}/{REPO}'. +Your job is to analyze a specific issue and report findings before taking action. + +**Primary Directive:** Ignore any events from users ending in `[bot]`. +**Reporting Directive:** Output a concise summary starting with "Analysis for Issue #[number]:". + +**THRESHOLDS:** +- Stale Threshold: {stale_threshold_days} days. +- Close Threshold: {close_threshold_days} days. + +**WORKFLOW:** +1. **Context Gathering**: Call `get_issue_state`. +2. **Decision**: Follow this strict decision tree using the data returned by the tool. + +--- **DECISION TREE** --- + +**STEP 1: CHECK IF ALREADY STALE** +- **Condition**: Is `is_stale` (from tool) **True**? +- **Action**: + - **Check Role**: Look at `last_action_role`. + + - **IF 'author' OR 'other_user'**: + - **Context**: The user has responded. The issue is now ACTIVE. + - **Action 1**: Call `remove_label_from_issue` with '{STALE_LABEL_NAME}'. + - **Action 2 (ALERT CHECK)**: Look at `maintainer_alert_needed`. + - **IF True**: User edited description silently. + -> **Action**: Call `alert_maintainer_of_edit`. + - **IF False**: User commented normally. No alert needed. + - **Report**: "Analysis for Issue #[number]: ACTIVE. User activity detected. Removed stale label." + + - **IF 'maintainer'**: + - **Check Time**: Check `days_since_stale_label`. + - **If `days_since_stale_label` > {close_threshold_days}**: + - **Action**: Call `close_as_stale`. + - **Report**: "Analysis for Issue #[number]: STALE. Close threshold met. Closing." + - **Else**: + - **Report**: "Analysis for Issue #[number]: STALE. Waiting for close threshold. No action." + +**STEP 2: CHECK IF ACTIVE (NOT STALE)** +- **Condition**: `is_stale` is **False**. +- **Action**: + - **Check Role**: If `last_action_role` is 'author' or 'other_user': + - **Context**: The issue is Active. + - **Action (ALERT CHECK)**: Look at `maintainer_alert_needed`. + - **IF True**: The user edited the description silently, and we haven't alerted yet. + -> **Action**: Call `alert_maintainer_of_edit`. + -> **Report**: "Analysis for Issue #[number]: ACTIVE. Silent update detected (Description Edit). Alerted maintainer." + - **IF False**: + -> **Report**: "Analysis for Issue #[number]: ACTIVE. Last action was by user. No action." + + - **Check Role**: If `last_action_role` is 'maintainer': + - **Proceed to STEP 3.** + +**STEP 3: ANALYZE MAINTAINER INTENT** +- **Context**: The last person to act was a Maintainer. +- **Action**: Analyze `last_comment_text` using `maintainers` list and `last_actor_name`. + + - **Internal Discussion Check**: Does the comment mention or address any username found in the `maintainers` list (other than the speaker `last_actor_name`)? + - **Verdict**: **ACTIVE** (Internal Team Discussion). + - **Report**: "Analysis for Issue #[number]: ACTIVE. Maintainer is discussing with another maintainer. No action." + + - **Question Check**: Does the text ask a question, request clarification, ask for logs, or give suggestions? + - **Time Check**: Is `days_since_activity` > {stale_threshold_days}? + + - **DECISION**: + - **IF (Question == YES) AND (Time == YES) AND (Internal Discussion Check == FALSE):** + - **Action**: Call `add_stale_label_and_comment`. + - **Check**: If '{REQUEST_CLARIFICATION_LABEL}' is not in `current_labels`, call `add_label_to_issue` with '{REQUEST_CLARIFICATION_LABEL}'. + - **Report**: "Analysis for Issue #[number]: STALE. Maintainer asked question [days_since_activity] days ago. Marking stale." + - **IF (Question == YES) BUT (Time == NO)**: + - **Report**: "Analysis for Issue #[number]: PENDING. Maintainer asked question, but threshold not met yet. No action." + - **IF (Question == NO) OR (Internal Discussion Check == TRUE):** + - **Report**: "Analysis for Issue #[number]: ACTIVE. Maintainer gave status update or internal discussion detected. No action." \ No newline at end of file diff --git a/contributing/samples/stale-bot-agent/agent.go b/contributing/samples/stale-bot-agent/agent.go new file mode 100644 index 000000000..44711ea3e --- /dev/null +++ b/contributing/samples/stale-bot-agent/agent.go @@ -0,0 +1,663 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "log" + "math" + "sort" + "strings" + "time" + + "google.golang.org/adk/tool" +) + +var maintainersCache []string + +var BOT_ALERT_SIGNATURE = "**Notification:** The author has updated the issue description" + +var BOT_NAME = "adk-bot" + +type TimelineEvent struct { + Type string `json:"type"` + Actor string `json:"actor"` + Time time.Time `json:"time"` + Data any `json:"data"` +} + +type IssueState struct { + LastActionRole string `json:"last_action_role"` + LastActivityTime time.Time `json:"last_activity_time"` + LastActionType string `json:"last_action_type"` + LastCommentText *string `json:"last_comment_text"` + LastActorName string `json:"last_actor_name"` +} + +// Struct for tools that only need an Issue Number +// Used by: addStaleLabelAndComment, alertMaintainerOfEdit, closeAsStale, getIssueState +type IssueTargetArgs struct { + IssueNumber int `json:"issue_number" description:"The number of the GitHub issue to act upon"` +} + +// Struct for tools that need an Issue Number AND a Label Name +// Used by: addLabelToIssue, removeLabelFromIssue +type LabelTargetArgs struct { + IssueNumber int `json:"issue_number" description:"The number of the GitHub issue"` + LabelName string `json:"label_name" description:"The specific name of the label"` +} + +func getCachedMaintainers() ([]string, error) { + // if _MAINTAINERS_CACHE is not None: return it + if maintainersCache != nil { + return maintainersCache, nil + } + + log.Println("Initializing Maintainers Cache...") + + url := fmt.Sprintf("%s/repos/%s/%s/collaborators", GitHubBaseURL, Owner, Repo) + params := map[string]interface{}{ + "permission": "push", + } + + // Uses your util-layer retry + backoff logic + data, err := GetRequest(url, params) + if err != nil { + log.Printf("ERROR: Failed to verify repository maintainers. Error: %v", err) + return nil, fmt.Errorf("maintainer verification failed: %w", err) + } + + rawList, ok := data.([]interface{}) + if !ok { + log.Printf( + "Invalid API response format: Expected list, got %T", + data, + ) + return nil, fmt.Errorf("github API returned non-list data") + } + + maintainers := make([]string, 0, len(rawList)) + + for _, item := range rawList { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + if login, ok := obj["login"].(string); ok { + maintainers = append(maintainers, login) + } + } + + maintainersCache = maintainers + log.Printf("Cached %d maintainers.", len(maintainersCache)) + + return maintainersCache, nil +} + +func FetchGraphQLData(itemNumber int) (map[string]any, error) { + query := ` +query($owner: String!, $name: String!, $number: Int!, $commentLimit: Int!, $timelineLimit: Int!, $editLimit: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + author { login } + createdAt + labels(first: 20) { nodes { name } } + + comments(last: $commentLimit) { + nodes { + author { login } + body + createdAt + lastEditedAt + } + } + + userContentEdits(last: $editLimit) { + nodes { + editor { login } + editedAt + } + } + + timelineItems( + itemTypes: [LABELED_EVENT, RENAMED_TITLE_EVENT, REOPENED_EVENT], + last: $timelineLimit + ) { + nodes { + __typename + ... on LabeledEvent { + createdAt + actor { login } + label { name } + } + ... on RenamedTitleEvent { + createdAt + actor { login } + } + ... on ReopenedEvent { + createdAt + actor { login } + } + } + } + } + } +} +` + + variables := map[string]any{ + "owner": Owner, + "name": Repo, + "number": itemNumber, + "commentLimit": GraphQLCommentLimit, + "editLimit": GraphQLEditLimit, + "timelineLimit": GraphQLTimelineLimit, + } + + payload := map[string]any{ + "query": query, + "variables": variables, + } + + respAny, err := PostRequest(GitHubBaseURL+"/graphql", payload) + if err != nil { + return nil, err + } + + resp, ok := respAny.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid GraphQL response format") + } + + if errs, ok := resp["errors"]; ok { + if errList, ok := errs.([]any); ok && len(errList) > 0 { + if firstErr, ok := errList[0].(map[string]any); ok { + return nil, fmt.Errorf("GraphQL Error: %v", firstErr["message"]) + } + } + return nil, fmt.Errorf("GraphQL Error: unknown format") + } + + data := resp["data"].(map[string]any) + repo := data["repository"].(map[string]any) + issue := repo["issue"] + + if issue == nil { + return nil, fmt.Errorf("issue #%d not found", itemNumber) + } + + return issue.(map[string]any), nil +} + +func buildHistoryTimeline(data map[string]any) ([]TimelineEvent, []time.Time, *time.Time) { + issueAuthor := "" + if author, ok := data["author"].(map[string]any); ok { + issueAuthor, _ = author["login"].(string) + } + + var history []TimelineEvent + var labelEvents []time.Time + var lastBotAlertTime *time.Time + + parseTime := func(val any) time.Time { + s, _ := val.(string) + t, _ := time.Parse(time.RFC3339, s) + return t + } + + isBot := func(actor string) bool { + return actor == "" || strings.HasSuffix(actor, "[bot]") || actor == BOT_NAME + } + + // 1. Baseline: Issue Creation + createdAt := parseTime(data["createdAt"]) + history = append(history, TimelineEvent{ + Type: "created", + Actor: issueAuthor, + Time: createdAt, + Data: nil, + }) + + // 2. Process Comments + if comments, ok := data["comments"].(map[string]any); ok { + if nodes, ok := comments["nodes"].([]any); ok { + for _, node := range nodes { + c, ok := node.(map[string]any) + if !ok || c == nil { + continue + } + + actor := "" + if a, ok := c["author"].(map[string]any); ok { + actor, _ = a["login"].(string) + } + + cBody, _ := c["body"].(string) + cTime := parseTime(c["createdAt"]) + + // Track bot alerts for spam prevention + if strings.Contains(cBody, BOT_ALERT_SIGNATURE) { + if lastBotAlertTime == nil || cTime.After(*lastBotAlertTime) { + tempTime := cTime + lastBotAlertTime = &tempTime + } + continue + } + + if !isBot(actor) { + // Use edit time if available, otherwise creation time + actualTime := cTime + if eTimeStr, ok := c["lastEditedAt"].(string); ok && eTimeStr != "" { + actualTime = parseTime(eTimeStr) + } + + history = append(history, TimelineEvent{ + Type: "commented", + Actor: actor, + Time: actualTime, + Data: cBody, + }) + } + } + } + } + + // 3. Process Body Edits ("Ghost Edits") + if edits, ok := data["userContentEdits"].(map[string]any); ok { + if nodes, ok := edits["nodes"].([]any); ok { + for _, node := range nodes { + e, ok := node.(map[string]any) + if !ok || e == nil { + continue + } + + actor := "" + if ed, ok := e["editor"].(map[string]any); ok { + actor, _ = ed["login"].(string) + } + + if !isBot(actor) { + history = append(history, TimelineEvent{ + Type: "edited_description", + Actor: actor, + Time: parseTime(e["editedAt"]), + Data: nil, + }) + } + } + } + } + + // 4. Process Timeline Events + if timeline, ok := data["timelineItems"].(map[string]any); ok { + if nodes, ok := timeline["nodes"].([]any); ok { + for _, node := range nodes { + t, ok := node.(map[string]any) + if !ok || t == nil { + continue + } + + etype, _ := t["__typename"].(string) + actor := "" + if a, ok := t["actor"].(map[string]any); ok { + actor, _ = a["login"].(string) + } + timeVal := parseTime(t["createdAt"]) + + if etype == "LabeledEvent" { + labelName := "" + if lbl, ok := t["label"].(map[string]any); ok { + labelName, _ = lbl["name"].(string) + } + if labelName == StaleLabelName { + labelEvents = append(labelEvents, timeVal) + } + continue + } + + if !isBot(actor) { + prettyType := "reopened" + if etype == "RenamedTitleEvent" { + prettyType = "renamed_title" + } + history = append(history, TimelineEvent{ + Type: prettyType, + Actor: actor, + Time: timeVal, + Data: nil, + }) + } + } + } + } + + // Sort chronologically + sort.Slice(history, func(i, j int) bool { + return history[i].Time.Before(history[j].Time) + }) + + return history, labelEvents, lastBotAlertTime +} + +func replayHistoryToFindState(history []TimelineEvent, maintainers []string, issueAuthor string) IssueState { + // Initialize defaults (Baseline: Issue Creation) + // We assume history is never empty because buildHistoryTimeline adds the "created" event. + lastActionRole := "author" + lastActivityTime := history[0].Time + lastActionType := "created" + var lastCommentText *string = nil + lastActorName := issueAuthor + + for _, event := range history { + actor := event.Actor + etype := event.Type + + // Determine Role + role := "other_user" + if actor == issueAuthor { + role = "author" + } else if isMaintainer(actor, maintainers) { + role = "maintainer" + } + + // Update State + lastActionRole = role + lastActivityTime = event.Time + lastActionType = etype + lastActorName = actor + + // Handle Comment Text Logic + if etype == "commented" { + // Convert any/interface{} Data to string + if text, ok := event.Data.(string); ok { + lastCommentText = &text + } + } else { + // Resets on other events like labels/edits + lastCommentText = nil + } + } + + return IssueState{ + LastActionRole: lastActionRole, + LastActivityTime: lastActivityTime, + LastActionType: lastActionType, + LastCommentText: lastCommentText, + LastActorName: lastActorName, + } +} + +// Helper to check if actor is in maintainers list +func isMaintainer(actor string, maintainers []string) bool { + for _, m := range maintainers { + if actor == m { + return true + } + } + return false +} + +func formatDays(hours float64) string { + days := hours / 24.0 + // If it's a whole number (e.g., 7.0), return as integer string "7" + if math.Mod(days, 1.0) == 0 { + return fmt.Sprintf("%d", int(days)) + } + // Otherwise return with 1 decimal place (e.g., "0.5") + return fmt.Sprintf("%.1f", days) +} + +func errorResponse(msg string) map[string]any { + return map[string]any{ + "status": "error", + "error": msg, + } +} + +func addLabelToIssue(ctx tool.Context, args LabelTargetArgs) (ToolResult, error) { + url := fmt.Sprintf( + "%s/repos/%s/%s/issues/%d/labels", + GitHubBaseURL, + Owner, + Repo, + args.IssueNumber, + ) + + payload := []string{args.LabelName} + + _, err := PostRequest(url, payload) + if err != nil { + return ToolResult{ + Status: "failure", + Message: err.Error(), + }, err + } + + return ToolResult{ + Status: "success", + }, nil +} + +func removeLabelFromIssue(ctx tool.Context, args LabelTargetArgs) (ToolResult, error) { + url := fmt.Sprintf( + "%s/repos/%s/%s/issues/%d/labels/%s", + GitHubBaseURL, + Owner, + Repo, + args.IssueNumber, + args.LabelName, + ) + + _, err := DeleteRequest(url) + if err != nil { + return ToolResult{ + Status: "failure", + Message: fmt.Sprintf("error removing label: %v", err), + }, err + } + + return ToolResult{ + Status: "success", + }, nil +} + +func addStaleLabelAndComment(ctx tool.Context, args IssueTargetArgs) (ToolResult, error) { + staleDaysStr := formatDays(STALE_HOURS_THRESHOLD) + closeDaysStr := formatDays(CLOSE_HOURS_AFTER_STALE_THRESHOLD) + + comment := fmt.Sprintf( + "This issue has been automatically marked as stale because it has not"+ + " had recent activity for %s days after a maintainer"+ + " requested clarification. It will be closed if no further activity"+ + " occurs within %s days.", + staleDaysStr, closeDaysStr, + ) + + // 1. Post comment + commentURL := fmt.Sprintf( + "%s/repos/%s/%s/issues/%d/comments", + GitHubBaseURL, Owner, Repo, args.IssueNumber, + ) + + if _, err := PostRequest(commentURL, map[string]string{"body": comment}); err != nil { + return ToolResult{ + Status: "failure", + Message: fmt.Sprintf("error posting stale comment: %v", err), + }, err + } + + // 2. Add label + labelURL := fmt.Sprintf( + "%s/repos/%s/%s/issues/%d/labels", + GitHubBaseURL, Owner, Repo, args.IssueNumber, + ) + + if _, err := PostRequest(labelURL, []string{StaleLabelName}); err != nil { + return ToolResult{ + Status: "failure", + Message: fmt.Sprintf("error adding stale label: %v", err), + }, err + } + + return ToolResult{ + Status: "success", + }, nil +} + +func alertMaintainerOfEdit(ctx tool.Context, args IssueTargetArgs) (ToolResult, error) { + comment := fmt.Sprintf("%s. Maintainers, please review.", BOT_ALERT_SIGNATURE) + + url := fmt.Sprintf( + "%s/repos/%s/%s/issues/%d/comments", + GitHubBaseURL, Owner, Repo, args.IssueNumber, + ) + + if _, err := PostRequest(url, map[string]string{"body": comment}); err != nil { + return ToolResult{ + Status: "failure", + Message: fmt.Sprintf("error posting alert: %v", err), + }, err + } + + return ToolResult{ + Status: "success", + }, nil +} + +func closeAsStale(ctx tool.Context, args IssueTargetArgs) (ToolResult, error) { + daysStr := formatDays(CLOSE_HOURS_AFTER_STALE_THRESHOLD) + + comment := fmt.Sprintf( + "This has been automatically closed because it has been marked as stale"+ + " for over %s days.", + daysStr, + ) + + // 1. Post comment + commentURL := fmt.Sprintf( + "%s/repos/%s/%s/issues/%d/comments", + GitHubBaseURL, Owner, Repo, args.IssueNumber, + ) + + if _, err := PostRequest(commentURL, map[string]string{"body": comment}); err != nil { + return ToolResult{ + Status: "failure", + Message: fmt.Sprintf("error posting close comment: %v", err), + }, err + } + + // 2. Close issue + issueURL := fmt.Sprintf( + "%s/repos/%s/%s/issues/%d", + GitHubBaseURL, Owner, Repo, args.IssueNumber, + ) + + if _, err := PatchRequest(issueURL, map[string]string{"state": "closed"}); err != nil { + return ToolResult{ + Status: "failure", + Message: fmt.Sprintf("error closing issue: %v", err), + }, err + } + + return ToolResult{ + Status: "success", + }, nil +} + +// getIssueState orchestrates the fetching and analysis of an issue. +func getIssueState(ctx tool.Context, args IssueTargetArgs) (map[string]any, error) { + itemNumber := args.IssueNumber + + maintainers, err := getCachedMaintainers() + if err != nil { + return errorResponse(fmt.Sprintf("error getting cached maintainers: %v", err)), nil + } + + rawData, err := FetchGraphQLData(itemNumber) + if err != nil { + return errorResponse(fmt.Sprintf("network error: %v", err)), nil + } + + // Extract author + issueAuthor := "" + if author, ok := rawData["author"].(map[string]any); ok { + issueAuthor, _ = author["login"].(string) + } + + // Extract labels + var labelsList []string + if labels, ok := rawData["labels"].(map[string]any); ok { + if nodes, ok := labels["nodes"].([]any); ok { + for _, n := range nodes { + if node, ok := n.(map[string]any); ok { + if name, ok := node["name"].(string); ok { + labelsList = append(labelsList, name) + } + } + } + } + } + + history, labelEvents, lastBotAlertTime := buildHistoryTimeline(rawData) + state := replayHistoryToFindState(history, maintainers, issueAuthor) + + now := time.Now().UTC() + daysSinceActivity := now.Sub(state.LastActivityTime).Hours() / 24.0 + + isStale := false + for _, l := range labelsList { + if l == StaleLabelName { + isStale = true + break + } + } + + daysSinceStaleLabel := 0.0 + if isStale && len(labelEvents) > 0 { + latest := labelEvents[0] + for _, t := range labelEvents { + if t.After(latest) { + latest = t + } + } + daysSinceStaleLabel = now.Sub(latest).Hours() / 24.0 + } + + maintainerAlertNeeded := false + if (state.LastActionRole == "author" || state.LastActionRole == "other_user") && + state.LastActionType == "edited_description" { + + if lastBotAlertTime == nil || lastBotAlertTime.Before(state.LastActivityTime) { + maintainerAlertNeeded = true + } + } + + return map[string]any{ + "status": "success", + "last_action_role": state.LastActionRole, + "last_action_type": state.LastActionType, + "last_actor_name": state.LastActorName, + "maintainer_alert_needed": maintainerAlertNeeded, + "is_stale": isStale, + "days_since_activity": daysSinceActivity, + "days_since_stale_label": daysSinceStaleLabel, + "last_comment_text": state.LastCommentText, + "current_labels": labelsList, + "stale_threshold_days": STALE_HOURS_THRESHOLD / 24.0, + "close_threshold_days": CLOSE_HOURS_AFTER_STALE_THRESHOLD / 24.0, + "maintainers": maintainers, + "issue_author": issueAuthor, + }, nil +} diff --git a/contributing/samples/stale-bot-agent/config.go b/contributing/samples/stale-bot-agent/config.go new file mode 100644 index 000000000..7a728519f --- /dev/null +++ b/contributing/samples/stale-bot-agent/config.go @@ -0,0 +1,108 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "log" + "os" + "strconv" +) + +var ( + GitHubBaseURL = "https://api.github.com" + GitHubToken string + + Owner string + Repo string + + // Labels + StaleLabelName = "stale" + RequestClarificationLabel = "request clarification" + + // Thresholds (hours) + STALE_HOURS_THRESHOLD float64 + CLOSE_HOURS_AFTER_STALE_THRESHOLD float64 + + // Performance + ConcurrencyLimit int + + // GraphQL limits + GraphQLCommentLimit int + GraphQLEditLimit int + GraphQLTimelineLimit int + + // Rate limiting + SleepBetweenChunks float64 +) + +func InitConfig() { + GitHubToken = os.Getenv("GITHUB_TOKEN") + log.Printf("GITHUB_TOKEN length: %d", len(GitHubToken)) + if GitHubToken == "" { + log.Fatal("GITHUB_TOKEN environment variable not set") + } + + // Repo + Owner = getEnv("OWNER", "google") + Repo = getEnv("REPO", "adk-go") + + // Thresholds (hours) + STALE_HOURS_THRESHOLD = getEnvFloat("STALE_HOURS_THRESHOLD", 168.0) + CLOSE_HOURS_AFTER_STALE_THRESHOLD = getEnvFloat("CLOSE_HOURS_AFTER_STALE_THRESHOLD", 168.0) + + // Performance + ConcurrencyLimit = getEnvInt("CONCURRENCY_LIMIT", 3) + + GraphQLCommentLimit = getEnvInt("GRAPHQL_COMMENT_LIMIT", 30) + GraphQLEditLimit = getEnvInt("GRAPHQL_EDIT_LIMIT", 10) + GraphQLTimelineLimit = getEnvInt("GRAPHQL_TIMELINE_LIMIT", 20) + + // Rate limiting + SleepBetweenChunks = getEnvFloat("SLEEP_BETWEEN_CHUNKS", 1.5) + + // Sanity log + log.Printf("Config loaded → repo=%s/%s stale=%.2fh close=%.2fh", Owner, Repo, STALE_HOURS_THRESHOLD, CLOSE_HOURS_AFTER_STALE_THRESHOLD) +} + +func getEnv(key, fallback string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + i, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return i +} + +func getEnvFloat(key string, fallback float64) float64 { + v := os.Getenv(key) + if v == "" { + return fallback + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return fallback + } + return f +} diff --git a/contributing/samples/stale-bot-agent/main.go b/contributing/samples/stale-bot-agent/main.go new file mode 100644 index 000000000..cbd2cfc12 --- /dev/null +++ b/contributing/samples/stale-bot-agent/main.go @@ -0,0 +1,307 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "google.golang.org/genai" + + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/artifact" + "google.golang.org/adk/memory" + "google.golang.org/adk/model/gemini" + "google.golang.org/adk/runner" + "google.golang.org/adk/session" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" +) + +// --- Configuration Constants --- +const ( + AppName = "stale_bot_app" + UserID = "stale_bot_user" +) + +var ( + rootAgent agent.Agent + PROMPT_TEMPLATE string + geminiModel = getEnv("GEMINI_MODEL", "gemini-2.5-pro") +) + +// processSingleResult holds the return values for processSingleIssue +type processSingleResult struct { + duration time.Duration + apiCalls int +} + +// ToolResult is used for tool return values +type ToolResult struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +// processSingleIssue processes a single GitHub issue using the AI agent. +func processSingleIssue(ctx context.Context, issueNumber int) processSingleResult { + startTime := time.Now() + startAPICalls := GetAPICallCount() + log.Printf("Processing Issue #%d...", issueNumber) + res := processSingleResult{} + + // Error handling block (equivalent to try...except) + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Error processing issue #%d: %v", issueNumber, r) + } + }() + + // Initialize Session Service (InMemory) + sessionService := session.InMemoryService() + + // Create Session + sess, err := sessionService.Create(ctx, &session.CreateRequest{ + AppName: AppName, + UserID: UserID, + }) + if err != nil { + log.Printf("Error creating session for issue #%d: %v", issueNumber, err) + return + } + + // Create runner + r, err := runner.New(runner.Config{ + AppName: AppName, + Agent: rootAgent, + SessionService: sessionService, + ArtifactService: artifact.InMemoryService(), + MemoryService: memory.InMemoryService(), + }) + if err != nil { + log.Fatalf("Failed to create runner: %v", err) + } + + // Construct Prompt + promptText := fmt.Sprintf("Audit Issue #%d.", issueNumber) + promptMessage := &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + {Text: promptText}, + }, + } + + eventStream := r.Run(ctx, UserID, sess.Session.ID(), promptMessage, agent.RunConfig{}) + for event := range eventStream { + if event.Content != nil && len(event.Content.Parts) > 0 { + part := event.Content.Parts[0] + if part.Text != "" { + text := part.Text + cleanText := strings.ReplaceAll(text, "\n", " ") + if len(cleanText) > 150 { + cleanText = cleanText[:150] + } + log.Printf("#%d Decision: %s...", issueNumber, cleanText) + } + } + } + }() + + res.duration = time.Since(startTime) + endAPICalls := GetAPICallCount() + res.apiCalls = endAPICalls - startAPICalls + log.Printf("Issue #%d finished in %.2fs with ~%d API calls.", issueNumber, res.duration.Seconds(), res.apiCalls) + return res +} + +func loadPromptTemplate(filename string) (string, error) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("cannot determine caller location") + } + baseDir := filepath.Dir(currentFile) + path := filepath.Join(baseDir, filename) + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(data), nil +} + +func setupTools() []tool.Tool { + t1, _ := functiontool.New(functiontool.Config{ + Name: "add_label_to_issue", + Description: "Adds a specific label to a GitHub issue.", + }, addLabelToIssue) + + t2, _ := functiontool.New(functiontool.Config{ + Name: "remove_label_from_issue", + Description: "Remove a specific label from a GitHub issue.", + }, removeLabelFromIssue) + + t3, _ := functiontool.New(functiontool.Config{ + Name: "add_stale_label_and_comment", + Description: "Marks the issue as stale with a comment and label.", + }, addStaleLabelAndComment) + + t4, _ := functiontool.New(functiontool.Config{ + Name: "alert_maintainer_of_edit", + Description: "Post a comment alerting maintainers of a silent edit.", + }, alertMaintainerOfEdit) + + t5, _ := functiontool.New(functiontool.Config{ + Name: "close_as_stale", + Description: "Close the issue as completed/stale.", + }, closeAsStale) + + t6, _ := functiontool.New(functiontool.Config{ + Name: "get_issue_state", + Description: "Fetch and analyze the current state/history of the issue.", + }, getIssueState) + + return []tool.Tool{t1, t2, t3, t4, t5, t6} +} + +func formatPrompt(template string, values map[string]string) string { + result := template + for k, v := range values { + result = strings.ReplaceAll(result, "{"+k+"}", v) + } + return result +} + +func main() { + startTotalTime := time.Now() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + InitConfig() + + var err error + PROMPT_TEMPLATE, err = loadPromptTemplate("PROMPT_INSTRUCTION.txt") + if err != nil { + log.Fatalf("Failed to load PROMPT_INSTRUCTION.txt: %v", err) + } + + log.Println("PROMPT_TEMPLATE loaded successfully.") + log.Printf("--- Starting Stale Bot for %s/%s ---", Owner, Repo) + log.Printf("Concurrency level set to %d", ConcurrencyLimit) + + model, err := gemini.NewModel(ctx, geminiModel, &genai.ClientConfig{APIKey: os.Getenv("GOOGLE_API_KEY")}) + if err != nil { + log.Fatalf("Failed to create model: %v", err) + } + + instruction := formatPrompt(PROMPT_TEMPLATE, map[string]string{ + "OWNER": Owner, + "REPO": Repo, + "STALE_LABEL_NAME": StaleLabelName, + "REQUEST_CLARIFICATION_LABEL": RequestClarificationLabel, + "stale_threshold_days": fmt.Sprintf("%g", float64(STALE_HOURS_THRESHOLD)/24.0), + "close_threshold_days": fmt.Sprintf("%g", float64(CLOSE_HOURS_AFTER_STALE_THRESHOLD)/24.0), + }) + + toolList := setupTools() + rootAgent, err = llmagent.New(llmagent.Config{ + Name: "adk_repository_auditor_agent", + Description: "Audits open issues.", + Model: model, + Instruction: instruction, + Tools: toolList, + }) + if err != nil { + log.Fatalf("failed to create root agent: %v", err) + } + + ResetAPICallCount() + filterDays := STALE_HOURS_THRESHOLD / 24.0 + + allIssues, err := GetOldOpenIssueNumbers(Owner, Repo, &filterDays) + if err != nil { + log.Fatalf("Failed to fetch issue list: %v", err) + } + + totalCount := len(allIssues) + searchAPICalls := GetAPICallCount() + if totalCount == 0 { + log.Println("No issues matched the criteria. Run finished.") + return + } + + log.Printf("Found %d issues to process. (Initial search used %d API calls).", totalCount, searchAPICalls) + + var totalProcessingTime time.Duration + var totalIssueAPICalls int + processedCount := 0 + + for i := 0; i < totalCount; i += ConcurrencyLimit { + end := i + ConcurrencyLimit + if end > totalCount { + end = totalCount + } + chunk := allIssues[i:end] + currentChunkNum := (i / ConcurrencyLimit) + 1 + log.Printf("--- Starting chunk %d: Processing issues %v ---", currentChunkNum, chunk) + + var wg sync.WaitGroup + resultsChan := make(chan processSingleResult, len(chunk)) + + for _, issueNum := range chunk { + wg.Add(1) + go func(num int) { + defer wg.Done() + res := processSingleIssue(ctx, num) + resultsChan <- res + }(issueNum) + } + + wg.Wait() + close(resultsChan) + + for res := range resultsChan { + totalProcessingTime += res.duration + totalIssueAPICalls += res.apiCalls + } + + processedCount += len(chunk) + log.Printf("--- Finished chunk %d. Progress: %d/%d ---", currentChunkNum, processedCount, totalCount) + + if end < totalCount { + time.Sleep(time.Duration(SleepBetweenChunks * float64(time.Second))) + } + } + + totalAPICallsForRun := searchAPICalls + totalIssueAPICalls + avgTimePerIssue := 0.0 + if totalCount > 0 { + avgTimePerIssue = totalProcessingTime.Seconds() / float64(totalCount) + } + + log.Println("--- Stale Agent Run Finished ---") + log.Printf("Successfully processed %d issues.", processedCount) + log.Printf("Total API calls made this run: %d", totalAPICallsForRun) + log.Printf("Average processing time per issue: %.2f seconds.", avgTimePerIssue) + + duration := time.Since(startTotalTime) + log.Printf("Full audit finished in %.2f minutes.", duration.Minutes()) +} diff --git a/contributing/samples/stale-bot-agent/util.go b/contributing/samples/stale-bot-agent/util.go new file mode 100644 index 000000000..2fd69865a --- /dev/null +++ b/contributing/samples/stale-bot-agent/util.go @@ -0,0 +1,314 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "sync" + "time" +) + +// ---------------- Retry Configuration ---------------- + +var retryStatusCodes = map[int]bool{ + 429: true, + 500: true, + 502: true, + 503: true, + 504: true, +} + +const ( + maxRetries = 6 + backoffFactor = 2 +) + +// ---------------- API Call Counter ---------------- + +var ( + apiCallCount int + counterLock sync.Mutex +) + +func GetAPICallCount() int { + counterLock.Lock() + defer counterLock.Unlock() + return apiCallCount +} + +func ResetAPICallCount() { + counterLock.Lock() + defer counterLock.Unlock() + apiCallCount = 0 +} + +func incrementAPICallCount() { + counterLock.Lock() + apiCallCount++ + counterLock.Unlock() +} + +// ---------------- HTTP Client ---------------- + +var httpClient = &http.Client{ + Timeout: 60 * time.Second, +} + +// ---------------- Core HTTP Logic ---------------- + +func doRequest(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "token "+GitHubToken) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + var resp *http.Response + var err error + backoff := time.Second + + for attempt := 0; attempt <= maxRetries; attempt++ { + resp, err = httpClient.Do(req) + + if err == nil && !retryStatusCodes[resp.StatusCode] { + return resp, nil + } + + if resp != nil { + if cerr := resp.Body.Close(); cerr != nil { + log.Printf("error closing response body: %v", cerr) + } + } + + if attempt == maxRetries { + break + } + + time.Sleep(backoff) + backoff *= backoffFactor + } + + if err != nil { + return nil, err + } + + return nil, fmt.Errorf("request failed after retries") +} + +// ---------------- Public Request Helpers ---------------- + +func GetRequest(rawURL string, params map[string]any) (any, error) { + incrementAPICallCount() + + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + if params != nil { + q := u.Query() + for k, v := range params { + q.Set(k, fmt.Sprintf("%v", v)) + } + u.RawQuery = q.Encode() + } + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + + resp, err := doRequest(req) + if err != nil { + log.Printf("GET request failed for %s: %v", rawURL, err) + return nil, err + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + log.Printf("error closing response body: %v", cerr) + } + }() + + return decodeJSON(resp) +} + +func PostRequest(url string, payload any) (any, error) { + incrementAPICallCount() + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal POST request payload: %w", err) + } + req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + resp, err := doRequest(req) + if err != nil { + log.Printf("POST request failed for %s: %v", url, err) + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("error closing response body: %v", err) + } + }() + + return decodeJSON(resp) +} + +func PatchRequest(url string, payload any) (any, error) { + incrementAPICallCount() + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal PATCH request payload: %w", err) + } + req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + resp, err := doRequest(req) + if err != nil { + log.Printf("PATCH request failed for %s: %v", url, err) + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("error closing response body: %v", err) + } + }() + + return decodeJSON(resp) +} + +func DeleteRequest(url string) (any, error) { + incrementAPICallCount() + + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return nil, err + } + + resp, err := doRequest(req) + if err != nil { + log.Printf("DELETE request failed for %s: %v", url, err) + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("error closing response body: %v", err) + } + }() + + if resp.StatusCode == 204 { + return map[string]any{ + "status": "success", + "message": "Deletion successful.", + }, nil + } + + return decodeJSON(resp) +} + +// ---------------- JSON Helper ---------------- + +func decodeJSON(resp *http.Response) (any, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var data any + if err := json.Unmarshal(body, &data); err != nil { + return nil, err + } + + return data, nil +} + +// ---------------- Issue Search ---------------- + +func GetOldOpenIssueNumbers(owner, repo string, daysOld *float64) ([]int, error) { + days := STALE_HOURS_THRESHOLD / 24 + if daysOld != nil { + days = *daysOld + } + + cutoff := time.Now().UTC(). + Add(-time.Duration(days*24) * time.Hour). + Format("2006-01-02T15:04:05Z") + + query := fmt.Sprintf( + "repo:%s/%s is:issue state:open created:<%s", + owner, repo, cutoff, + ) + + log.Printf("SEARCH QUERY: %s", query) + log.Printf("Searching for issues created before %s...", cutoff) + + var issueNumbers []int + page := 1 + + for { + params := map[string]any{ + "q": query, + "per_page": 100, + "page": page, + } + + dataAny, err := GetRequest( + "https://api.github.com/search/issues", + params, + ) + if err != nil { + log.Printf("GitHub search failed on page %d: %v", page, err) + break + } + + data, ok := dataAny.(map[string]any) + if !ok { + log.Printf("Invalid API response format") + break + } + + items, ok := data["items"].([]any) + if !ok || len(items) == 0 { + break + } + + for _, item := range items { + m := item.(map[string]any) + if _, isPR := m["pull_request"]; !isPR { + if n, ok := m["number"].(float64); ok { + issueNumbers = append(issueNumbers, int(n)) + } + } + } + + if len(items) < 100 { + break + } + page++ + } + + log.Printf("Found %d stale issues.", len(issueNumbers)) + return issueNumbers, nil +} diff --git a/telemetry/telemetry_test.go b/telemetry/telemetry_test.go index cb2a38dc6..773814eb6 100644 --- a/telemetry/telemetry_test.go +++ b/telemetry/telemetry_test.go @@ -144,7 +144,11 @@ func TestTelemetryCustomProvider(t *testing.T) { } } -func extractResourceAttributes(res *resource.Resource) (projectID, serviceName, serviceVersion string) { +func extractResourceAttributes(res *resource.Resource) (string, string, string) { + var projectID string + var serviceName string + var serviceVersion string + for _, attr := range res.Attributes() { switch attr.Key { case "gcp.project_id": @@ -155,7 +159,8 @@ func extractResourceAttributes(res *resource.Resource) (projectID, serviceName, serviceVersion = attr.Value.AsString() } } - return + + return projectID, serviceName, serviceVersion } func TestResolveResourceProject(t *testing.T) {