3232import org .kohsuke .github .GHPullRequest ;
3333import org .kohsuke .github .GHRelease ;
3434import org .kohsuke .github .GHRepository ;
35+ import org .kohsuke .github .GHUser ;
3536import org .kohsuke .github .GitHub ;
3637import 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 */
4858public 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}
0 commit comments