From 1fa4da8205e62f2c14dfb66de30ac9d018adb5bc Mon Sep 17 00:00:00 2001 From: Conor H Date: Mon, 30 Sep 2024 02:51:42 +0100 Subject: [PATCH] #100 Integrate GitHub POST issue API call --- frontend/src/App.js | 2 + frontend/src/CreateIssue.js | 87 +++++++++++++++++++ frontend/src/RepoIssues.js | 4 +- .../com/ironoc/portfolio/client/Client.java | 2 + .../ironoc/portfolio/client/GitClient.java | 52 +++++++++-- .../ironoc/portfolio/config/Properties.java | 1 + .../portfolio/config/PropertyConfig.java | 5 ++ .../portfolio/config/PropertyConfigI.java | 2 + .../ironoc/portfolio/config/PropertyKey.java | 5 ++ .../ironoc/portfolio/config/PropertyKeyI.java | 2 + .../controller/GitProjectsController.java | 32 +++++++ .../portfolio/domain/CreateIssueDomain.java | 21 +++++ .../ironoc/portfolio/dto/CreateIssueDto.java | 32 +++++++ .../ironoc/portfolio/service/GitDetails.java | 3 + .../portfolio/service/GitDetailsService.java | 45 +++++++++- src/main/resources/application.yml | 1 + 16 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 frontend/src/CreateIssue.js create mode 100644 src/main/java/com/ironoc/portfolio/domain/CreateIssueDomain.java create mode 100644 src/main/java/com/ironoc/portfolio/dto/CreateIssueDto.java diff --git a/frontend/src/App.js b/frontend/src/App.js index faddcab..74df630 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -5,6 +5,7 @@ import About from './About'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import RepoDetails from './RepoDetails'; import RepoIssues from './RepoIssues'; +import CreateIssue from './CreateIssue'; class App extends Component { render() { @@ -16,6 +17,7 @@ class App extends Component { + diff --git a/frontend/src/CreateIssue.js b/frontend/src/CreateIssue.js new file mode 100644 index 0000000..9a4ada7 --- /dev/null +++ b/frontend/src/CreateIssue.js @@ -0,0 +1,87 @@ +import React, { Component } from 'react'; +import { Link, withRouter } from 'react-router-dom'; +import { Button, Container, Form, FormGroup, Input, Label } from 'reactstrap'; +import AppNavbar from './AppNavbar'; + +class CreateIssue extends Component { + + emptyItem = { + title: '', + body: '', + assignee: '', + labels: '' + }; + + constructor(props) { + super(props); + this.state = { + item: this.emptyItem + }; + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + async componentDidMount() { + const issue = await (await fetch(`create-issue/${this.props.match.params.repo}`)).json(); + this.setState({item: issue}); + } + + handleChange(event) { + const target = event.target; + const value = target.value; + const name = target.name; + let item = {...this.state.item}; + item[name] = value; + this.setState({item}); + } + + async handleSubmit(event) { + event.preventDefault(); + const {item} = this.state; + console.log(item); + await fetch(`/create-repo-issue/conorheffron/${this.props.match.params.repo}/`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(item) + }); + this.props.history.push(`/issues/${this.props.match.params.repo}/`); + } + + render() { + const {item} = this.state; + const title =

Create Issue

; + + return
+

+ +
+

{title}

+
+ + + + + + + + + + + + +
+ + {' '} + +
+
+
+ } +} +export default withRouter(CreateIssue); \ No newline at end of file diff --git a/frontend/src/RepoIssues.js b/frontend/src/RepoIssues.js index c082c38..b029e03 100644 --- a/frontend/src/RepoIssues.js +++ b/frontend/src/RepoIssues.js @@ -33,13 +33,15 @@ class RepoIssues extends Component { {issue.body} }); + + const repo = this.props.match.params.repo; return (



- +

GitHub Projects

diff --git a/src/main/java/com/ironoc/portfolio/client/Client.java b/src/main/java/com/ironoc/portfolio/client/Client.java index b1d21eb..db11c20 100644 --- a/src/main/java/com/ironoc/portfolio/client/Client.java +++ b/src/main/java/com/ironoc/portfolio/client/Client.java @@ -9,6 +9,8 @@ public interface Client { List callGitHubApi(String apiUri, String uri, Class type, String httpMethod); + void postGitHubApi(String apiUri, String uri, String httpMethod, String jsonBody) throws Exception; + HttpsURLConnection createConn(String url, String baseUrl, String httpMethod) throws IOException; InputStream readInputStream(HttpsURLConnection conn) throws IOException; diff --git a/src/main/java/com/ironoc/portfolio/client/GitClient.java b/src/main/java/com/ironoc/portfolio/client/GitClient.java index fc5a2e1..102f7ac 100644 --- a/src/main/java/com/ironoc/portfolio/client/GitClient.java +++ b/src/main/java/com/ironoc/portfolio/client/GitClient.java @@ -9,14 +9,10 @@ import io.micrometer.common.util.StringUtils; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; -import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import javax.net.ssl.HttpsURLConnection; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; +import java.io.*; import java.net.URL; import java.util.ArrayList; import java.util.Collections; @@ -46,7 +42,7 @@ public GitClient(PropertyConfigI propertyConfig, @Override public List callGitHubApi(String apiUri, String uri, Class type, String httpMethod) { - info("Triggering GET request: url={}", apiUri); + info("Triggering {} request: url={}", httpMethod, apiUri); List dtos = new ArrayList<>(); InputStream inputStream = null; try { @@ -77,6 +73,50 @@ public List callGitHubApi(String apiUri, String uri, Class type, Strin return dtos; } + @Override + public void postGitHubApi(String apiUri, String uri, String httpMethod, String jsonBody) throws Exception { + info("Triggering {} request: url={}", httpMethod, apiUri); + URL urlBase = new URL(uri); + String base = urlBase.getProtocol() + "://" + urlBase.getHost(); + if (!urlUtils.isValidURL(apiUri) || !apiUri.startsWith(base)) { + log.error("The url is not valid for GIT client connection, url={}", apiUri); + return; + } + URL apiUrlEndpoint = new URL(apiUri); + HttpsURLConnection conn = (HttpsURLConnection) apiUrlEndpoint.openConnection(); + String token = secretManager.getGitSecret(); + if (StringUtils.isBlank(token)) { + log.warn("GIT token not set, the lower request rate will apply"); + } else { + // TODO generate token + conn.setRequestProperty("Authorization", "Bearer " + token); + conn.setRequestProperty("Accept", "application/vnd.github.raw+json"); + conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28"); + } + conn.setRequestMethod(httpMethod); + conn.setFollowRedirects(propertyConfig.getGitFollowRedirects()); + conn.setConnectTimeout(propertyConfig.getGitTimeoutConnect()); + conn.setReadTimeout(propertyConfig.getGitTimeoutRead()); + conn.setInstanceFollowRedirects(propertyConfig.getGitInstanceFollowRedirects()); + +// conn.setDoInput(true); + conn.setDoOutput(true); +// conn.setRequestProperty("Accept", "application/json"); +// conn.setRequestProperty("Content-Type", "application/json"); + OutputStream os = conn.getOutputStream(); + os.write(jsonBody.getBytes()); + os.flush(); + +// InputStream inputStream = this.readInputStream(conn); + + info("code={}, message={}", conn.getResponseCode(), conn.getResponseMessage()); + + InputStream errorStream = conn.getErrorStream(); + String jsonResponse = this.convertInputStreamToString(errorStream); + info(jsonResponse); + this.closeConn(errorStream); + } + @Override public HttpsURLConnection createConn(String url, String baseUrl, String httpMethod) throws IOException { URL urlBase = new URL(baseUrl); diff --git a/src/main/java/com/ironoc/portfolio/config/Properties.java b/src/main/java/com/ironoc/portfolio/config/Properties.java index 2c11797..8ca2f72 100644 --- a/src/main/java/com/ironoc/portfolio/config/Properties.java +++ b/src/main/java/com/ironoc/portfolio/config/Properties.java @@ -7,6 +7,7 @@ public enum Properties { GIT_API_ENDPOINT_REPOS("com.ironoc.portfolio.github.api.endpoint.repos"), GIT_API_ENDPOINT_ISSUES("com.ironoc.portfolio.github.api.endpoint.issues"), + GIT_API_ENDPOINT_CREATE_ISSUE("com.ironoc.portfolio.github.api.endpoint.create-issue"), GIT_TIMEOUT_CONNECT ("com.ironoc.portfolio.github.timeout.connect"), GIT_TIMEOUT_READ("com.ironoc.portfolio.github.timeout.read"), GIT_INSTANCE_FOLLOW_REDIRECTS("com.ironoc.portfolio.github.instance-follow-redirects"), diff --git a/src/main/java/com/ironoc/portfolio/config/PropertyConfig.java b/src/main/java/com/ironoc/portfolio/config/PropertyConfig.java index 4cd014d..cea6462 100644 --- a/src/main/java/com/ironoc/portfolio/config/PropertyConfig.java +++ b/src/main/java/com/ironoc/portfolio/config/PropertyConfig.java @@ -25,6 +25,11 @@ public String getGitApiEndpointIssues() { return environment.getRequiredProperty(propertyKey.getGitApiEndpointIssues()); } + @Override + public String getGitApiEndpointCreateIssue() { + return environment.getRequiredProperty(propertyKey.getGitApiEndpointCreateIssue()); + } + @Override public Integer getGitTimeoutConnect() { return Integer.valueOf(environment.getRequiredProperty(propertyKey.getGitTimeoutConnect())); diff --git a/src/main/java/com/ironoc/portfolio/config/PropertyConfigI.java b/src/main/java/com/ironoc/portfolio/config/PropertyConfigI.java index e895ccc..4897417 100644 --- a/src/main/java/com/ironoc/portfolio/config/PropertyConfigI.java +++ b/src/main/java/com/ironoc/portfolio/config/PropertyConfigI.java @@ -13,4 +13,6 @@ public interface PropertyConfigI { Boolean getGitFollowRedirects(); String getGitApiEndpointIssues(); + + String getGitApiEndpointCreateIssue(); } \ No newline at end of file diff --git a/src/main/java/com/ironoc/portfolio/config/PropertyKey.java b/src/main/java/com/ironoc/portfolio/config/PropertyKey.java index ab4a751..9401db6 100644 --- a/src/main/java/com/ironoc/portfolio/config/PropertyKey.java +++ b/src/main/java/com/ironoc/portfolio/config/PropertyKey.java @@ -15,6 +15,11 @@ public String getGitApiEndpointIssues() { return Properties.GIT_API_ENDPOINT_ISSUES.getKey(); } + @Override + public String getGitApiEndpointCreateIssue() { + return Properties.GIT_API_ENDPOINT_CREATE_ISSUE.getKey(); + } + @Override public String getGitTimeoutConnect() { return Properties.GIT_TIMEOUT_CONNECT.getKey(); diff --git a/src/main/java/com/ironoc/portfolio/config/PropertyKeyI.java b/src/main/java/com/ironoc/portfolio/config/PropertyKeyI.java index 1c1351d..7ea5ece 100644 --- a/src/main/java/com/ironoc/portfolio/config/PropertyKeyI.java +++ b/src/main/java/com/ironoc/portfolio/config/PropertyKeyI.java @@ -13,4 +13,6 @@ public interface PropertyKeyI { String getGitFollowRedirects(); String getGitApiEndpointIssues(); + + String getGitApiEndpointCreateIssue(); } diff --git a/src/main/java/com/ironoc/portfolio/controller/GitProjectsController.java b/src/main/java/com/ironoc/portfolio/controller/GitProjectsController.java index 511fc95..243a147 100644 --- a/src/main/java/com/ironoc/portfolio/controller/GitProjectsController.java +++ b/src/main/java/com/ironoc/portfolio/controller/GitProjectsController.java @@ -1,5 +1,6 @@ package com.ironoc.portfolio.controller; +import com.ironoc.portfolio.domain.CreateIssueDomain; import com.ironoc.portfolio.domain.RepositoryDetailDomain; import com.ironoc.portfolio.domain.RepositoryIssueDomain; import com.ironoc.portfolio.dto.RepositoryDetailDto; @@ -17,6 +18,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -64,6 +67,34 @@ public ResponseEntity> getIssuesByUsernameAndRepoPat return getIssuesByUsernameAndRepo(request, username, repository); } + @PostMapping(value = {"/create-repo-issue/{username}/{repository}/"}, consumes = MediaType.APPLICATION_JSON_VALUE, + produces= MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> postIssue(@PathVariable(value = "username") String username, + @PathVariable(value = "repository") String repository, + @RequestBody CreateIssueDomain createIssue) { + // user & repo name validation (must contain only letters, numbers and/or dash chars) + String userId = ""; + String repo = ""; + if (!StringUtils.isNoneBlank(username, repository)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Collections.emptyList()); + } else if (!StringUtils.isAlphanumericSpace(sanitizeValue(username) + .replaceAll("-", " "))) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Collections.emptyList()); + } else if (!StringUtils.isAlphanumericSpace(sanitizeValue(repository) + .replaceAll("-", " "))) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Collections.emptyList()); + } else { + List pathVars = sanitizeValues(username, repository); + userId = pathVars.get(0); + repo = pathVars.get(1); + } + + // TODO post and save issue + gitDetailsService.createIssue(userId, repo, createIssue); + return ResponseEntity.status(HttpStatus.OK) + .body(gitDetailsService.mapIssuesToResponse(Collections.emptyList())); + } + private ResponseEntity> getIssuesByUsernameAndRepo(HttpServletRequest request, String username, String repository) { @@ -83,6 +114,7 @@ private ResponseEntity> getIssuesByUsernameAndRepo(H userId = pathVars.get(0); repo = pathVars.get(1); } + info("Github list issues by username={} and repo={} for request, host={}, uri={}, user-agent={}", userId, repo, request.getHeader("host"), diff --git a/src/main/java/com/ironoc/portfolio/domain/CreateIssueDomain.java b/src/main/java/com/ironoc/portfolio/domain/CreateIssueDomain.java new file mode 100644 index 0000000..bb5f84c --- /dev/null +++ b/src/main/java/com/ironoc/portfolio/domain/CreateIssueDomain.java @@ -0,0 +1,21 @@ +package com.ironoc.portfolio.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Data +public class CreateIssueDomain { + + private String title; + + private String body; + + private String assignee; + + private String labels; +} diff --git a/src/main/java/com/ironoc/portfolio/dto/CreateIssueDto.java b/src/main/java/com/ironoc/portfolio/dto/CreateIssueDto.java new file mode 100644 index 0000000..2b97ea4 --- /dev/null +++ b/src/main/java/com/ironoc/portfolio/dto/CreateIssueDto.java @@ -0,0 +1,32 @@ +package com.ironoc.portfolio.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Data +public class CreateIssueDto { + + private String owner; + + private String repo; + + private String title; + + private String body; + + private List assignees; + + private List labels; + + private Integer milestone; + + private Map headers; +} diff --git a/src/main/java/com/ironoc/portfolio/service/GitDetails.java b/src/main/java/com/ironoc/portfolio/service/GitDetails.java index 8df6753..4e1576a 100644 --- a/src/main/java/com/ironoc/portfolio/service/GitDetails.java +++ b/src/main/java/com/ironoc/portfolio/service/GitDetails.java @@ -1,5 +1,6 @@ package com.ironoc.portfolio.service; +import com.ironoc.portfolio.domain.CreateIssueDomain; import com.ironoc.portfolio.domain.RepositoryDetailDomain; import com.ironoc.portfolio.domain.RepositoryIssueDomain; import com.ironoc.portfolio.dto.RepositoryDetailDto; @@ -19,5 +20,7 @@ List mapResponseToRepositories( List getIssues(String userId, String repo); + void createIssue(String userId, String repo, CreateIssueDomain createIssue); + List mapIssuesToResponse(List repositoryIssueDtos); } diff --git a/src/main/java/com/ironoc/portfolio/service/GitDetailsService.java b/src/main/java/com/ironoc/portfolio/service/GitDetailsService.java index 3a34667..acec3a1 100644 --- a/src/main/java/com/ironoc/portfolio/service/GitDetailsService.java +++ b/src/main/java/com/ironoc/portfolio/service/GitDetailsService.java @@ -1,10 +1,14 @@ package com.ironoc.portfolio.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; import com.ironoc.portfolio.client.Client; import com.ironoc.portfolio.config.PropertyConfigI; +import com.ironoc.portfolio.domain.CreateIssueDomain; import com.ironoc.portfolio.domain.RepositoryDetailDomain; import com.ironoc.portfolio.domain.RepositoryIssueDomain; +import com.ironoc.portfolio.dto.CreateIssueDto; import com.ironoc.portfolio.dto.RepositoryDetailDto; import com.ironoc.portfolio.dto.RepositoryIssueDto; import com.ironoc.portfolio.job.GitDetailsJob; @@ -16,9 +20,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.util.UriComponentsBuilder; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; @Service @@ -98,7 +100,7 @@ public List mapResponseToRepositories( @Override public List getIssues(String userId, String repo) { - // further end-point validation (contains User ID) + // further end-point validation (contains User ID & repository) String uri = propertyConfig.getGitApiEndpointIssues(); String apiUri = UriComponentsBuilder.fromHttpUrl(uri) .buildAndExpand(userId, repo) @@ -111,6 +113,41 @@ public List getIssues(String userId, String repo) { return gitClient.callGitHubApi(apiUri, uri, RepositoryIssueDto.class, HttpMethod.GET.name()); } + @Override + public void createIssue(String userId, String repo, CreateIssueDomain createIssue) { + // further end-point validation (contains User ID & repository) + String uri = propertyConfig.getGitApiEndpointCreateIssue(); + String apiUri = UriComponentsBuilder.fromHttpUrl(uri) + .buildAndExpand(userId, repo) + .toUriString(); + if (StringUtils.isBlank(apiUri) | StringUtils.isBlank(apiUri) + | !urlUtils.isValidURL(apiUri)) { + warn("URL is not valid: url={}", apiUri); + return; + } + + try { +// '{"title":"Found a bug","body":"I'\''m having a problem with this.","assignees":["octocat"],"milestone":1,"labels":["bug"]}' + Map headers = new HashMap(); + headers.put("X-GitHub-Api-Version", "2022-11-28"); + CreateIssueDto createIssueDto = CreateIssueDto.builder() + .owner(userId) + .repo(repo) +// .assignees(Arrays.asList(createIssue.getAssignee())) +// .labels(Arrays.asList("bug")) +// .milestone(1) + .title(createIssue.getTitle()) + .body(createIssue.getBody()) + .headers(headers) + .build(); + ObjectWriter ow = new ObjectMapper().writer().withDefaultPrettyPrinter(); + String json = ow.writeValueAsString(createIssueDto); + gitClient.postGitHubApi(apiUri, uri, HttpMethod.POST.name(), json); + } catch (Exception ex) { + error("Unexpected error occurred creating GIT issue", ex); + } + } + @Override public List mapIssuesToResponse(List repositoryIssueDtos) { return repositoryIssueDtos.stream() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2bee1f4..e09efc9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,6 +11,7 @@ com: endpoint: repos: https://api.github.com/users/{username}/repos issues: https://api.github.com/repos/{username}/{repo}/issues + create-issue: https://api.github.com/repos/{username}/{repo}/issues spring: mvc: