Pulpogato is a java client for GitHub.

Installation

For releases, use mavenCentral.

https://repo.maven.apache.org/maven2/

For snapshots, use this url.

https://central.sonatype.com/repository/maven-snapshots/

The maven group id is io.github.pulpogato.

Most maven artifact ids are pulpogato-<api-type>-<gh-version>.

The api-type is one of graphql or rest.

The gh-version is one of ghec,fpt, or ghes-<GHES-version>.

To align versions across all Pulpogato modules, use io.github.pulpogato:pulpogato-bom.

Example in a gradle kotlin build script
// These properties can also be set in gradle.properties
ext {
    set("netflixDgsVersion", "9.1.2")
    set("ghVersion", "fpt")
    set("pulpogatoVersion", "2.8.0")
}

val ghVersion: String by project.ext
val pulpogatoVersion: String by project.ext
val netflixDgsVersion: String by project.ext

dependencies {
    implementation(platform("io.github.pulpogato:pulpogato-bom:${pulpogatoVersion}"))
    // Use enforcedPlatform(...) instead of platform(...) for strict version enforcement.

    implementation("io.github.pulpogato:pulpogato-rest-${ghVersion}")
    implementation("io.github.pulpogato:pulpogato-graphql-${ghVersion}")
    implementation("io.github.pulpogato:pulpogato-github-files")
    implementation("org.springframework.boot:spring-boot-starter-webflux:4.0.2")
    implementation("com.netflix.graphql.dgs:graphql-dgs-client:11.1.0")
    implementation(platform("com.netflix.graphql.dgs:graphql-dgs-platform-dependencies:${netflixDgsVersion}"))
}

Available versions

GitHub Version GraphQL REST

FPT

Maven Central Version Maven Snapshot

Maven Central Version Maven Snapshot

GHEC

Maven Central Version Maven Snapshot

Maven Central Version Maven Snapshot

GHES-3.20

Maven Central Version Maven Snapshot

Maven Central Version Maven Snapshot

GHES-3.19

Maven Central Version Maven Snapshot

Maven Central Version Maven Snapshot

GHES-3.18

Maven Central Version Maven Snapshot

Maven Central Version Maven Snapshot

GHES-3.17

Maven Central Version Maven Snapshot

Maven Central Version Maven Snapshot

GHES-3.16

Maven Central Version Maven Snapshot

Maven Central Version Maven Snapshot

EOL Versions

Shared modules

Module Version

pulpogato-common

Shared types used by both REST and GraphQL modules, such as common models and utilities.

Maven Central Version Maven Snapshot

pulpogato-bom

BOM to align versions across all Pulpogato modules. It is recommended to use this BOM in your build system to ensure all Pulpogato modules are on the same version.

Maven Central Version Maven Snapshot

pulpogato-github-files

Supports parsing Actions, Workflows, and Release configuration files.

Maven Central Version Maven Snapshot

Usage

REST Client Example

This shows how to create a REST client and get the authenticated user. Ideally, you would inject the WebClient and export a Bean for RestClients. If you’re supporting multiple GitHub Apps or sites, you would export a factory bean instead.

import io.github.pulpogato.rest.api.RestClients;
import io.github.pulpogato.rest.api.UsersApi;

public class RestExample {
    public static void main(String[] args) {
        // create a WebClient
        WebClient webClient = WebClient.builder()
                .baseUrl("https://api.github.com")
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer ...your token here...")
                .build();

        // Create RestClients instance
        RestClients restClients = new RestClients(webClient);
        // Get UsersApi
        UsersApi api = restClients.getUsersApi();
        // Call getAuthenticated method
        var authenticated = api.getAuthenticated();
        var body = authenticated.getBody();
        var privateUser = body.getPrivateUser();
        var login = privateUser.getLogin();

    }
}

Reactive REST Client Example

This shows how to create a REST client that returns a Mono reactive type.

import io.github.pulpogato.rest.api.reactive.RestClients;
import io.github.pulpogato.rest.api.reactive.UsersApi;

public class ReactiveRestExample {
    public static void main(String[] args) {
        // create a WebClient
        WebClient webClient = WebClient.builder()
                .baseUrl("https://api.github.com")
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer ...your token here...")
                .build();

        // Create RestClients instance
        RestClients restClients = new RestClients(webClient);
        // Get UsersApi
        UsersApi api = restClients.getUsersApi();
        // Call getAuthenticated method to obtain a Mono object
        var responseEntityMono = api.getAuthenticated();
        // Call block() to subscribe to the Mono and block
        // until the call response is received.
        var authenticated = responseEntityMono.block();
        var body = authenticated.getBody();
        var privateUser = body.getPrivateUser();
        var login = privateUser.getLogin();

    }
}

Webhook Example

This shows how to create a Webhook handler that listens for ping events. There are many other event types you can listen for.

@RestController
@RequestMapping("/webhooks")
@RequiredArgsConstructor
public class PingWebhooksController implements PingWebhooks<TestWebhookResponse> {
    private final ObjectMapper objectMapper;

    @Override
    public ResponseEntity<TestWebhookResponse> processPing(
            String userAgent,
            String xGithubHookId,
            String xGithubEvent,
            String xGithubHookInstallationTargetId,
            String xGithubHookInstallationTargetType,
            String xGitHubDelivery,
            String xHubSignature256,
            WebhookPing requestBody) {
        return ResponseEntity.ok(TestWebhookResponse.builder()
                .webhookName("ping")
                .body(objectMapper.writeValueAsString(requestBody))
                .build());
    }
}

GraphQL Client Example

This shows how to create a GraphQL client and get the authenticated user. Ideally, you would inject the WebClient and export a Bean for GraphQLClients. If you’re supporting multiple GitHub Apps or sites, you would export a factory bean instead.

This example uses a String for the query.

public class GraphQlExample {

    @Language("graphql")
    public static final String OPEN_PRS = """
        query findOpenPullRequests($owner: String!, $repo: String!, $branch: String!) {
          repository(owner: $owner, name: $repo, followRenames: false) {
            pullRequests(
                first: 100, states: [OPEN], baseRefName: $branch,
                orderBy: {field: UPDATED_AT, direction: DESC}
            ) {
              totalCount
              nodes {
                id
                number
                headRefName
                headRefOid
                baseRefOid
                mergeable
                author {
                  __typename
                  login
                }
                url
                autoMergeRequest {
                  enabledBy {
                    __typename
                    login
                  }
                  mergeMethod
                }
              }
            }
          }
        }
        """;

    public static void main(String[] args) {
        // create a WebClient
        WebClient graphqlWebClient = WebClient.builder()
                .baseUrl("https://api.github.com/graphql")
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer ...your token here...")
                .build();

        WebClientGraphQLClient graphQLClient = new WebClientGraphQLClient(graphqlWebClient);

        var variables = LinkedHashMapBuilder.of(
                entry("owner", "pulpogato"), (1)
                entry("repo", "pulpogato"),
                entry("branch", "main"));
        var response = graphQLClient.reactiveExecuteQuery(OPEN_PRS, variables).block();

        var repository = response.extractValueAsObject("repository", Repository.class);

    }
}
1 Set variables for the query

This is the same query using a Typed Query Builder

public class TypedGraphQlExample {

    public static void main(String[] args) {
        // create a WebClient
        WebClient graphqlWebClient = WebClient.builder()
                .baseUrl("https://api.github.com/graphql")
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer ...your token here...")
                .build();

        WebClientGraphQLClient graphQLClient = new WebClientGraphQLClient(graphqlWebClient);

        var query = RepositoryGraphQLQuery.newRequest()
                .followRenames(false)
                .owner("pulpogato")
                .name("pulpogato")
                .build();
        var repoProjection = new RepositoryProjectionRoot<>();
        var prProjection = repoProjection.pullRequests(
                /*after*/ null,
                /*baseRefName*/ "main",
                /*before*/ null,
                /*first*/ 100,
                /*headRefName*/ null,
                /*labels*/ null,
                /*last*/ null,
                IssueOrder.newBuilder()
                        .field(IssueOrderField.UPDATED_AT)
                        .direction(OrderDirection.DESC)
                        .build(),
                /*states*/ null);
        var nodesProjection = prProjection.totalCount().nodes();

        nodesProjection
                /* enter */
                .id()
                .number()
                .headRefName()
                .headRefOid()
                .baseRefOid()
                .url()
                .mergeable();

        nodesProjection
                /* enter */
                .author()
                .__typename()
                .login();
        var autoMergeRequest = nodesProjection
                /* enter */
                .autoMergeRequest();
        autoMergeRequest
                /* enter */
                .mergeMethod();
        autoMergeRequest
                /* enter */
                .enabledBy()
                /* enter */
                .__typename()
                .login();

        var typedQuery = new GraphQLQueryRequest(query, repoProjection);
        var response =
                graphQLClient.reactiveExecuteQuery(typedQuery.serialize()).block();

        var repository = response.extractValueAsObject("repository", Repository.class);

    }
}

GitHub Files Example

pulpogato-github-files provides typed models for common repository files:

  • Action metadata (action.yml) as GithubAction

  • Discussion forms (.github/DISCUSSION_TEMPLATE/*.yml) as GithubDiscussion

  • Funding configuration (.github/FUNDING.yml) as GithubFunding

  • Issue Template Chooser config (.github/ISSUE_TEMPLATE/config.yml) as GithubIssueConfig

  • Issue forms (.github/ISSUE_TEMPLATE/*.yml) as GithubIssueForms

  • Release Drafter config (.github/release.yml) as GithubReleaseConfig

  • Workflow files (.github/workflows/*.yml) as GithubWorkflow

  • Workflow template properties (.github/workflow-templates/*.properties.json) as GithubWorkflowTemplateProperties

The module supports both Jackson 2 (com.fasterxml.jackson) and Jackson 3 (tools.jackson).

Dependency:

implementation("io.github.pulpogato:pulpogato-github-files:<version>")

Jackson 2 YAML parsing example:

var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
        new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());

var workflowYaml = """
        name: CI
        on: push
        jobs:
          test:
            runs-on: ubuntu-latest
            steps:
              - run: ./gradlew test
        """;

var workflow = yamlMapper.readValue(workflowYaml, GithubWorkflow.class);
var workflowName = workflow.getName();

Jackson 3 YAML parsing example:

var yamlMapper = tools.jackson.dataformat.yaml.YAMLMapper.builder().build();

var actionYaml = """
        name: Setup Java
        description: Setup JDK
        runs:
          using: node20
          main: index.js
        """;

var action = yamlMapper.readValue(actionYaml, GithubAction.class);
var actionName = action.getName();

Funding parsing example:

var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
        new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());

var fundingYaml = """
        github: [octocat, hubot]
        patreon: octocat
        """;

var funding = yamlMapper.readValue(fundingYaml, GithubFunding.class);
var githubSponsors = funding.getGithub().getList();

Issue forms parsing example:

var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
        new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());

var issueFormsYaml = """
        name: Bug Report
        description: File a bug report
        body:
          - type: input
            id: version
            attributes:
              label: Version
        """;

var issueForms = yamlMapper.readValue(issueFormsYaml, GithubIssueForms.class);
var name = issueForms.getName();

Release Drafter parsing example:

var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
        new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());

var releaseYaml = """
        changelog:
          categories:
            - title: Features
              labels: [feature]
        """;

var releaseConfig = yamlMapper.readValue(releaseYaml, GithubReleaseConfig.class);
var firstCategory = releaseConfig.getChangelog().getCategories().get(0).getTitle();

Discussion form parsing example:

var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
        new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());

var discussionYaml = """
        title: New Feature Idea
        body:
          - type: input
            attributes:
              label: Idea name
              placeholder: My cool idea
        """;

var discussion =
        yamlMapper.readValue(discussionYaml, io.github.pulpogato.githubfiles.discussion.GithubDiscussion.class);
var title = discussion.getTitle();

Issue Template Chooser parsing example:

var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
        new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());

var issueConfigYaml = """
        blank_issues_enabled: false
        contact_links:
          - name: Community Support
            url: https://community.example.com
            about: Get help here
        """;

var issueConfig = yamlMapper.readValue(
        issueConfigYaml, io.github.pulpogato.githubfiles.issueconfig.GithubIssueConfig.class);
var enabled = issueConfig.getBlankIssuesEnabled();

Workflow template properties parsing example:

var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
        new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());

var templatePropertiesYaml = """
        name: Java CI
        description: Java CI with Gradle
        iconName: java
        categories: [Java]
        """;

var templateProperties = yamlMapper.readValue(templatePropertiesYaml, GithubWorkflowTemplateProperties.class);
var iconName = templateProperties.getIconName();

Many fields in these schemas are unions (for example, on in workflows can be a string, list, or object). These are represented as typed wrapper objects with one populated field per variant.

var on = workflow.getOn();
if (on.getEvent() != null) {
    assertThat(on.getEvent()).isEqualTo(Event.PUSH);
} else if (on.getList() != null) {
    assertThat(on.getList()).contains(Event.PUSH);
} else if (on.getGithubWorkflowOnVariant2() != null) {
    assertThat(on.getGithubWorkflowOnVariant2()).isNotNull();
}

GitHub App JWT Authentication

Pulpogato provides JwtFilter to authenticate as a GitHub App using JSON Web Tokens (JWT). This is useful when you need to authenticate as the app itself rather than as an installation.

JWT Filter Setup

var jwtFactory = new JwtFactory(
        privateKeyPem, (1)
        12345L (2)
        );
var jwtFilter = JwtFilter.builder().jwtFactory(jwtFactory).build();

var webClient = WebClient.builder()
        .baseUrl("https://api.github.com")
        .filter(jwtFilter) (3)
        .build();
1 The RSA private key in PEM format (PKCS#1 or PKCS#8), loaded from your GitHub App settings
2 Your GitHub App ID (or client ID) as a string
3 Add the filter to your WebClient

The filter:

  • Generates RS256-signed JWTs per GitHub’s requirements

  • Sets iat (issued at) 60 seconds in the past to account for clock drift

  • Sets exp (expiration) to 9 minutes in the future

  • Caches tokens and refreshes them 30 seconds before expiry

  • Supports both PKCS#1 (BEGIN RSA PRIVATE KEY) and PKCS#8 (BEGIN PRIVATE KEY) formats

HTTP Caching

Pulpogato provides CachingExchangeFilterFunction to cache HTTP responses based on standard HTTP caching headers (ETag, Last-Modified, Cache-Control). This can significantly reduce API calls and improve performance.

Basic Caching Setup

var cachingFilter = CachingExchangeFilterFunction.builder()
        .cache(cache) (1)
        .build();

var cachingClient = webClient
        .mutate()
        .filter(cachingFilter) (2)
        .build();
1 This is a Spring Cache (ConcurrentMapCache, Caffeine, Redis, etc.)
2 You can add multiple filters here. This filter configures caching.

The filter handles:

  • ETag / If-None-Match: Stores the ETag and sends conditional requests

  • Last-Modified / If-Modified-Since: Stores the last modified date and sends conditional requests

  • Cache-Control max-age: Returns cached responses directly while fresh

The response includes an X-Pulpogato-Cache header indicating the cache status:

  • HIT - Response served from cache without server request

  • MISS - Response fetched from server and cached

  • REVALIDATED - Server returned 304, cached response body returned

  • SKIP - Response too large to cache (still returned to caller)

Advanced Caching Options

var cachingFilter = CachingExchangeFilterFunction.builder()
        .cache(cache)
        .cacheKeyMapper(request -> request.url().toASCIIString()) (1)
        .maxCacheableSize(4 * 1024 * 1024) (2)
        .alwaysRevalidate(true) (3)
        .build();

var cachingClient = webClient.mutate().filter(cachingFilter).build();
1 Custom function to generate cache keys from requests
2 Maximum response size to cache (default: 2MB)
3 Always send conditional requests even if cache is fresh. This is helpful when GitHub’s cache directive is too long for your usecase.

Rate Limit Metrics

Pulpogato provides MetricsExchangeFunction to capture GitHub API rate limit information as Micrometer metrics. This helps monitor your API usage and avoid hitting rate limits.

Basic Metrics Setup

var metricsFilter = MetricsExchangeFunction.builder()
        .registry(meterRegistry) (1)
        .build();

var metricsClient = webClient.mutate().filter(metricsFilter).build();
1 This is a Micrometer MeterRegistry (SimpleMeterRegistry, PrometheusMeterRegistry, etc.) It could be injected from Spring.

The filter extracts these headers from GitHub responses and creates gauges:

  • github.api.rateLimit.limit - Maximum requests allowed

  • github.api.rateLimit.remaining - Requests remaining in current window

  • github.api.rateLimit.used - Requests used in current window

  • github.api.rateLimit.secondsToReset - Seconds until rate limit resets

All metrics are tagged with the resource (e.g., "core", "search", "graphql").

Advanced Metrics Options

var metricsFilter = MetricsExchangeFunction.builder()
        .registry(meterRegistry)
        .prefix("myapp.github.rateLimit") (1)
        .defaultTags(List.of(Tag.of("env", "production"))) (2)
        .build();

var metricsClient = webClient.mutate().filter(metricsFilter).build();
1 Custom metric name prefix
2 Additional tags to add to all metrics