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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,11 @@ If `APP_SEED_ENABLED=true` and `APP_SEED_USER_EMAIL` is not provided (or the use
| `GOOGLE_DRIVE_CLIENT_SECRET` | *(empty)* | Google OAuth client secret for Drive integration |
| `GOOGLE_DRIVE_REDIRECT_URI` | `http://localhost:8080/api/v1/google-drive/oauth/callback` | OAuth callback URL registered in Google Cloud |
| `GOOGLE_DRIVE_OAUTH_COMPLETE_URL` | *(empty)* | Frontend URL that receives OAuth completion redirects |
| `GITHUB_USER_ID` | `65777252` | Stable GitHub numeric user ID of the linked account (canonical identity of the integration) |
| `GITHUB_LOGIN` | *(empty)* | Legacy username. Resolved once to a numeric ID when no `GITHUB_USER_ID` is set; also used as a fallback label while GitHub is unreachable |
| `GITHUB_API_BASE_URL` | `https://api.github.com` | GitHub REST API base URL |
| `GITHUB_TOKEN` | *(empty)* | Optional PAT; only raises the GitHub API rate limit |
| `GITHUB_CACHE_TTL_SECONDS` | `3600` | How long a resolved GitHub profile is cached |
| `OPENAI_GPT_CLIENT_ID` | *(empty)* | OAuth client ID for GPT Actions |
| `OPENAI_GPT_CLIENT_SECRET` | *(empty)* | OAuth client secret for GPT Actions |
| `OPENAI_GPT_REDIRECT_URIS` | *(empty)* | Comma-separated GPT Action redirect URIs |
Expand Down
99 changes: 99 additions & 0 deletions src/main/java/com/jobtracker/config/GitHubProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.jobtracker.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.time.Duration;

/**
* Configuration for the GitHub account referenced by the Developer Tools → Source Code card.
* <p>
* The <b>numeric user ID</b> ({@code app.github.user-id}) is the canonical identity of the
* integration: GitHub lets an account rename its {@code login} at any time, but the numeric ID never
* changes. The optional {@code app.github.login} is kept only as a bootstrap value for legacy
* setups that were configured before the ID existed (see
* {@link com.jobtracker.service.GitHubProfileService}) and as a last-resort presentation fallback
* when the GitHub API cannot be reached.
*/
@Component
public class GitHubProperties {

private final Long userId;
private final String login;
private final String apiBaseUrl;
private final String token;
private final Duration cacheTtl;
private final Duration timeout;

public GitHubProperties(
@Value("${app.github.user-id:}") Long userId,
@Value("${app.github.login:}") String login,
@Value("${app.github.api-base-url:https://api.github.com}") String apiBaseUrl,
@Value("${app.github.token:}") String token,
@Value("${app.github.cache-ttl-seconds:3600}") long cacheTtlSeconds,
@Value("${app.github.timeout-ms:5000}") long timeoutMs
) {
this.userId = userId;
this.login = normalizeLogin(login);
this.apiBaseUrl = stripTrailingSlash(apiBaseUrl);
this.token = token == null ? "" : token.trim();
this.cacheTtl = Duration.ofSeconds(cacheTtlSeconds);
this.timeout = Duration.ofMillis(timeoutMs);
}

/** Stable GitHub numeric user ID, or {@code null} when only a legacy username is configured. */
public Long getUserId() {
return userId;
}

/** Legacy/bootstrap username. Presentation data only - never the identity of the integration. */
public String getLogin() {
return login;
}

public String getApiBaseUrl() {
return apiBaseUrl;
}

public String getToken() {
return token;
}

public Duration getCacheTtl() {
return cacheTtl;
}

public Duration getTimeout() {
return timeout;
}

public boolean hasToken() {
return !token.isBlank();
}

/** The integration needs at least one of the two ways to reach the account. */
public boolean isConfigured() {
return userId != null || !login.isBlank();
}

/**
* Accepts a bare login ({@code vitorhugo-dotnet}) as well as a full profile URL
* ({@code https://github.com/vitorhugo-dotnet}), which is how the frontend used to be configured.
*/
private String normalizeLogin(String value) {
if (value == null) {
return "";
}
String trimmed = value.trim();
int lastSlash = trimmed.lastIndexOf('/');
if (lastSlash >= 0) {
trimmed = trimmed.substring(lastSlash + 1);
}
return trimmed;
}

private String stripTrailingSlash(String value) {
String base = value == null || value.isBlank() ? "https://api.github.com" : value.trim();
return base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
}
}
44 changes: 44 additions & 0 deletions src/main/java/com/jobtracker/controller/GitHubController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.jobtracker.controller;

import com.jobtracker.dto.github.GitHubProfileResponse;
import com.jobtracker.service.GitHubProfileService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "GitHub", description = "GitHub account reference used by the Developer Tools screen")
@RestController
@RequestMapping("/api/v1/github")
public class GitHubController {

private final GitHubProfileService gitHubProfileService;

public GitHubController(GitHubProfileService gitHubProfileService) {
this.gitHubProfileService = gitHubProfileService;
}

@Operation(
summary = "Get the linked GitHub profile",
description = """
Resolves the configured GitHub account from its stable numeric user ID and returns the
login and profile URL currently reported by GitHub, so the reference keeps working after
a username change.""",
responses = {
@ApiResponse(responseCode = "200", description = "Resolved GitHub profile",
content = @Content(schema = @Schema(implementation = GitHubProfileResponse.class))),
@ApiResponse(responseCode = "401", description = "Not authenticated"),
@ApiResponse(responseCode = "404", description = "No GitHub account configured on the server"),
@ApiResponse(responseCode = "503", description = "GitHub could not be reached and no cached profile exists")
}
)
@GetMapping("/profile")
public ResponseEntity<GitHubProfileResponse> getProfile() {
return ResponseEntity.ok(gitHubProfileService.getProfile());
}
}
22 changes: 22 additions & 0 deletions src/main/java/com/jobtracker/dto/github/GitHubProfileResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.jobtracker.dto.github;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "GitHub account backing the Developer Tools source-code card, resolved from its stable numeric user ID")
public record GitHubProfileResponse(

@Schema(description = "Stable GitHub numeric user ID - the canonical identity of the integration", example = "65777252")
Long userId,

@Schema(description = "Current GitHub login as returned by GitHub. Presentation data only, may change at any time", example = "vitorhugo-dotnet")
String login,

@Schema(description = "Current profile URL as returned by GitHub", example = "https://github.com/vitorhugo-dotnet")
String htmlUrl,

@Schema(description = "Current avatar URL as returned by GitHub", example = "https://avatars.githubusercontent.com/u/65777252?v=4")
String avatarUrl,

@Schema(description = "True when GitHub could not be reached and a cached or configured fallback was served instead")
boolean stale
) {}
173 changes: 173 additions & 0 deletions src/main/java/com/jobtracker/service/GitHubProfileService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package com.jobtracker.service;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.jobtracker.config.GitHubProperties;
import com.jobtracker.dto.github.GitHubProfileResponse;
import com.jobtracker.exception.ResourceNotFoundException;
import com.jobtracker.exception.ServiceUnavailableException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

import java.time.Clock;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;

/**
* Resolves the GitHub account referenced by the Developer Tools &rarr; Source Code card.
* <p>
* The account is addressed by its <b>stable numeric user ID</b>, never by its username: a GitHub
* user can rename their {@code login} at any time, which would silently break any persisted
* username-based reference. The current {@code login} and {@code html_url} are always taken from
* GitHub's answer, so renaming the account keeps the <i>View on GitHub</i> link working without any
* reconfiguration.
* <p>
* <b>Legacy migration:</b> installations configured before the ID existed only carry
* {@code app.github.login}. The first lookup then resolves that username once through
* {@code GET /users/{login}}, remembers the numeric ID it returns, and every later lookup goes
* through {@code GET /user/{id}} like a natively configured installation.
*/
@Service
public class GitHubProfileService {

private static final Logger log = LoggerFactory.getLogger(GitHubProfileService.class);
private static final String ACCEPT_HEADER = "application/vnd.github+json";
private static final String API_VERSION_HEADER = "X-GitHub-Api-Version";
private static final String API_VERSION = "2022-11-28";

private final GitHubProperties properties;
private final RestClient restClient;
private final Clock clock;

/** Numeric ID discovered from a legacy username-only configuration. */
private final AtomicReference<Long> migratedUserId = new AtomicReference<>();
private final AtomicReference<CachedProfile> cache = new AtomicReference<>();

@Autowired
public GitHubProfileService(GitHubProperties properties, RestClient.Builder restClientBuilder) {
this(properties, restClientBuilder.requestFactory(requestFactory(properties)).build(), Clock.systemUTC());
}

/** Explicit wiring, used by tests to supply a stubbed {@link RestClient} and a controllable {@link Clock}. */
public GitHubProfileService(GitHubProperties properties, RestClient restClient, Clock clock) {
this.properties = properties;
this.restClient = restClient;
this.clock = clock;
}

/**
* @return the current profile of the configured account.
* @throws ResourceNotFoundException when no GitHub account is configured at all.
* @throws ServiceUnavailableException when GitHub is unreachable and nothing can be served from cache.
*/
public GitHubProfileResponse getProfile() {
if (!properties.isConfigured()) {
throw new ResourceNotFoundException("No GitHub account is configured on the server");
}

CachedProfile cached = cache.get();
Instant now = clock.instant();
if (cached != null && cached.isFresh(now, properties.getCacheTtl())) {
return cached.profile();
}

Long userId = canonicalUserId();
try {
GitHubUser user = userId != null ? fetchById(userId) : fetchByLogin(properties.getLogin());
return cacheAndReturn(user, now);
} catch (RestClientException e) {
log.warn("Failed to resolve GitHub profile (userId={}, login={}): {}",
userId, properties.getLogin(), e.getMessage());
return fallback(cached, userId);
}
}

/** The configured ID, or the one recovered once from a legacy username-only configuration. */
private Long canonicalUserId() {
Long configured = properties.getUserId();
return configured != null ? configured : migratedUserId.get();
}

private GitHubUser fetchById(long userId) {
return get("/user/" + userId);
}

private GitHubUser fetchByLogin(String login) {
GitHubUser user = get("/users/" + login);
if (user != null && user.id() != null) {
// Migration step: from now on this installation is addressed by its stable ID.
migratedUserId.set(user.id());
log.info("Resolved legacy GitHub username '{}' to stable user ID {}; subsequent lookups use the ID",
login, user.id());
}
return user;
}

private GitHubUser get(String path) {
RestClient.RequestHeadersSpec<?> request = restClient.get()
.uri(properties.getApiBaseUrl() + path)
.header("Accept", ACCEPT_HEADER)
.header(API_VERSION_HEADER, API_VERSION);
if (properties.hasToken()) {
request = request.header("Authorization", "Bearer " + properties.getToken());
}
return request.retrieve().body(GitHubUser.class);
}

private GitHubProfileResponse cacheAndReturn(GitHubUser user, Instant now) {
if (user == null || user.id() == null || user.login() == null || user.login().isBlank()) {
throw new RestClientException("GitHub returned an incomplete user payload");
}
String htmlUrl = user.htmlUrl() != null && !user.htmlUrl().isBlank()
? user.htmlUrl()
: "https://github.com/" + user.login();
GitHubProfileResponse profile = new GitHubProfileResponse(
user.id(), user.login(), htmlUrl, user.avatarUrl(), false);
cache.set(new CachedProfile(profile, now));
return profile;
}

/**
* Never fail just because GitHub is momentarily unreachable: serve the last known profile, or the
* configured username as a presentation-only fallback. Both are flagged {@code stale}. A numeric
* ID is never turned into a guessed web URL - only GitHub can map it to a profile.
*/
private GitHubProfileResponse fallback(CachedProfile cached, Long userId) {
if (cached != null) {
GitHubProfileResponse profile = cached.profile();
return new GitHubProfileResponse(
profile.userId(), profile.login(), profile.htmlUrl(), profile.avatarUrl(), true);
}
String login = properties.getLogin();
if (!login.isBlank()) {
return new GitHubProfileResponse(userId, login, "https://github.com/" + login, null, true);
}
throw new ServiceUnavailableException("GitHub profile could not be resolved");
}

private static SimpleClientHttpRequestFactory requestFactory(GitHubProperties properties) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(properties.getTimeout());
factory.setReadTimeout(properties.getTimeout());
return factory;
}

private record CachedProfile(GitHubProfileResponse profile, Instant fetchedAt) {
boolean isFresh(Instant now, java.time.Duration ttl) {
return fetchedAt.plus(ttl).isAfter(now);
}
}

@JsonIgnoreProperties(ignoreUnknown = true)
private record GitHubUser(
Long id,
String login,
@JsonProperty("html_url") String htmlUrl,
@JsonProperty("avatar_url") String avatarUrl
) {}
}
12 changes: 12 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ app:
mail:
enabled: ${APP_MAIL_ENABLED:true}
from: ${APP_MAIL_FROM:no-reply@jobtracker.com}
github:
# Stable numeric user ID of the GitHub account shown in Developer Tools -> Source Code.
# This is the canonical identity of the integration: usernames can be renamed, IDs cannot.
user-id: ${GITHUB_USER_ID:65777252}
# Legacy/bootstrap username. Only used when no user ID is configured (resolved once to an ID)
# or as a presentation fallback while the GitHub API is unreachable.
login: ${GITHUB_LOGIN:}
api-base-url: ${GITHUB_API_BASE_URL:https://api.github.com}
# Optional PAT. Unauthenticated requests are enough here, it only raises the rate limit.
token: ${GITHUB_TOKEN:}
cache-ttl-seconds: ${GITHUB_CACHE_TTL_SECONDS:3600}
timeout-ms: ${GITHUB_TIMEOUT_MS:5000}
google-drive:
client-id: ${GOOGLE_DRIVE_CLIENT_ID:}
client-secret: ${GOOGLE_DRIVE_CLIENT_SECRET:}
Expand Down
Loading
Loading