diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java index de34b6f..6bba2db 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java @@ -1,7 +1,7 @@ package com.onixbyte.deltaforceguide.config; +import com.onixbyte.deltaforceguide.interceptor.GitHubWebhookInterceptor; import com.onixbyte.deltaforceguide.interceptor.TrafficInterceptor; -import com.onixbyte.deltaforceguide.interceptor.GitLabWebhookInterceptor; import com.onixbyte.deltaforceguide.properties.AppProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -15,21 +15,21 @@ public class AppConfig implements WebMvcConfigurer { private final TrafficInterceptor trafficInterceptor; - private final GitLabWebhookInterceptor gitLabWebhookInterceptor; + private final GitHubWebhookInterceptor gitHubWebhookInterceptor; @Autowired public AppConfig( TrafficInterceptor trafficInterceptor, - GitLabWebhookInterceptor gitLabWebhookInterceptor + GitHubWebhookInterceptor gitHubWebhookInterceptor ) { this.trafficInterceptor = trafficInterceptor; - this.gitLabWebhookInterceptor = gitLabWebhookInterceptor; + this.gitHubWebhookInterceptor = gitHubWebhookInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(trafficInterceptor); - registry.addInterceptor(gitLabWebhookInterceptor) - .addPathPatterns("/webhook/gitlab"); + registry.addInterceptor(gitHubWebhookInterceptor) + .addPathPatterns("/webhooks/github"); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java new file mode 100644 index 0000000..580e1f9 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java @@ -0,0 +1,43 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest; +import com.onixbyte.deltaforceguide.service.WebhookService; +import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/webhooks/github") +public class GitHubWebhookController { + + private static final Logger log = LoggerFactory.getLogger(GitHubWebhookController.class); + + private final WebhookService webhookService; + + public GitHubWebhookController(WebhookService webhookService) { + this.webhookService = webhookService; + } + + @PostMapping + public ResponseEntity handleWebhook( + @RequestHeader(GitHubWebhookHeader.EVENT) String event, + @RequestBody GitHubIssueRequest request + ) { + if (!"issues".equals(event)) { + log.debug("Ignoring non-issue event: {}", event); + return ResponseEntity.ok().build(); + } + if (!"opened".equals(request.action())) { + log.debug("Ignoring issue action: {}", request.action()); + return ResponseEntity.ok().build(); + } + webhookService.processIssueEvent(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index 4a1f50d..3489c2c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -1,14 +1,192 @@ package com.onixbyte.deltaforceguide.service; -import com.onixbyte.deltaforceguide.manager.WebhookManager; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest; +import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest; +import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; +import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; +import com.onixbyte.deltaforceguide.manager.ModificationManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import org.yaml.snakeyaml.Yaml; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; @Service public class WebhookService { - private final WebhookManager webhookManager; + private static final Logger log = LoggerFactory.getLogger(WebhookService.class); + private static final String TRIGGER_LABEL = "weapon-mod"; + private static final Duration DEDUP_TTL = Duration.ofHours(12); + private static final Pattern YAML_FENCE = + Pattern.compile("```ya?ml\\s*\\n(.*?)```", Pattern.DOTALL); - public WebhookService(WebhookManager webhookManager) { - this.webhookManager = webhookManager; + private final ModificationManager modificationManager; + private final RedisTemplate redisTemplate; + private final Yaml yaml; + + public WebhookService( + ModificationManager modificationManager, + RedisTemplate redisTemplate + ) { + this.modificationManager = modificationManager; + this.redisTemplate = redisTemplate; + this.yaml = new Yaml(); + } + + public void processIssueEvent(GitHubIssueRequest request) { + var issue = request.issue(); + var repository = request.repository(); + + if (!hasTriggerLabel(issue.labels())) { + log.debug("Issue #{} lacks trigger label, skipping", issue.number()); + return; + } + + var dedupKey = "github:webhook:processed:%s:%d" + .formatted(repository.fullName(), issue.number()); + var acquired = redisTemplate.opsForValue() + .setIfAbsent(dedupKey, "1", DEDUP_TTL); + if (acquired == null || !acquired) { + log.info("Issue #{} already processed, skipping", issue.number()); + return; + } + + var parsedYaml = extractYaml(issue.body()); + if (parsedYaml == null) { + log.warn("No YAML block found in issue #{} body", issue.number()); + return; + } + + @SuppressWarnings("unchecked") + var data = (Map) yaml.load(parsedYaml); + if (data == null) { + log.warn("Empty YAML block in issue #{}", issue.number()); + return; + } + + if (data.containsKey("modifications")) { + processBatch(issue.number(), data); + } else { + processSingle(issue.number(), data); + } + } + + private void processSingle(Long issueNumber, Map data) { + var request = mapToRequest(data); + log.info("Creating modification from issue #{}: name={}", issueNumber, request.name()); + modificationManager.create(request); + } + + @SuppressWarnings("unchecked") + private void processBatch(Long issueNumber, Map data) { + var list = (List>) data.get("modifications"); + if (list == null || list.isEmpty()) { + log.warn("Empty modifications list in issue #{}", issueNumber); + return; + } + var requests = list.stream() + .map(this::mapToRequest) + .toList(); + log.info("Batch creating {} modifications from issue #{}", requests.size(), issueNumber); + modificationManager.batchCreate(requests); + } + + private ModificationRequest mapToRequest(Map data) { + Long firearmId = toLong(data.get("firearmId")); + String name = (String) data.get("name"); + String code = (String) data.get("code"); + List tags = toStringList(data.get("tags")); + List accessories = mapAccessories(data.get("accessories")); + String note = (String) data.get("note"); + String author = (String) data.get("author"); + String videoUrl = (String) data.get("videoUrl"); + + return new ModificationRequest(firearmId, name, code, tags, accessories, + note, author, videoUrl); + } + + private List mapAccessories(Object raw) { + if (!(raw instanceof List list)) { + return new ArrayList<>(); + } + var result = new ArrayList(); + for (var item : list) { + if (item instanceof Map map) { + result.add(new AccessoryRequest( + (String) map.get("slotName"), + (String) map.get("accessoryName"), + mapTunings(map.get("tunings")) + )); + } + } + return result; + } + + private List mapTunings(Object raw) { + if (!(raw instanceof List list)) { + return new ArrayList<>(); + } + var result = new ArrayList(); + for (var item : list) { + if (item instanceof Map map) { + result.add(new TuningRequest( + (String) map.get("tuningName"), + toDouble(map.get("tuningValue")) + )); + } + } + return result; + } + + private List toStringList(Object raw) { + if (raw instanceof List list) { + return list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + } + return new ArrayList<>(); + } + + private boolean hasTriggerLabel(List labels) { + if (labels == null) { + return false; + } + return labels.stream().anyMatch(label -> TRIGGER_LABEL.equals(label.name())); + } + + private String extractYaml(String body) { + if (body == null) { + return null; + } + var matcher = YAML_FENCE.matcher(body); + return matcher.find() ? matcher.group(1) : null; + } + + private Long toLong(Object value) { + if (value instanceof Number num) { + return num.longValue(); + } + if (value instanceof String s) { + return Long.parseLong(s); + } + return null; + } + + private Double toDouble(Object value) { + if (value instanceof Number num) { + return num.doubleValue(); + } + if (value instanceof String s) { + return Double.parseDouble(s); + } + return null; } }