diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml new file mode 100644 index 000000000..5f759f775 --- /dev/null +++ b/.github/workflows/stale-bot.yml @@ -0,0 +1,56 @@ + +name: ADK Stale Issue Auditor (Java) + +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-java' + + runs-on: ubuntu-latest + timeout-minutes: 60 + + permissions: + issues: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Build with Maven + run: mvn clean compile + + - name: Run Auditor Agent + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + STALE_HOURS_THRESHOLD: ${{ secrets.STALE_HOURS_THRESHOLD }} + CLOSE_HOURS_AFTER_STALE_THRESHOLD: ${{ secrets.CLOSE_HOURS_AFTER_STALE_THRESHOLD }} + + GRAPHQL_COMMENT_LIMIT: ${{ secrets.GRAPHQL_COMMENT_LIMIT }} + GRAPHQL_EDIT_LIMIT: ${{ secrets.GRAPHQL_EDIT_LIMIT }} + GRAPHQL_TIMELINE_LIMIT: ${{ secrets.GRAPHQL_TIMELINE_LIMIT }} + + SLEEP_BETWEEN_CHUNKS: ${{ secrets.SLEEP_BETWEEN_CHUNKS }} + + OWNER: ${{ github.repository_owner }} + REPO: adk-java + CONCURRENCY_LIMIT: 3 + LLM_MODEL_NAME: "gemini-2.5-flash" + + JAVA_TOOL_OPTIONS: "-Djava.util.logging.SimpleFormatter.format='%1$tF %1$tT %4$s %2$s %5$s%6$s%n'" + + run: mvn compile exec:java@run-stale-bot -pl :stale-agent diff --git a/contrib/samples/pom.xml b/contrib/samples/pom.xml index 580b10de9..2448f5645 100644 --- a/contrib/samples/pom.xml +++ b/contrib/samples/pom.xml @@ -1,24 +1,21 @@ - - + 4.0.0 - com.google.adk google-adk-parent - 0.5.1-SNAPSHOT + 0.5.1-SNAPSHOT + ../.. - google-adk-samples pom - Google ADK Samples Aggregator for sample applications. - a2a_basic configagent helloworld mcpfilesystem + stale-agent diff --git a/contrib/samples/stale-agent/pom.xml b/contrib/samples/stale-agent/pom.xml new file mode 100644 index 000000000..2fabafbf7 --- /dev/null +++ b/contrib/samples/stale-agent/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + com.google.adk + google-adk-samples + 0.5.1-SNAPSHOT + + com.google.adk + stale-agent + 0.5.1-SNAPSHOT + stale-agent + http://maven.apache.org + + 17 + 17 + 17 + UTF-8 + 0.5.0 + 2.0.9 + + + + com.google.adk + google-adk + ${adk.version} + + + + org.kohsuke + github-api + 1.318 + + + org.apache.httpcomponents.client5 + httpclient5 + 5.2.1 + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + org.slf4j + slf4j-simple + ${slf4j.version} + + + + com.fasterxml.jackson.core + jackson-databind + 2.16.1 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + run-stale-bot + + java + + + com.google.adk.samples.stale.StaleBotApp + + + + + + + diff --git a/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/StaleBotApp.java b/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/StaleBotApp.java new file mode 100644 index 000000000..1bee77d1c --- /dev/null +++ b/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/StaleBotApp.java @@ -0,0 +1,193 @@ +package com.google.adk.samples.stale; + +import com.google.adk.runner.InMemoryRunner; +import com.google.adk.samples.stale.agent.StaleAgent; +import com.google.adk.samples.stale.config.StaleBotSettings; +import com.google.adk.samples.stale.utils.GitHubUtils; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +public class StaleBotApp { + + private static final Logger logger = Logger.getLogger(StaleBotApp.class.getName()); + private static final String USER_ID = "stale_bot_user"; + + record IssueResult(long issueNumber, double durationSeconds, int apiCalls) {} + + public static void main(String[] args) { + + try { + runBot(); + } catch (Exception e) { + logger.log(Level.SEVERE, "Unexpected fatal error", e); + } + } + + public static void runBot() { + logger.info(" Starting Stale Bot for " + StaleBotSettings.OWNER + "/" + StaleBotSettings.REPO); + logger.info("Concurrency level set to " + StaleBotSettings.CONCURRENCY_LIMIT); + + GitHubUtils.resetApiCallCount(); + + double filterDays = StaleBotSettings.STALE_HOURS_THRESHOLD / 24.0; + logger.fine(String.format("Fetching issues older than %.2f days...", filterDays)); + + List allIssues; + try { + allIssues = + GitHubUtils.getOldOpenIssueNumbers( + StaleBotSettings.OWNER, StaleBotSettings.REPO, filterDays); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to fetch issue list", e); + return; + } + + int totalCount = allIssues.size(); + int searchApiCalls = GitHubUtils.getApiCallCount(); + + if (totalCount == 0) { + logger.info("No issues matched the criteria. Run finished."); + return; + } + + logger.info( + String.format( + "Found %d issues to process. (Initial search used %d API calls).", + totalCount, searchApiCalls)); + + double totalProcessingTime = 0.0; + int totalIssueApiCalls = 0; + int processedCount = 0; + + InMemoryRunner runner = new InMemoryRunner(StaleAgent.create()); + + for (int i = 0; i < totalCount; i += StaleBotSettings.CONCURRENCY_LIMIT) { + int end = Math.min(i + StaleBotSettings.CONCURRENCY_LIMIT, totalCount); + List chunk = allIssues.subList(i, end); + int currentChunkNum = (i / StaleBotSettings.CONCURRENCY_LIMIT) + 1; + + logger.info( + String.format("Starting chunk %d: Processing issues %s ", currentChunkNum, chunk)); + + // Create a list of Futures (Async Tasks) + List> futures = + chunk.stream().map(issueNum -> processSingleIssue(issueNum)).collect(Collectors.toList()); + + // Wait for all tasks in this chunk to complete + CompletableFuture allFutures = + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + + try { + allFutures.join(); + + // Aggregate results + for (CompletableFuture f : futures) { + IssueResult result = f.get(); + if (result != null) { + totalProcessingTime += result.durationSeconds(); + totalIssueApiCalls += result.apiCalls(); + } + } + } catch (Exception e) { + logger.log(Level.SEVERE, "Error gathering chunk results", e); + } + + processedCount += chunk.size(); + logger.info( + String.format( + "Finished chunk %d. Progress: %d/%d ", currentChunkNum, processedCount, totalCount)); + + // Sleep between chunks if not finished + if (end < totalCount) { + logger.fine( + "Sleeping for " + + StaleBotSettings.SLEEP_BETWEEN_CHUNKS + + "s to respect rate limits..."); + try { + Thread.sleep((long) (StaleBotSettings.SLEEP_BETWEEN_CHUNKS * 1000)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warning("Sleep interrupted."); + } + } + } + + int totalApiCallsForRun = searchApiCalls + totalIssueApiCalls; + double avgTimePerIssue = totalCount > 0 ? totalProcessingTime / totalCount : 0; + + logger.info("Successfully processed " + processedCount + " issues."); + logger.info("Total API calls made this run: " + totalApiCallsForRun); + logger.info(String.format("Average processing time per issue: %.2f seconds.", avgTimePerIssue)); + } + + private static CompletableFuture processSingleIssue(int issueNumber) { + return CompletableFuture.supplyAsync( + () -> { + long startNano = System.nanoTime(); + int startApiCalls = GitHubUtils.getApiCallCount(); + + logger.info("Processing Issue #" + issueNumber + "..."); + + InMemoryRunner localRunner = new InMemoryRunner(StaleAgent.create()); + + String sessionId = "session-" + issueNumber + "-" + UUID.randomUUID().toString(); + + try { + + localRunner + .sessionService() + .createSession(localRunner.appName(), USER_ID, null, sessionId) + .blockingGet(); + + logger.fine("Session created successfully: " + sessionId); + + String promptText = "Audit Issue #" + issueNumber + "."; + Content promptMessage = Content.fromParts(Part.fromText(promptText)); + StringBuilder fullResponse = new StringBuilder(); + + localRunner + .runAsync(USER_ID, sessionId, promptMessage) + .blockingSubscribe( + event -> { + try { + if (event.content() != null && event.content().isPresent()) { + event + .content() + .get() + .parts() + .get() + .forEach( + p -> { + p.text().ifPresent(text -> fullResponse.append(text)); + }); + } + } catch (Exception ignored) { + } + }, + error -> { + logger.severe( + "Stream failed for Issue #" + issueNumber + ": " + error.getMessage()); + }); + + String decision = fullResponse.toString().replace("\n", " "); + if (decision.length() > 150) decision = decision.substring(0, 150); + + logger.info("#" + issueNumber + " Decision: " + decision + "..."); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Error processing issue #" + issueNumber, e); + } + + double durationSeconds = (System.nanoTime() - startNano) / 1_000_000_000.0; + int issueApiCalls = Math.max(0, GitHubUtils.getApiCallCount() - startApiCalls); + + return new IssueResult(issueNumber, durationSeconds, issueApiCalls); + }); + } +} diff --git a/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/agent/StaleAgent.java b/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/agent/StaleAgent.java new file mode 100644 index 000000000..217cf6e03 --- /dev/null +++ b/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/agent/StaleAgent.java @@ -0,0 +1,507 @@ +package com.google.adk.samples.stale.agent; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.adk.agents.LlmAgent; +import com.google.adk.samples.stale.config.StaleBotSettings; +import com.google.adk.samples.stale.utils.GitHubUtils; +import com.google.adk.tools.Annotations.Schema; +import com.google.adk.tools.FunctionTool; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class StaleAgent { + private static final Logger logger = LoggerFactory.getLogger(StaleAgent.class); + + private static final String BOT_ALERT_SIGNATURE = + "**Notification:** The author has updated the issue description"; + private static final String BOT_NAME = "adk-bot"; + + private List maintainersCache = null; + + public static LlmAgent create() { + StaleAgent toolInstance = new StaleAgent(); + String prompt = toolInstance.loadAndFormatPrompt(); + + return LlmAgent.builder() + .name("adk_repository_auditor_agent") + .description("Audits open issues.") + .instruction(prompt) + .model(StaleBotSettings.LLM_MODEL_NAME) + .tools( + FunctionTool.create(toolInstance, "addLabelToIssue"), + FunctionTool.create(toolInstance, "addStaleLabelAndComment"), + FunctionTool.create(toolInstance, "alertMaintainerOfEdit"), + FunctionTool.create(toolInstance, "closeAsStale"), + FunctionTool.create(toolInstance, "getIssueState"), + FunctionTool.create(toolInstance, "removeLabelFromIssue")) + .build(); + } + + private String loadAndFormatPrompt() { + try (InputStream is = getClass().getResourceAsStream("/PROMPT_INSTRUCTION.txt")) { + if (is == null) throw new RuntimeException("PROMPT_INSTRUCTION.txt not found"); + + String template = new String(is.readAllBytes(), StandardCharsets.UTF_8); + + return template + .replace("{OWNER}", StaleBotSettings.OWNER) + .replace("{REPO}", StaleBotSettings.REPO) + .replace("{STALE_LABEL_NAME}", StaleBotSettings.STALE_LABEL_NAME) + .replace("{REQUEST_CLARIFICATION_LABEL}", StaleBotSettings.REQUEST_CLARIFICATION_LABEL) + .replace( + "{stale_threshold_days}", + String.valueOf(StaleBotSettings.STALE_HOURS_THRESHOLD / 24.0)) + .replace( + "{close_threshold_days}", + String.valueOf(StaleBotSettings.CLOSE_HOURS_AFTER_STALE_THRESHOLD / 24.0)); + + } catch (IOException e) { + throw new RuntimeException("Failed to load prompt template", e); + } + } + + private List getCachedMaintainers() { + if (maintainersCache != null) return maintainersCache; + + logger.info("Initializing Maintainers Cache..."); + try { + String url = + String.format( + "%s/repos/%s/%s/collaborators", + StaleBotSettings.GITHUB_BASE_URL, StaleBotSettings.OWNER, StaleBotSettings.REPO); + + JsonNode data = GitHubUtils.getRequest(url, Map.of("permission", "push")); + + if (data.isArray()) { + List list = new ArrayList<>(); + for (JsonNode u : data) { + if (u.has("login")) { + list.add(u.get("login").asText()); + } + } + maintainersCache = list; + logger.info("Cached {} maintainers.", list.size()); + return maintainersCache; + } else { + throw new IllegalArgumentException("GitHub API returned non-list data"); + } + } catch (Exception e) { + logger.error("FATAL: Failed to verify repository maintainers.", e); + throw new RuntimeException("Maintainer verification failed. processing aborted.", e); + } + } + + private JsonNode fetchGraphqlData(int itemNumber) { + String 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 } + } + } + } + } + } + } + """; + + Map variables = new HashMap<>(); + variables.put("owner", StaleBotSettings.OWNER); + variables.put("name", StaleBotSettings.REPO); + variables.put("number", itemNumber); + variables.put("commentLimit", StaleBotSettings.GRAPHQL_COMMENT_LIMIT); + variables.put("editLimit", StaleBotSettings.GRAPHQL_EDIT_LIMIT); + variables.put("timelineLimit", StaleBotSettings.GRAPHQL_TIMELINE_LIMIT); + + JsonNode response = + GitHubUtils.postRequest( + StaleBotSettings.GITHUB_BASE_URL + "/graphql", + Map.of("query", query, "variables", variables)); + + if (response.has("errors")) { + throw new RuntimeException( + "GraphQL Error: " + response.get("errors").get(0).get("message").asText()); + } + + JsonNode data = response.path("data").path("repository").path("issue"); + if (data.isMissingNode() || data.isNull()) { + throw new RuntimeException("Issue #" + itemNumber + " not found."); + } + return data; + } + + // Data structure to hold history analysis results + private record HistoryResult( + List> history, List labelEvents, Instant lastBotAlertTime) {} + + private HistoryResult buildHistoryTimeline(JsonNode data) { + String issueAuthor = data.path("author").path("login").asText(null); + List> history = new ArrayList<>(); + List labelEvents = new ArrayList<>(); + Instant lastBotAlertTime = null; + + Map createdEvent = new HashMap<>(); + createdEvent.put("type", "created"); + createdEvent.put("actor", issueAuthor); + createdEvent.put("time", Instant.parse(data.get("createdAt").asText())); + createdEvent.put("data", null); + history.add(createdEvent); + + for (JsonNode c : data.path("comments").path("nodes")) { + String actor = c.path("author").path("login").asText(null); + String body = c.path("body").asText(""); + Instant cTime = Instant.parse(c.get("createdAt").asText()); + + if (body.contains(BOT_ALERT_SIGNATURE)) { + if (lastBotAlertTime == null || cTime.isAfter(lastBotAlertTime)) { + lastBotAlertTime = cTime; + } + continue; + } + + if (actor != null && !actor.endsWith("[bot]") && !actor.equals(BOT_NAME)) { + Instant eTime = + c.hasNonNull("lastEditedAt") ? Instant.parse(c.get("lastEditedAt").asText()) : null; + Instant actualTime = (eTime != null) ? eTime : cTime; + + Map event = new HashMap<>(); + event.put("type", "commented"); + event.put("actor", actor); + event.put("time", actualTime); + event.put("data", body); + history.add(event); + } + } + + for (JsonNode e : data.path("userContentEdits").path("nodes")) { + String actor = e.path("editor").path("login").asText(null); + if (actor != null && !actor.endsWith("[bot]") && !actor.equals(BOT_NAME)) { + Map event = new HashMap<>(); + event.put("type", "edited_description"); + event.put("actor", actor); + event.put("time", Instant.parse(e.get("editedAt").asText())); + event.put("data", null); + history.add(event); + } + } + + for (JsonNode t : data.path("timelineItems").path("nodes")) { + String type = t.get("__typename").asText(); + String actor = t.path("actor").path("login").asText(null); + Instant timeVal = Instant.parse(t.get("createdAt").asText()); + + if ("LabeledEvent".equals(type)) { + String labelName = t.path("label").path("name").asText(); + if (StaleBotSettings.STALE_LABEL_NAME.equals(labelName)) { + labelEvents.add(timeVal); + } + continue; + } + + if (actor != null && !actor.endsWith("[bot]") && !actor.equals(BOT_NAME)) { + String prettyType = "RenamedTitleEvent".equals(type) ? "renamed_title" : "reopened"; + Map event = new HashMap<>(); + event.put("type", prettyType); + event.put("actor", actor); + event.put("time", timeVal); + event.put("data", null); + history.add(event); + } + } + + history.sort(Comparator.comparing(m -> (Instant) m.get("time"))); + return new HistoryResult(history, labelEvents, lastBotAlertTime); + } + + private Map replayHistoryToFindState( + List> history, List maintainers, String issueAuthor) { + String lastActionRole = "author"; + Instant lastActivityTime = (Instant) history.get(0).get("time"); + String lastActionType = "created"; + String lastCommentText = null; + String lastActorName = issueAuthor; + + for (Map event : history) { + String actor = (String) event.get("actor"); + String type = (String) event.get("type"); + + String role = "other_user"; + if (Objects.equals(actor, issueAuthor)) role = "author"; + else if (maintainers.contains(actor)) role = "maintainer"; + + lastActionRole = role; + lastActivityTime = (Instant) event.get("time"); + lastActionType = type; + lastActorName = actor; + + if ("commented".equals(type)) { + Object data = event.get("data"); + lastCommentText = data != null ? data.toString() : ""; + } else { + lastCommentText = ""; + } + } + + Map state = new HashMap<>(); + state.put("last_action_role", lastActionRole); + state.put("last_activity_time", lastActivityTime); + state.put("last_action_type", lastActionType); + state.put("last_comment_text", lastCommentText); + state.put("last_actor_name", lastActorName); + return state; + } + + @Schema(description = "Retrieves the comprehensive state of a GitHub issue using GraphQL.") + public Map getIssueState( + @Schema(description = "The GitHub issue number") int itemNumber) { + try { + List maintainers = getCachedMaintainers(); + + JsonNode rawData = fetchGraphqlData(itemNumber); + String issueAuthor = rawData.path("author").path("login").asText(null); + + List labelsList = new ArrayList<>(); + for (JsonNode l : rawData.path("labels").path("nodes")) { + labelsList.add(l.get("name").asText()); + } + + HistoryResult historyResult = buildHistoryTimeline(rawData); + + Map state = + replayHistoryToFindState(historyResult.history, maintainers, issueAuthor); + Instant lastActivityTime = (Instant) state.get("last_activity_time"); + + Instant currentTime = Instant.now(); + double daysSinceActivity = + Duration.between(lastActivityTime, currentTime).toSeconds() / 86400.0; + + boolean isStale = labelsList.contains(StaleBotSettings.STALE_LABEL_NAME); + double daysSinceStaleLabel = 0.0; + if (isStale && !historyResult.labelEvents.isEmpty()) { + Instant latestLabelTime = Collections.max(historyResult.labelEvents); + daysSinceStaleLabel = Duration.between(latestLabelTime, currentTime).toSeconds() / 86400.0; + } + + boolean maintainerAlertNeeded = false; + String lastRole = (String) state.get("last_action_role"); + String lastType = (String) state.get("last_action_type"); + + if (List.of("author", "other_user").contains(lastRole) + && "edited_description".equals(lastType)) { + if (historyResult.lastBotAlertTime != null + && historyResult.lastBotAlertTime.isAfter(lastActivityTime)) { + logger.info("#{}: Silent edit detected, but Bot already alerted.", itemNumber); + } else { + maintainerAlertNeeded = true; + logger.info("#{}: Silent edit detected. Alert needed.", itemNumber); + } + } + + logger.debug( + "#{} VERDICT: Role={}, Idle={}d", + itemNumber, + lastRole, + String.format("%.2f", daysSinceActivity)); + + Map result = new HashMap<>(); + result.put("status", "success"); + result.put("last_action_role", state.get("last_action_role")); + result.put("last_action_type", state.get("last_action_type")); + result.put("last_actor_name", state.get("last_actor_name")); + result.put("maintainer_alert_needed", maintainerAlertNeeded); + result.put("is_stale", isStale); + result.put("days_since_activity", daysSinceActivity); + result.put("days_since_stale_label", daysSinceStaleLabel); + result.put("last_comment_text", state.get("last_comment_text")); + result.put("current_labels", labelsList); + result.put("stale_threshold_days", StaleBotSettings.STALE_HOURS_THRESHOLD / 24.0); + result.put("close_threshold_days", StaleBotSettings.CLOSE_HOURS_AFTER_STALE_THRESHOLD / 24.0); + result.put("maintainers", maintainers); + result.put("issue_author", issueAuthor); + result.put("last_comment_text", state.get("last_comment_text")); + return result; + + } catch (Exception e) { + logger.error("Error analyzing issue #" + itemNumber, e); + return GitHubUtils.errorResponse("Analysis Error: " + e.getMessage()); + } + } + + private String formatDays(double hours) { + double days = hours / 24.0; + if (days % 1 == 0) return String.format("%.0f", days); + return String.format("%.1f", days); + } + + @Schema(description = "Adds a label to the issue.") + public Map addLabelToIssue( + @Schema(description = "The GitHub issue number") int itemNumber, + @Schema(description = "The name of the label") String labelName) { + + logger.debug("Adding label '{}' to issue #{}", labelName, itemNumber); + String url = + String.format( + "%s/repos/%s/%s/issues/%d/labels", + StaleBotSettings.GITHUB_BASE_URL, + StaleBotSettings.OWNER, + StaleBotSettings.REPO, + itemNumber); + try { + GitHubUtils.postRequest(url, List.of(labelName)); + return Map.of("status", "success"); + } catch (Exception e) { + return GitHubUtils.errorResponse("Error adding label: " + e.getMessage()); + } + } + + @Schema(description = "Removes a label from the issue.") + public Map removeLabelFromIssue( + @Schema(description = "The GitHub issue number") int itemNumber, + @Schema(description = "The name of the label") String labelName) { + + logger.debug("Removing label '{}' from issue #{}", labelName, itemNumber); + String url = + String.format( + "%s/repos/%s/%s/issues/%d/labels/%s", + StaleBotSettings.GITHUB_BASE_URL, + StaleBotSettings.OWNER, + StaleBotSettings.REPO, + itemNumber, + labelName); + try { + GitHubUtils.deleteRequest(url); + return Map.of("status", "success"); + } catch (Exception e) { + return GitHubUtils.errorResponse("Error removing label: " + e.getMessage()); + } + } + + @Schema(description = "Marks the issue as stale with a comment and label.") + public Map addStaleLabelAndComment( + @Schema(description = "The GitHub issue number") int itemNumber) { + String staleDays = formatDays(StaleBotSettings.STALE_HOURS_THRESHOLD); + String closeDays = formatDays(StaleBotSettings.CLOSE_HOURS_AFTER_STALE_THRESHOLD); + + String comment = + String.format( + "This issue has been automatically marked as stale because there is no recent activity for %s days " + + "after a maintainer requested clarification. It will be closed if no further activity occurs within %s days.", + staleDays, closeDays); + + try { + String commentUrl = + String.format( + "%s/repos/%s/%s/issues/%d/comments", + StaleBotSettings.GITHUB_BASE_URL, + StaleBotSettings.OWNER, + StaleBotSettings.REPO, + itemNumber); + GitHubUtils.postRequest(commentUrl, Map.of("body", comment)); + + String labelUrl = + String.format( + "%s/repos/%s/%s/issues/%d/labels", + StaleBotSettings.GITHUB_BASE_URL, + StaleBotSettings.OWNER, + StaleBotSettings.REPO, + itemNumber); + GitHubUtils.postRequest(labelUrl, List.of(StaleBotSettings.STALE_LABEL_NAME)); + logger.debug(" label url : '{}' and Comment url : '{}'", commentUrl, labelUrl); + + return Map.of("status", "success"); + } catch (Exception e) { + return GitHubUtils.errorResponse("Error marking issue as stale: " + e.getMessage()); + } + } + + @Schema(description = "Posts a comment alerting maintainers of a silent description update.") + public Map alertMaintainerOfEdit( + @Schema(description = "The GitHub issue number") int itemNumber) { + String comment = BOT_ALERT_SIGNATURE + ". Maintainers, please review."; + try { + String url = + String.format( + "%s/repos/%s/%s/issues/%d/comments", + StaleBotSettings.GITHUB_BASE_URL, + StaleBotSettings.OWNER, + StaleBotSettings.REPO, + itemNumber); + GitHubUtils.postRequest(url, Map.of("body", comment)); + return Map.of("status", "success"); + } catch (Exception e) { + return GitHubUtils.errorResponse("Error posting alert: " + e.getMessage()); + } + } + + @Schema(description = "Closes the issue as not planned/stale.") + public Map closeAsStale( + @Schema(description = "The GitHub issue number") int itemNumber) { + String daysStr = formatDays(StaleBotSettings.CLOSE_HOURS_AFTER_STALE_THRESHOLD); + String comment = + String.format( + "This has been automatically closed because it has been marked as stale for over %s days.", + daysStr); + + try { + String commentUrl = + String.format( + "%s/repos/%s/%s/issues/%d/comments", + StaleBotSettings.GITHUB_BASE_URL, + StaleBotSettings.OWNER, + StaleBotSettings.REPO, + itemNumber); + GitHubUtils.postRequest(commentUrl, Map.of("body", comment)); + + String patchUrl = + String.format( + "%s/repos/%s/%s/issues/%d", + StaleBotSettings.GITHUB_BASE_URL, + StaleBotSettings.OWNER, + StaleBotSettings.REPO, + itemNumber); + GitHubUtils.patchRequest(patchUrl, Map.of("state", "closed")); + + return Map.of("status", "success"); + } catch (Exception e) { + return GitHubUtils.errorResponse("Error closing issue: " + e.getMessage()); + } + } +} diff --git a/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/config/StaleBotSettings.java b/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/config/StaleBotSettings.java new file mode 100644 index 000000000..ec7ac1ca3 --- /dev/null +++ b/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/config/StaleBotSettings.java @@ -0,0 +1,57 @@ +package com.google.adk.samples.stale.config; + +public final class StaleBotSettings { + + private StaleBotSettings() { + throw new UnsupportedOperationException("Configuration class cannot be instantiated"); + } + + // --- GitHub API Configuration --- + + public static final String GITHUB_BASE_URL = "https://api.github.com"; + + // Critical: Fail fast if token is missing + public static final String GITHUB_TOKEN = getEnv("GITHUB_TOKEN"); + + public static final String OWNER = getEnv("OWNER"); + public static final String REPO = getEnv("REPO"); + public static final String LLM_MODEL_NAME = getEnv("LLM_MODEL_NAME"); + + public static final String STALE_LABEL_NAME = "stale"; + public static final String REQUEST_CLARIFICATION_LABEL = "waiting on reporter"; + + // --- THRESHOLDS IN HOURS --- + + public static final double STALE_HOURS_THRESHOLD = getDoubleEnv("STALE_HOURS_THRESHOLD"); + + public static final double CLOSE_HOURS_AFTER_STALE_THRESHOLD = + getDoubleEnv("CLOSE_HOURS_AFTER_STALE_THRESHOLD"); + + // --- Performance Configuration --- + + public static final int CONCURRENCY_LIMIT = getIntEnv("CONCURRENCY_LIMIT"); + + // --- GraphQL Query Limits --- + + public static final int GRAPHQL_COMMENT_LIMIT = getIntEnv("GRAPHQL_COMMENT_LIMIT"); + + public static final int GRAPHQL_EDIT_LIMIT = getIntEnv("GRAPHQL_EDIT_LIMIT"); + + public static final int GRAPHQL_TIMELINE_LIMIT = getIntEnv("GRAPHQL_TIMELINE_LIMIT"); + + // --- Rate Limiting --- + + public static final double SLEEP_BETWEEN_CHUNKS = getDoubleEnv("SLEEP_BETWEEN_CHUNKS"); + + private static String getEnv(String key) { + return System.getenv(key); + } + + private static int getIntEnv(String key) { + return Integer.parseInt(getEnv(key)); + } + + private static double getDoubleEnv(String key) { + return Double.parseDouble(getEnv(key)); + } +} diff --git a/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/utils/GitHubUtils.java b/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/utils/GitHubUtils.java new file mode 100644 index 000000000..f7cbd46c2 --- /dev/null +++ b/contrib/samples/stale-agent/src/main/java/com/google/adk/samples/stale/utils/GitHubUtils.java @@ -0,0 +1,236 @@ +package com.google.adk.samples.stale.utils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.adk.samples.stale.config.StaleBotSettings; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.hc.client5.http.classic.methods.*; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class GitHubUtils { + + private static final Logger logger = LoggerFactory.getLogger(GitHubUtils.class); + private static final ObjectMapper mapper = new ObjectMapper(); + + private static final AtomicInteger apiCallCount = new AtomicInteger(0); + + public static int getApiCallCount() { + return apiCallCount.get(); + } + + public static void resetApiCallCount() { + apiCallCount.set(0); + } + + private static void incrementApiCallCount() { + apiCallCount.incrementAndGet(); + } + + private static final CloseableHttpClient httpClient; + + static { + DefaultHttpRequestRetryStrategy retryStrategy = + new DefaultHttpRequestRetryStrategy(6, TimeValue.ofSeconds(2L)); + + RequestConfig requestConfig = + RequestConfig.custom() + .setResponseTimeout(Timeout.of(60, TimeUnit.SECONDS)) + .setConnectTimeout(Timeout.of(60, TimeUnit.SECONDS)) + .build(); + + httpClient = + HttpClients.custom() + .setRetryStrategy(retryStrategy) + .setDefaultRequestConfig(requestConfig) + .build(); + } + + public static JsonNode getRequest(String url, Map params) { + incrementApiCallCount(); + try { + String fullUrl = buildUrlWithParams(url, params); + HttpGet request = new HttpGet(fullUrl); + addCommonHeaders(request); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + checkStatus(response, url, "GET"); + return mapper.readTree(response.getEntity().getContent()); + } + } catch (IOException e) { + logger.error("GET request failed for {}: {}", url, e.getMessage()); + throw new RuntimeException(e); + } + } + + public static JsonNode postRequest(String url, Object payload) { + incrementApiCallCount(); + try { + HttpPost request = new HttpPost(url); + addCommonHeaders(request); + request.setEntity( + new StringEntity(mapper.writeValueAsString(payload), ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + checkStatus(response, url, "POST"); + return mapper.readTree(response.getEntity().getContent()); + } + } catch (IOException e) { + logger.error("POST request failed for {}: {}", url, e.getMessage()); + throw new RuntimeException(e); + } + } + + public static JsonNode patchRequest(String url, Object payload) { + incrementApiCallCount(); + try { + HttpPatch request = new HttpPatch(url); + addCommonHeaders(request); + request.setEntity( + new StringEntity(mapper.writeValueAsString(payload), ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + checkStatus(response, url, "PATCH"); + return mapper.readTree(response.getEntity().getContent()); + } + } catch (IOException e) { + logger.error("PATCH request failed for {}: {}", url, e.getMessage()); + throw new RuntimeException(e); + } + } + + public static JsonNode deleteRequest(String url) { + incrementApiCallCount(); + try { + HttpDelete request = new HttpDelete(url); + addCommonHeaders(request); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + if (response.getCode() == 204) { + ObjectNode success = mapper.createObjectNode(); + success.put("status", "success"); + success.put("message", "Deletion successful."); + return success; + } + checkStatus(response, url, "DELETE"); + return mapper.readTree(response.getEntity().getContent()); + } + } catch (IOException e) { + logger.error("DELETE request failed for {}: {}", url, e.getMessage()); + throw new RuntimeException(e); + } + } + + public static Map errorResponse(String errorMessage) { + return Map.of("status", "error", "message", errorMessage); + } + + public static List getOldOpenIssueNumbers(String owner, String repo, Double daysOld) { + if (daysOld == null) { + daysOld = StaleBotSettings.STALE_HOURS_THRESHOLD / 24.0; + } + + Instant nowUtc = Instant.now(); + Instant cutoffDt = nowUtc.minus((long) (daysOld * 24 * 60), ChronoUnit.MINUTES); + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneId.of("UTC")); + String cutoffStr = formatter.format(cutoffDt); + + String query = + String.format("repo:%s/%s is:issue state:open created:<%s", owner, repo, cutoffStr); + + logger.info("Searching for issues in '{}/{}' created before {}...", owner, repo, cutoffStr); + + List issueNumbers = new ArrayList<>(); + int page = 1; + String searchUrl = "https://api.github.com/search/issues"; + + while (true) { + try { + Map params = new HashMap<>(); + params.put("q", query); + params.put("per_page", 100); + params.put("page", page); + + JsonNode data = getRequest(searchUrl, params); + JsonNode items = data.get("items"); + + if (items == null || items.isEmpty()) { + break; + } + + if (items.isArray()) { + for (JsonNode item : items) { + if (!item.has("pull_request")) { + issueNumbers.add(item.get("number").asInt()); + } + } + } + + if (items.size() < 100) { + break; + } + + page++; + + } catch (Exception e) { + logger.error("GitHub search failed on page {}: {}", page, e.getMessage()); + break; + } + } + + logger.info("Found {} stale issues.", issueNumbers.size()); + return issueNumbers; + } + + private static void addCommonHeaders(HttpUriRequestBase request) { + request.addHeader("Authorization", "token " + StaleBotSettings.GITHUB_TOKEN); + request.addHeader("Accept", "application/vnd.github.v3+json"); + } + + private static void checkStatus(CloseableHttpResponse response, String url, String method) + throws IOException { + int code = response.getCode(); + if (code >= 400) { + throw new IOException( + String.format("%s request to %s failed with status %d", method, url, code)); + } + } + + private static String buildUrlWithParams(String url, Map params) { + if (params == null || params.isEmpty()) { + return url; + } + StringBuilder sb = new StringBuilder(url); + sb.append("?"); + for (Map.Entry entry : params.entrySet()) { + if (sb.charAt(sb.length() - 1) != '?') { + sb.append("&"); + } + sb.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)); + sb.append("="); + sb.append(URLEncoder.encode(String.valueOf(entry.getValue()), StandardCharsets.UTF_8)); + } + return sb.toString(); + } +} diff --git a/contrib/samples/stale-agent/src/main/resources/PROMPT_INSTRUCTION.txt b/contrib/samples/stale-agent/src/main/resources/PROMPT_INSTRUCTION.txt new file mode 100644 index 000000000..c56c76a8b --- /dev/null +++ b/contrib/samples/stale-agent/src/main/resources/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