Skip to content
Merged
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
89 changes: 89 additions & 0 deletions .github/workflows/spam-detection-adk-java-issues.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Scans adk-java issues for spam/promotional content with the ADK Issue
# Monitoring (Spam Detection) Agent sample under
# contrib/samples/github/adkspam.
#
# Required repository secrets:
# - GOOGLE_API_KEY : Gemini API key (or wire up Vertex AI credentials and
# set GOOGLE_GENAI_USE_VERTEXAI=TRUE).
# Labeling/commenting 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 the spam label/alert comment attributed
# to a distinct bot identity.
#
# NOTE: the `spam` label (or whatever SPAM_LABEL_NAME is set to) must already
# exist in the repository's labels; the agent applies it but does not create it.
name: ADK Issue Monitoring (Spam Detection) Agent

on:
issues:
types: [opened]
schedule:
# Run daily at 06:00 UTC, matching the Python issue-monitor workflow.
- cron: '0 6 * * *'
workflow_dispatch:
inputs:
full_scan:
description: 'Audit ALL open issues (not just those updated in the last 24h).'
required: false
default: false
type: boolean

# Serialize runs that touch the same issue so the scheduled sweep can't race a
# per-issue run on that issue.
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
cancel-in-progress: false

jobs:
agent-scan-issues:
runs-on: ubuntu-latest
# Only run on the upstream repo, for newly-opened issues, the scheduled
# 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@v6

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

- name: Run Spam Detection 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/comment actions.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GOOGLE_GENAI_USE_VERTEXAI: '0'
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.event.repository.name }}
INTERACTIVE: '0'
# Defaults to a dry run (logs intended labels/comments 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 }}
# Mapped to the manual-dispatch checkbox. On the daily schedule this is
# empty, so only issues updated in the last 24h are audited.
INITIAL_FULL_SCAN: ${{ github.event.inputs.full_scan }}
run: |
# Install the ADK libs + this sample, then run exec:java scoped to this
# module (exec:java with -am would also run on the parent/core modules,
# which have no mainClass).
./mvnw -B -q -pl contrib/samples/github/adkspam -am install -DskipTests
./mvnw -B -q -pl contrib/samples/github/adkspam exec:java
155 changes: 151 additions & 4 deletions contrib/samples/github/GitHubTools.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@

import com.google.adk.tools.Annotations.Schema;
import java.io.IOException;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
Expand Down Expand Up @@ -48,9 +51,9 @@
* Reads {@code GITHUB_TOKEN} from the environment; callers set {@link #dryRun} to gate writes.
*
* <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; labelling/assigning issues; commenting on or closing issues; and reading, labelling and
* commenting on pull requests.
* diffs and file contents; searching code; listing and reading issues and their comments; listing
* repository collaborators; creating issues and pull requests; labelling/assigning issues;
* commenting on or closing issues; and reading, labelling and commenting on pull requests.
*
* <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
Expand Down Expand Up @@ -81,6 +84,13 @@ public final class GitHubTools {

private static final int MAX_SEARCH_RESULTS = 50;
private static final int MAX_ISSUES_LISTED = 100;

/**
* Upper bound for {@link #listOpenIssuesUpdatedSince}. Higher than {@link #MAX_ISSUES_LISTED}
* because the spam-detection sweep audits the whole open backlog, not just a triage batch.
*/
private static final int MAX_ISSUES_SCANNED = 500;

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 @@ -494,6 +504,60 @@ public static Map<String, Object> listOpenIssues(
}
}

@Schema(
name = "list_open_issues_updated_since",
description =
"Lists OPEN issues (excluding pull requests) for a repository, optionally restricted to"
+ " those updated at or after an ISO-8601 timestamp (e.g. 2026-01-01T00:00:00Z). Each"
+ " entry has the issue's number, title, body, html_url, author, labels and"
+ " assignees. Pass an empty updated_since to list all open issues.")
public static Map<String, Object> listOpenIssuesUpdatedSince(
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
@Schema(name = "repo_name", description = "The repository name.") String repoName,
@Schema(
name = "updated_since",
description =
"Only include issues updated at or after this ISO-8601 instant. May be empty to"
+ " disable the filter.",
optional = true)
String updatedSince,
@Schema(
name = "max_results",
description = "Maximum number of issues to return (capped at 500).",
optional = true)
Integer maxResults) {
int limit =
(maxResults == null || maxResults <= 0)
? MAX_ISSUES_SCANNED
: Math.min(maxResults, MAX_ISSUES_SCANNED);
Date since = parseInstantOrNull(updatedSince);
if (updatedSince != null && !updatedSince.isBlank() && since == null) {
return error("updated_since '" + updatedSince + "' is not a valid ISO-8601 instant.");
}
try {
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
org.kohsuke.github.GHIssueQueryBuilder.ForRepository query = repo.queryIssues();
query.state(GHIssueState.OPEN);
if (since != null) {
query.since(since);
}
query.pageSize(100);
List<Map<String, Object>> issues = new ArrayList<>();
for (GHIssue issue : query.list()) {
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 =
Expand All @@ -517,6 +581,53 @@ public static Map<String, Object> getIssue(
}
}

@Schema(
name = "get_issue_comments",
description =
"Lists all comments on an issue (oldest first), each with the comment author's login,"
+ " body and html_url. Use this to inspect a thread for spam or to check whether the"
+ " bot has already commented.")
public static Map<String, Object> getIssueComments(
@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 whose comments to fetch.")
int issueNumber) {
try {
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
GHIssue issue = repo.getIssue(issueNumber);
List<Map<String, Object>> comments = new ArrayList<>();
for (GHIssueComment comment : issue.getComments()) {
Map<String, Object> info = new LinkedHashMap<>();
info.put("author", commentAuthorLogin(comment));
info.put("body", comment.getBody() == null ? "" : comment.getBody());
info.put("html_url", comment.getHtmlUrl() == null ? "" : comment.getHtmlUrl().toString());
comments.add(info);
}
return success("comments", comments);
} catch (GHFileNotFoundException e) {
return error("Issue #" + issueNumber + " was not found.");
} catch (IOException | GHException e) {
return error("Failed to get comments for issue #" + issueNumber + ": " + e.getMessage());
}
}

@Schema(
name = "list_repository_collaborators",
description =
"Lists the login handles of the repository's collaborators (repo insiders). Used to skip"
+ " content authored by maintainers when auditing for spam.")
public static Map<String, Object> listRepositoryCollaborators(
@Schema(name = "repo_owner", description = "The repository owner.") String repoOwner,
@Schema(name = "repo_name", description = "The repository name.") String repoName) {
try {
GHRepository repo = connect().getRepository(repoOwner + "/" + repoName);
List<String> collaborators = new ArrayList<>(repo.getCollaboratorNames());
return success("collaborators", collaborators);
} catch (IOException | GHException e) {
return error("Failed to list collaborators: " + e.getMessage());
}
}

@Schema(
name = "add_label_to_issue",
description = "Adds a single label to an issue, preserving any labels already present.")
Expand Down Expand Up @@ -920,13 +1031,18 @@ public static Map<String, Object> closeIssue(
}
}

/** Formats an issue into the compact map (number, title, body, html_url, labels, assignees). */
/**
* Formats an issue into the compact map (number, title, body, html_url, author, labels,
* assignees). {@code author} is the login of the issue opener (empty when unavailable), used by
* the spam-detection sample to skip issues opened by maintainers/bots.
*/
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());
info.put("author", issueAuthorLogin(issue));
List<String> labels = new ArrayList<>();
for (GHLabel label : issue.getLabels()) {
labels.add(label.getName());
Expand All @@ -940,6 +1056,22 @@ private static Map<String, Object> formatIssue(GHIssue issue) {
return info;
}

/** Returns the login of the issue's author, or {@code ""} if it cannot be determined. */
private static String issueAuthorLogin(GHIssue issue) {
try {
GHUser user = issue.getUser();
return user == null || user.getLogin() == null ? "" : user.getLogin();
} catch (IOException | GHException e) {
return "";
}
}

/** Returns the login of a comment's author, or {@code ""} if it cannot be determined. */
private static String commentAuthorLogin(GHIssueComment comment) {
String name = comment.getUserName();
return name == null ? "" : name;
}

private static boolean hasDocsLabel(GHIssue issue) {
for (GHLabel label : issue.getLabels()) {
if (label.getName().equals(DOCS_UPDATES_LABEL)) {
Expand Down Expand Up @@ -998,6 +1130,21 @@ private static String docPathError(String path) {
return null;
}

/**
* Parses an ISO-8601 instant (e.g. {@code 2026-01-01T00:00:00Z}) into a {@link Date}, returning
* {@code null} when {@code value} is null/blank or not a valid instant.
*/
private static Date parseInstantOrNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Date.from(Instant.parse(value.trim()));
} catch (DateTimeParseException e) {
return null;
}
}

/** Connects to GitHub using GITHUB_TOKEN from the environment (anonymous if unset). */
private static GitHub connect() throws IOException {
GitHubBuilder builder = new GitHubBuilder();
Expand Down
2 changes: 2 additions & 0 deletions contrib/samples/github/adkprtriaging/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
<exclude>**/*.yml</exclude>
<exclude>adktriaging/**</exclude>
<exclude>adkreleasedocs/**</exclude>
<exclude>adkspam/**</exclude>
<exclude>adkstale/**</exclude>
<exclude>**/src/test/**</exclude>
<exclude>target/**</exclude>
Expand All @@ -170,6 +171,7 @@
<sourceFileExcludes>
<sourceFileExclude>adktriaging/**</sourceFileExclude>
<sourceFileExclude>adkreleasedocs/**</sourceFileExclude>
<sourceFileExclude>adkspam/**</sourceFileExclude>
<sourceFileExclude>adkstale/**</sourceFileExclude>
<sourceFileExclude>**/src/test/**</sourceFileExclude>
</sourceFileExcludes>
Expand Down
2 changes: 2 additions & 0 deletions contrib/samples/github/adkreleasedocs/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
<excludes>
<exclude>**/*.jar</exclude>
<exclude>adkprtriaging/**</exclude>
<exclude>adkspam/**</exclude>
<exclude>adkstale/**</exclude>
<exclude>adktriaging/**</exclude>
<exclude>target/**</exclude>
Expand All @@ -140,6 +141,7 @@
would otherwise fail. -->
<sourceFileExcludes>
<sourceFileExclude>adkprtriaging/**</sourceFileExclude>
<sourceFileExclude>adkspam/**</sourceFileExclude>
<sourceFileExclude>adkstale/**</sourceFileExclude>
<sourceFileExclude>adktriaging/**</sourceFileExclude>
</sourceFileExcludes>
Expand Down
Loading
Loading