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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/triage-adk-java-issues.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Triages newly-opened (and, on a schedule, untriaged) adk-java issues with the
# ADK Issue Triaging Agent sample under contrib/samples/github/adktriaging.
#
# Required repository secrets:
# - GOOGLE_API_KEY : Gemini API key (or wire up Vertex AI credentials and
# set GOOGLE_GENAI_USE_VERTEXAI=TRUE).
# Labeling/assignment uses the built-in GITHUB_TOKEN (no secret to manage); the
# `permissions:` block below grants it the `issues: write` scope it needs. Swap
# in a PAT only if you specifically want triage actions attributed to a distinct
# bot identity.
name: ADK Issue Triaging Agent

on:
issues:
types: [opened]
schedule:
# Run every 6 hours to triage untriaged issues.
- cron: '0 */6 * * *'
workflow_dispatch:

# Serialize runs that touch the same issue so the scheduled batch sweep can't race
# a per-issue run on that issue (which, with label appends, could duplicate labels).
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
cancel-in-progress: false

jobs:
agent-triage-issues:
runs-on: ubuntu-latest
# Only run on the upstream repo, for newly-opened issues, the scheduled
# batch sweep, or a manual dispatch.
if: >-
github.repository == 'google/adk-java' && (
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
github.event.action == 'opened'
)
permissions:
issues: write
contents: read

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven

- name: Run Triaging Agent
env:
# Built-in token scoped by the `permissions:` block above. Replace with a
# PAT (e.g. ${{ secrets.ADK_TRIAGE_AGENT }}) only if you need a distinct
# bot identity for the label/assignment actions.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GOOGLE_GENAI_USE_VERTEXAI: 'FALSE'
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.event.repository.name }}
INTERACTIVE: '0'
# Defaults to a dry run (logs intended labels/assignees without writing).
# Verify the pipeline, then set DRY_RUN to '0' to go live.
DRY_RUN: '1'
EVENT_NAME: ${{ github.event_name }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
# Number of issues to process per scheduled batch run.
ISSUE_COUNT_TO_PROCESS: '3'
# Comma-separated GitHub handles to round-robin assign issues to.
# Owner assignment is skipped while this is empty. Store the real
# handles in a repo secret/variable rather than committing them.
GTECH_ASSIGNEES: ${{ vars.GTECH_ASSIGNEES }}
run: |
./mvnw -B -q \
-pl contrib/samples/github/adktriaging -am \
compile exec:java
227 changes: 223 additions & 4 deletions contrib/samples/github/GitHubTools.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,24 @@
import org.kohsuke.github.GHPullRequest;
import org.kohsuke.github.GHRelease;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GHUser;
import org.kohsuke.github.GitHub;
import org.kohsuke.github.GitHubBuilder;

/**
* Reusable GitHub function tools backed by the {@code org.kohsuke:github-api} client. Each returns
* a {@code Map} with a {@code "status"} of {@code "success"} or {@code "error"}. Reads {@code
* GITHUB_TOKEN} from the environment; callers set {@link #dryRun} to gate writes.
* a {@code Map} with a {@code "status"} of {@code "success"}, {@code "error"} or {@code "dry_run"}.
* Reads {@code GITHUB_TOKEN} from the environment; callers set {@link #dryRun} to gate writes.
*
* <p>Defense in depth against prompt injection: the agent reads untrusted GitHub content (diffs,
* <p>The tools cover the operations needed by the ADK GitHub automation samples: reading releases,
* diffs and file contents; searching code; listing and reading issues; creating issues and pull
* requests; and labelling/assigning issues.
*
* <p>Defense in depth against prompt injection: the agents read untrusted GitHub content (diffs,
* file contents, issue/PR titles) and could be steered into harmful writes. Independently of the
* prompt, the write tools (a) only target {@link #writeRepoOwner}/{@link #writeRepoName} when set,
* (b) only modify Markdown files under {@code docs/}, and (c) are capped per run.
* (b) restrict pull requests to Markdown files under {@code docs/}, and (c) cap issue/PR creation
* per run.
*/
public final class GitHubTools {

Expand All @@ -63,6 +69,7 @@ public final class GitHubTools {
public static String writeRepoName = null;

private static final int MAX_SEARCH_RESULTS = 50;
private static final int MAX_ISSUES_LISTED = 100;
private static final String DOCS_UPDATES_LABEL = "docs updates";
private static final String STATUS_KEY = "status";
private static final String STATUS_SUCCESS = "success";
Expand Down Expand Up @@ -427,6 +434,204 @@ public static Map<String, Object> createPullRequest(
}
}

@Schema(
name = "list_open_issues",
description =
"Lists OPEN issues (excluding pull requests) for a repository. Each entry has the issue's"
+ " number, title, body, html_url, labels and assignees.")
public static Map<String, Object> listOpenIssues(
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
@Schema(name = "repo_name", description = "The repository name.") String repoName,
@Schema(
name = "max_results",
description = "Maximum number of issues to return (capped at 100).",
optional = true)
Integer maxResults) {
int limit =
(maxResults == null || maxResults <= 0)
? MAX_ISSUES_LISTED
: Math.min(maxResults, MAX_ISSUES_LISTED);
try {
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
List<Map<String, Object>> issues = new ArrayList<>();
for (GHIssue issue : repo.getIssues(GHIssueState.OPEN)) {
if (issue.isPullRequest()) {
continue;
}
issues.add(formatIssue(issue));
if (issues.size() >= limit) {
break;
}
}
return success("issues", issues);
} catch (IOException | GHException e) {
return error("Failed to list issues: " + e.getMessage());
}
}

@Schema(
name = "get_issue",
description =
"Fetches a single OPEN or closed issue by number, returning its number, title, body,"
+ " html_url, labels and assignees.")
public static Map<String, Object> getIssue(
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
@Schema(name = "repo_name", description = "The repository name.") String repoName,
@Schema(name = "issue_number", description = "The issue number to fetch.") int issueNumber) {
try {
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
GHIssue issue = repo.getIssue(issueNumber);
if (issue.isPullRequest()) {
return error("#" + issueNumber + " is a pull request, not an issue.");
}
return success("issue", formatIssue(issue));
} catch (GHFileNotFoundException e) {
return error("Issue #" + issueNumber + " was not found.");
} catch (IOException | GHException e) {
return error("Failed to get issue #" + issueNumber + ": " + e.getMessage());
}
}

@Schema(
name = "add_label_to_issue",
description = "Adds a single label to an issue, preserving any labels already present.")
public static Map<String, Object> addLabelToIssue(
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
@Schema(name = "repo_name", description = "The repository name.") String repoName,
@Schema(name = "issue_number", description = "The issue number to label.") int issueNumber,
@Schema(name = "label", description = "The label to add.") String label) {
String targetError = writeTargetError(repoOwner, repoName);
if (targetError != null) {
return error(targetError);
}
if (dryRun) {
return dryRunPreview(
"DRY RUN: no label was added. Set DRY_RUN=0 to label issues for real.",
"issue_number",
issueNumber,
"label",
label);
}
try {
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
repo.getIssue(issueNumber).addLabels(label);
Map<String, Object> result = new LinkedHashMap<>();
result.put("issue_number", issueNumber);
result.put("added_label", label);
return success(result);
} catch (IOException | GHException e) {
return error(
"Failed to add label '" + label + "' to issue #" + issueNumber + ": " + e.getMessage());
}
}

@Schema(
name = "remove_label_from_issue",
description =
"Removes a single label from an issue. Succeeds as a no-op if the label is not present.")
public static Map<String, Object> removeLabelFromIssue(
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
@Schema(name = "repo_name", description = "The repository name.") String repoName,
@Schema(name = "issue_number", description = "The issue number to unlabel.") int issueNumber,
@Schema(name = "label", description = "The label to remove.") String label) {
String targetError = writeTargetError(repoOwner, repoName);
if (targetError != null) {
return error(targetError);
}
if (dryRun) {
return dryRunPreview(
"DRY RUN: no label was removed. Set DRY_RUN=0 to modify issues for real.",
"issue_number",
issueNumber,
"label",
label);
}
try {
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
repo.getIssue(issueNumber).removeLabel(label);
Map<String, Object> result = new LinkedHashMap<>();
result.put("issue_number", issueNumber);
result.put("removed_label", label);
return success(result);
} catch (GHFileNotFoundException e) {
// The label (or label-on-issue) was not present; removing it is a no-op success.
Map<String, Object> result = new LinkedHashMap<>();
result.put("issue_number", issueNumber);
result.put("removed_label", label);
result.put("note", "label was not present");
return success(result);
} catch (IOException | GHException e) {
return error(
"Failed to remove label '"
+ label
+ "' from issue #"
+ issueNumber
+ ": "
+ e.getMessage());
}
}

@Schema(
name = "assign_issue",
description = "Adds one or more assignees (by GitHub handle) to an issue.")
public static Map<String, Object> assignIssue(
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
@Schema(name = "repo_name", description = "The repository name.") String repoName,
@Schema(name = "issue_number", description = "The issue number to assign.") int issueNumber,
@Schema(name = "assignees", description = "GitHub handles to assign.")
List<String> assignees) {
if (assignees == null || assignees.isEmpty()) {
return error("assignees must be non-empty.");
}
String targetError = writeTargetError(repoOwner, repoName);
if (targetError != null) {
return error(targetError);
}
if (dryRun) {
return dryRunPreview(
"DRY RUN: no assignee was added. Set DRY_RUN=0 to assign issues for real.",
"issue_number",
issueNumber,
"assignees",
assignees);
}
try {
GitHub github = connect();
GHRepository repo = github.getRepository(repoOwner + "/" + repoName);
List<GHUser> users = new ArrayList<>();
for (String assignee : assignees) {
users.add(github.getUser(assignee));
}
repo.getIssue(issueNumber).addAssignees(users);
Map<String, Object> result = new LinkedHashMap<>();
result.put("issue_number", issueNumber);
result.put("assignees", assignees);
return success(result);
} catch (IOException | GHException e) {
return error("Failed to assign issue #" + issueNumber + ": " + e.getMessage());
}
}

/** Formats an issue into the compact map (number, title, body, html_url, labels, assignees). */
private static Map<String, Object> formatIssue(GHIssue issue) {
Map<String, Object> info = new LinkedHashMap<>();
info.put("number", issue.getNumber());
info.put("title", issue.getTitle());
info.put("body", issue.getBody() == null ? "" : issue.getBody());
info.put("html_url", issue.getHtmlUrl() == null ? "" : issue.getHtmlUrl().toString());
List<String> labels = new ArrayList<>();
for (GHLabel label : issue.getLabels()) {
labels.add(label.getName());
}
info.put("labels", labels);
List<String> assignees = new ArrayList<>();
for (GHUser user : issue.getAssignees()) {
assignees.add(user.getLogin());
}
info.put("assignees", assignees);
return info;
}

private static boolean hasDocsLabel(GHIssue issue) {
for (GHLabel label : issue.getLabels()) {
if (label.getName().equals(DOCS_UPDATES_LABEL)) {
Expand Down Expand Up @@ -515,4 +720,18 @@ private static Map<String, Object> error(String message) {
response.put("error_message", message);
return response;
}

/**
* Builds a {@code dry_run} preview envelope from {@code message} and an even number of key/value
* pairs describing the write that would have happened.
*/
private static Map<String, Object> dryRunPreview(String message, Object... keyValuePairs) {
Map<String, Object> preview = new LinkedHashMap<>();
preview.put(STATUS_KEY, STATUS_DRY_RUN);
preview.put("message", message);
for (int i = 0; i + 1 < keyValuePairs.length; i += 2) {
preview.put(String.valueOf(keyValuePairs[i]), keyValuePairs[i + 1]);
}
return preview;
}
}
31 changes: 31 additions & 0 deletions contrib/samples/github/adkreleasedocs/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@
<target>${java.version}</target>
<parameters>true</parameters>
</configuration>
<executions>
<!-- The shared github/ directory is on the source path (see build-helper
below), so restrict the main compile to this sample's files plus the
shared GitHubTools.java; otherwise sibling samples under github/ would
also be compiled into this module. -->
<execution>
<id>default-compile</id>
<configuration>
<includes>
<include>GitHubTools.java</include>
<include>adkreleasedocs/*.java</include>
</includes>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
Expand All @@ -104,12 +119,28 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<configuration>
<!-- The github/ source root recursively includes sibling samples; keep them
out of this module's -Prelease sources jar. -->
<excludes>
<exclude>**/*.jar</exclude>
<exclude>adktriaging/**</exclude>
<exclude>target/**</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<configuration>
<!-- Mirror the source excludes: the github/ source root pulls in sibling
samples (with their own, possibly test-scoped, dependencies) that are not
on this module's Javadoc classpath, so the -Prelease attach-javadocs goal
would otherwise fail. -->
<sourceFileExcludes>
<sourceFileExclude>adktriaging/**</sourceFileExclude>
</sourceFileExcludes>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
Expand Down
Loading
Loading