Skip to content

Commit fa94438

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: Add ADK Java Issue Triaging Agent sample
PiperOrigin-RevId: 935997601
1 parent 3737885 commit fa94438

12 files changed

Lines changed: 2417 additions & 4 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Triages newly-opened (and, on a schedule, untriaged) adk-java issues with the
2+
# ADK Issue Triaging Agent sample under contrib/samples/github/adktriaging.
3+
#
4+
# Required repository secrets:
5+
# - GOOGLE_API_KEY : Gemini API key (or wire up Vertex AI credentials and
6+
# set GOOGLE_GENAI_USE_VERTEXAI=TRUE).
7+
# Labeling/assignment uses the built-in GITHUB_TOKEN (no secret to manage); the
8+
# `permissions:` block below grants it the `issues: write` scope it needs. Swap
9+
# in a PAT only if you specifically want triage actions attributed to a distinct
10+
# bot identity.
11+
name: ADK Issue Triaging Agent
12+
13+
on:
14+
issues:
15+
types: [opened]
16+
schedule:
17+
# Run every 6 hours to triage untriaged issues.
18+
- cron: '0 */6 * * *'
19+
workflow_dispatch:
20+
21+
# Serialize runs that touch the same issue so the scheduled batch sweep can't race
22+
# a per-issue run on that issue (which, with label appends, could duplicate labels).
23+
concurrency:
24+
group: ${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
25+
cancel-in-progress: false
26+
27+
jobs:
28+
agent-triage-issues:
29+
runs-on: ubuntu-latest
30+
# Only run on the upstream repo, for newly-opened issues, the scheduled
31+
# batch sweep, or a manual dispatch.
32+
if: >-
33+
github.repository == 'google/adk-java' && (
34+
github.event_name == 'schedule' ||
35+
github.event_name == 'workflow_dispatch' ||
36+
github.event.action == 'opened'
37+
)
38+
permissions:
39+
issues: write
40+
contents: read
41+
42+
steps:
43+
- name: Checkout repository
44+
uses: actions/checkout@v6
45+
46+
- name: Set up Java
47+
uses: actions/setup-java@v5
48+
with:
49+
distribution: temurin
50+
java-version: '17'
51+
cache: maven
52+
53+
- name: Run Triaging Agent
54+
env:
55+
# Built-in token scoped by the `permissions:` block above. Replace with a
56+
# PAT (e.g. ${{ secrets.ADK_TRIAGE_AGENT }}) only if you need a distinct
57+
# bot identity for the label/assignment actions.
58+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
59+
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
60+
GOOGLE_GENAI_USE_VERTEXAI: '0'
61+
OWNER: ${{ github.repository_owner }}
62+
REPO: ${{ github.event.repository.name }}
63+
INTERACTIVE: '0'
64+
# Defaults to a dry run (logs intended labels/assignees without writing).
65+
# Verify the pipeline, then set DRY_RUN to '0' to go live.
66+
DRY_RUN: '1'
67+
EVENT_NAME: ${{ github.event_name }}
68+
ISSUE_NUMBER: ${{ github.event.issue.number }}
69+
ISSUE_TITLE: ${{ github.event.issue.title }}
70+
ISSUE_BODY: ${{ github.event.issue.body }}
71+
# Number of issues to process per scheduled batch run.
72+
ISSUE_COUNT_TO_PROCESS: '3'
73+
# Comma-separated GitHub handles to round-robin assign issues to.
74+
# Owner assignment is skipped while this is empty. Store the real
75+
# handles in a repo secret/variable rather than committing them.
76+
GTECH_ASSIGNEES: ${{ vars.GTECH_ASSIGNEES }}
77+
run: |
78+
# Install the ADK libs + this sample, then run exec:java scoped to this
79+
# module (exec:java with -am would also run on the parent/core modules,
80+
# which have no mainClass).
81+
./mvnw -B -q -pl contrib/samples/github/adktriaging -am install -DskipTests
82+
./mvnw -B -q -pl contrib/samples/github/adktriaging exec:java

contrib/samples/github/GitHubTools.java

Lines changed: 227 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,28 @@
3232
import org.kohsuke.github.GHPullRequest;
3333
import org.kohsuke.github.GHRelease;
3434
import org.kohsuke.github.GHRepository;
35+
import org.kohsuke.github.GHUser;
3536
import org.kohsuke.github.GitHub;
3637
import org.kohsuke.github.GitHubBuilder;
3738

3839
/**
3940
* Reusable GitHub function tools backed by the {@code org.kohsuke:github-api} client. Each returns
40-
* a {@code Map} with a {@code "status"} of {@code "success"} or {@code "error"}. Reads {@code
41-
* GITHUB_TOKEN} from the environment; callers set {@link #dryRun} to gate writes.
41+
* a {@code Map} with a {@code "status"} of {@code "success"}, {@code "error"} or {@code "dry_run"}.
42+
* Reads {@code GITHUB_TOKEN} from the environment; callers set {@link #dryRun} to gate writes.
4243
*
43-
* <p>Defense in depth against prompt injection: the agent reads untrusted GitHub content (diffs,
44+
* <p>The tools cover the operations needed by the ADK GitHub automation samples: reading releases,
45+
* diffs and file contents; searching code; listing and reading issues; creating issues and pull
46+
* requests; and labelling/assigning issues.
47+
*
48+
* <p>Defense in depth against prompt injection: the agents read untrusted GitHub content (diffs,
4449
* file contents, issue/PR titles) and could be steered into harmful writes. Independently of the
4550
* prompt, the write tools (a) only target {@link #writeRepoOwner}/{@link #writeRepoName} when set,
46-
* (b) only modify Markdown files under {@code docs/}, and (c) are capped per run.
51+
* (b) restrict pull requests to Markdown files under {@code docs/}, and (c) cap how many issues and
52+
* pull requests a single run may <em>create</em>. The labelling/assignment tools are not separately
53+
* capped: unlike issue/PR creation they do not create new objects (so they carry no unbounded-spam
54+
* risk) and only mutate pre-existing issues in the pinned target repository from (a); the consuming
55+
* triaging agent additionally binds them to a fixed label allowlist and the specific issue numbers
56+
* the workflow authorized.
4757
*/
4858
public final class GitHubTools {
4959

@@ -63,6 +73,7 @@ public final class GitHubTools {
6373
public static String writeRepoName = null;
6474

6575
private static final int MAX_SEARCH_RESULTS = 50;
76+
private static final int MAX_ISSUES_LISTED = 100;
6677
private static final String DOCS_UPDATES_LABEL = "docs updates";
6778
private static final String STATUS_KEY = "status";
6879
private static final String STATUS_SUCCESS = "success";
@@ -427,6 +438,204 @@ public static Map<String, Object> createPullRequest(
427438
}
428439
}
429440

441+
@Schema(
442+
name = "list_open_issues",
443+
description =
444+
"Lists OPEN issues (excluding pull requests) for a repository. Each entry has the issue's"
445+
+ " number, title, body, html_url, labels and assignees.")
446+
public static Map<String, Object> listOpenIssues(
447+
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
448+
@Schema(name = "repo_name", description = "The repository name.") String repoName,
449+
@Schema(
450+
name = "max_results",
451+
description = "Maximum number of issues to return (capped at 100).",
452+
optional = true)
453+
Integer maxResults) {
454+
int limit =
455+
(maxResults == null || maxResults <= 0)
456+
? MAX_ISSUES_LISTED
457+
: Math.min(maxResults, MAX_ISSUES_LISTED);
458+
try {
459+
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
460+
List<Map<String, Object>> issues = new ArrayList<>();
461+
for (GHIssue issue : repo.getIssues(GHIssueState.OPEN)) {
462+
if (issue.isPullRequest()) {
463+
continue;
464+
}
465+
issues.add(formatIssue(issue));
466+
if (issues.size() >= limit) {
467+
break;
468+
}
469+
}
470+
return success("issues", issues);
471+
} catch (IOException | GHException e) {
472+
return error("Failed to list issues: " + e.getMessage());
473+
}
474+
}
475+
476+
@Schema(
477+
name = "get_issue",
478+
description =
479+
"Fetches a single OPEN or closed issue by number, returning its number, title, body,"
480+
+ " html_url, labels and assignees.")
481+
public static Map<String, Object> getIssue(
482+
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
483+
@Schema(name = "repo_name", description = "The repository name.") String repoName,
484+
@Schema(name = "issue_number", description = "The issue number to fetch.") int issueNumber) {
485+
try {
486+
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
487+
GHIssue issue = repo.getIssue(issueNumber);
488+
if (issue.isPullRequest()) {
489+
return error("#" + issueNumber + " is a pull request, not an issue.");
490+
}
491+
return success("issue", formatIssue(issue));
492+
} catch (GHFileNotFoundException e) {
493+
return error("Issue #" + issueNumber + " was not found.");
494+
} catch (IOException | GHException e) {
495+
return error("Failed to get issue #" + issueNumber + ": " + e.getMessage());
496+
}
497+
}
498+
499+
@Schema(
500+
name = "add_label_to_issue",
501+
description = "Adds a single label to an issue, preserving any labels already present.")
502+
public static Map<String, Object> addLabelToIssue(
503+
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
504+
@Schema(name = "repo_name", description = "The repository name.") String repoName,
505+
@Schema(name = "issue_number", description = "The issue number to label.") int issueNumber,
506+
@Schema(name = "label", description = "The label to add.") String label) {
507+
String targetError = writeTargetError(repoOwner, repoName);
508+
if (targetError != null) {
509+
return error(targetError);
510+
}
511+
if (dryRun) {
512+
return dryRunPreview(
513+
"DRY RUN: no label was added. Set DRY_RUN=0 to label issues for real.",
514+
"issue_number",
515+
issueNumber,
516+
"label",
517+
label);
518+
}
519+
try {
520+
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
521+
repo.getIssue(issueNumber).addLabels(label);
522+
Map<String, Object> result = new LinkedHashMap<>();
523+
result.put("issue_number", issueNumber);
524+
result.put("added_label", label);
525+
return success(result);
526+
} catch (IOException | GHException e) {
527+
return error(
528+
"Failed to add label '" + label + "' to issue #" + issueNumber + ": " + e.getMessage());
529+
}
530+
}
531+
532+
@Schema(
533+
name = "remove_label_from_issue",
534+
description =
535+
"Removes a single label from an issue. Succeeds as a no-op if the label is not present.")
536+
public static Map<String, Object> removeLabelFromIssue(
537+
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
538+
@Schema(name = "repo_name", description = "The repository name.") String repoName,
539+
@Schema(name = "issue_number", description = "The issue number to unlabel.") int issueNumber,
540+
@Schema(name = "label", description = "The label to remove.") String label) {
541+
String targetError = writeTargetError(repoOwner, repoName);
542+
if (targetError != null) {
543+
return error(targetError);
544+
}
545+
if (dryRun) {
546+
return dryRunPreview(
547+
"DRY RUN: no label was removed. Set DRY_RUN=0 to modify issues for real.",
548+
"issue_number",
549+
issueNumber,
550+
"label",
551+
label);
552+
}
553+
try {
554+
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
555+
repo.getIssue(issueNumber).removeLabel(label);
556+
Map<String, Object> result = new LinkedHashMap<>();
557+
result.put("issue_number", issueNumber);
558+
result.put("removed_label", label);
559+
return success(result);
560+
} catch (GHFileNotFoundException e) {
561+
// The label (or label-on-issue) was not present; removing it is a no-op success.
562+
Map<String, Object> result = new LinkedHashMap<>();
563+
result.put("issue_number", issueNumber);
564+
result.put("removed_label", label);
565+
result.put("note", "label was not present");
566+
return success(result);
567+
} catch (IOException | GHException e) {
568+
return error(
569+
"Failed to remove label '"
570+
+ label
571+
+ "' from issue #"
572+
+ issueNumber
573+
+ ": "
574+
+ e.getMessage());
575+
}
576+
}
577+
578+
@Schema(
579+
name = "assign_issue",
580+
description = "Adds one or more assignees (by GitHub handle) to an issue.")
581+
public static Map<String, Object> assignIssue(
582+
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
583+
@Schema(name = "repo_name", description = "The repository name.") String repoName,
584+
@Schema(name = "issue_number", description = "The issue number to assign.") int issueNumber,
585+
@Schema(name = "assignees", description = "GitHub handles to assign.")
586+
List<String> assignees) {
587+
if (assignees == null || assignees.isEmpty()) {
588+
return error("assignees must be non-empty.");
589+
}
590+
String targetError = writeTargetError(repoOwner, repoName);
591+
if (targetError != null) {
592+
return error(targetError);
593+
}
594+
if (dryRun) {
595+
return dryRunPreview(
596+
"DRY RUN: no assignee was added. Set DRY_RUN=0 to assign issues for real.",
597+
"issue_number",
598+
issueNumber,
599+
"assignees",
600+
assignees);
601+
}
602+
try {
603+
GitHub github = connect();
604+
GHRepository repo = github.getRepository(repoOwner + "/" + repoName);
605+
List<GHUser> users = new ArrayList<>();
606+
for (String assignee : assignees) {
607+
users.add(github.getUser(assignee));
608+
}
609+
repo.getIssue(issueNumber).addAssignees(users);
610+
Map<String, Object> result = new LinkedHashMap<>();
611+
result.put("issue_number", issueNumber);
612+
result.put("assignees", assignees);
613+
return success(result);
614+
} catch (IOException | GHException e) {
615+
return error("Failed to assign issue #" + issueNumber + ": " + e.getMessage());
616+
}
617+
}
618+
619+
/** Formats an issue into the compact map (number, title, body, html_url, labels, assignees). */
620+
private static Map<String, Object> formatIssue(GHIssue issue) {
621+
Map<String, Object> info = new LinkedHashMap<>();
622+
info.put("number", issue.getNumber());
623+
info.put("title", issue.getTitle());
624+
info.put("body", issue.getBody() == null ? "" : issue.getBody());
625+
info.put("html_url", issue.getHtmlUrl() == null ? "" : issue.getHtmlUrl().toString());
626+
List<String> labels = new ArrayList<>();
627+
for (GHLabel label : issue.getLabels()) {
628+
labels.add(label.getName());
629+
}
630+
info.put("labels", labels);
631+
List<String> assignees = new ArrayList<>();
632+
for (GHUser user : issue.getAssignees()) {
633+
assignees.add(user.getLogin());
634+
}
635+
info.put("assignees", assignees);
636+
return info;
637+
}
638+
430639
private static boolean hasDocsLabel(GHIssue issue) {
431640
for (GHLabel label : issue.getLabels()) {
432641
if (label.getName().equals(DOCS_UPDATES_LABEL)) {
@@ -515,4 +724,18 @@ private static Map<String, Object> error(String message) {
515724
response.put("error_message", message);
516725
return response;
517726
}
727+
728+
/**
729+
* Builds a {@code dry_run} preview envelope from {@code message} and an even number of key/value
730+
* pairs describing the write that would have happened.
731+
*/
732+
private static Map<String, Object> dryRunPreview(String message, Object... keyValuePairs) {
733+
Map<String, Object> preview = new LinkedHashMap<>();
734+
preview.put(STATUS_KEY, STATUS_DRY_RUN);
735+
preview.put("message", message);
736+
for (int i = 0; i + 1 < keyValuePairs.length; i += 2) {
737+
preview.put(String.valueOf(keyValuePairs[i]), keyValuePairs[i + 1]);
738+
}
739+
return preview;
740+
}
518741
}

contrib/samples/github/adkreleasedocs/pom.xml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@
7878
<target>${java.version}</target>
7979
<parameters>true</parameters>
8080
</configuration>
81+
<executions>
82+
<!-- The shared github/ directory is on the source path (see build-helper
83+
below), so restrict the main compile to this sample's files plus the
84+
shared GitHubTools.java; otherwise sibling samples under github/ would
85+
also be compiled into this module. -->
86+
<execution>
87+
<id>default-compile</id>
88+
<configuration>
89+
<includes>
90+
<include>GitHubTools.java</include>
91+
<include>adkreleasedocs/*.java</include>
92+
</includes>
93+
</configuration>
94+
</execution>
95+
</executions>
8196
</plugin>
8297
<plugin>
8398
<groupId>org.codehaus.mojo</groupId>
@@ -104,12 +119,28 @@
104119
<groupId>org.apache.maven.plugins</groupId>
105120
<artifactId>maven-source-plugin</artifactId>
106121
<configuration>
122+
<!-- The github/ source root recursively includes sibling samples; keep them
123+
out of this module's -Prelease sources jar. -->
107124
<excludes>
108125
<exclude>**/*.jar</exclude>
126+
<exclude>adktriaging/**</exclude>
109127
<exclude>target/**</exclude>
110128
</excludes>
111129
</configuration>
112130
</plugin>
131+
<plugin>
132+
<groupId>org.apache.maven.plugins</groupId>
133+
<artifactId>maven-javadoc-plugin</artifactId>
134+
<configuration>
135+
<!-- Mirror the source excludes: the github/ source root pulls in sibling
136+
samples (with their own, possibly test-scoped, dependencies) that are not
137+
on this module's Javadoc classpath, so the -Prelease attach-javadocs goal
138+
would otherwise fail. -->
139+
<sourceFileExcludes>
140+
<sourceFileExclude>adktriaging/**</sourceFileExclude>
141+
</sourceFileExcludes>
142+
</configuration>
143+
</plugin>
113144
<plugin>
114145
<groupId>org.codehaus.mojo</groupId>
115146
<artifactId>exec-maven-plugin</artifactId>

0 commit comments

Comments
 (0)