diff --git a/backend/src/main/java/io/diveni/backend/config/RestTemplateConfig.java b/backend/src/main/java/io/diveni/backend/config/RestTemplateConfig.java new file mode 100644 index 000000000..c91b46481 --- /dev/null +++ b/backend/src/main/java/io/diveni/backend/config/RestTemplateConfig.java @@ -0,0 +1,15 @@ +package io.diveni.backend.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder){ + return builder.build(); + } +} diff --git a/backend/src/main/java/io/diveni/backend/controller/ErrorMessages.java b/backend/src/main/java/io/diveni/backend/controller/ErrorMessages.java index d84bc7d7d..c08fc91a1 100644 --- a/backend/src/main/java/io/diveni/backend/controller/ErrorMessages.java +++ b/backend/src/main/java/io/diveni/backend/controller/ErrorMessages.java @@ -30,5 +30,10 @@ public class ErrorMessages { public static String failedToRetrieveUsernameErrorMessage = "failed to retrieve current user"; + public static String noPullRequestsMatchingCriteriaMessage = "There are no pull requests matching the criteria"; + public static String invalidPullRequestStateMessage = "Invalid pull request state. Valid states: open, closed, all"; + public static String maxPullRequestsPerPageMessage = "Max 100 pull requests can be returned per page"; + public static String serverLimitReachedMessage = "API rate limit reached"; + private ErrorMessages() {} } diff --git a/backend/src/main/java/io/diveni/backend/controller/NewsController.java b/backend/src/main/java/io/diveni/backend/controller/NewsController.java new file mode 100644 index 000000000..10694ef36 --- /dev/null +++ b/backend/src/main/java/io/diveni/backend/controller/NewsController.java @@ -0,0 +1,41 @@ +package io.diveni.backend.controller; + +import io.diveni.backend.model.news.PullRequest; +import io.diveni.backend.service.news.GithubApiService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +@RequestMapping("/news") +public class NewsController { + + public static final String STATE_PARAM = "state"; + public static final String PAGE_PARAM = "page"; + public static final String PER_PAGE_PARAM = "per_page"; + public static final String IS_MERGED = "is_merged"; + public static final String SORT = "sort"; + public static final String SORT_DIRECTION = "direction"; + + private GithubApiService service; + + @Autowired + public NewsController(GithubApiService service) { + this.service = service; + } + + @GetMapping("/pull-requests") + public ResponseEntity getPullRequests(@RequestParam(name = STATE_PARAM, defaultValue = "closed") String state, + @RequestParam(name = SORT, defaultValue = "created") String sort, + @RequestParam(name = SORT_DIRECTION, defaultValue = "asc") String direction, + @RequestParam(name = IS_MERGED, defaultValue = "false") Boolean isMerged, + @RequestParam(name = PER_PAGE_PARAM, defaultValue = "50") Integer perPage, + @RequestParam(name = PAGE_PARAM, defaultValue = "1") Integer page) { + + return new ResponseEntity<>(service.getPullRequests(state,sort,direction, isMerged, perPage, page), HttpStatus.OK); + } +} diff --git a/backend/src/main/java/io/diveni/backend/model/news/PullRequest.java b/backend/src/main/java/io/diveni/backend/model/news/PullRequest.java new file mode 100644 index 000000000..f64bbdc32 --- /dev/null +++ b/backend/src/main/java/io/diveni/backend/model/news/PullRequest.java @@ -0,0 +1,37 @@ +package io.diveni.backend.model.news; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PullRequest { + + private int number; + + @JsonProperty("html_url") + private String htmlUrl; + + private String title; + + @JsonProperty("merged_at") + private LocalDateTime mergedAt; + + @JsonProperty("updated_at") + private LocalDateTime updatedAt; + + @JsonProperty("user_type") + private String userType; + + @JsonProperty("user") + private void unpackNestedProperty(Map map) { + userType = map.get("type"); + } + +} diff --git a/backend/src/main/java/io/diveni/backend/service/news/GithubApiService.java b/backend/src/main/java/io/diveni/backend/service/news/GithubApiService.java new file mode 100644 index 000000000..d461c8fe6 --- /dev/null +++ b/backend/src/main/java/io/diveni/backend/service/news/GithubApiService.java @@ -0,0 +1,87 @@ +package io.diveni.backend.service.news; + +import io.diveni.backend.controller.ErrorMessages; +import io.diveni.backend.model.news.PullRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Arrays; +import java.util.List; + +import static io.diveni.backend.controller.NewsController.*; + +@Service +public class GithubApiService { + + private static final List VALID_STATES = List.of("open", "closed", "all"); + private static final int MAX_PER_PAGE = 100; + + private static final String API_VERSION_HEADER = "X-GitHub-Api-Version"; + + @Value("${vsc.github.api.pr.get}") + private String url; + + @Value("${vsc.github.access-token}") + private String authToken; + + @Value("${vsc.github.api.version}") + private String apiVersion; + + private static final Logger logger = LoggerFactory.getLogger(GithubApiService.class); + private RestTemplate client; + + @Autowired + public GithubApiService(RestTemplate client) { + this.client = client; + } + + public PullRequest[] getPullRequests(String state, String sort, String direction, boolean isMerged, int perPage, int page) { + + HttpHeaders headers = new HttpHeaders(); + if (authToken != null && !authToken.equals("null")) { + headers.setBearerAuth(authToken); + } + headers.set(API_VERSION_HEADER, apiVersion); + + HttpEntity entity = new HttpEntity<>(null, headers); + + if (!VALID_STATES.contains(state)) { + logger.warn("Bad request: {}", ErrorMessages.noPullRequestsMatchingCriteriaMessage); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ErrorMessages.invalidPullRequestStateMessage); + } + + if (perPage > MAX_PER_PAGE) { + logger.warn("Bad request: {}", ErrorMessages.maxPullRequestsPerPageMessage); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ErrorMessages.maxPullRequestsPerPageMessage); + } + + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(url) + .queryParam(STATE_PARAM, state) + .queryParam(PAGE_PARAM, page) + .queryParam(PER_PAGE_PARAM, perPage) + .queryParam(SORT, sort).queryParam(SORT_DIRECTION, direction); + + ResponseEntity data; + try { + data = client.exchange(builder.toUriString(), HttpMethod.GET, entity, PullRequest[].class); + } catch (Exception ex) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, ErrorMessages.serverLimitReachedMessage); + } + PullRequest[] body = data.getBody(); + + if (isMerged) { + body = Arrays.stream(body).filter(e -> e.getMergedAt() != null).toArray(PullRequest[]::new); + } + + logger.debug("getPullRequests({},{},{})", state, perPage, page); + + return body; + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 183a75769..07b52df41 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,3 +1,10 @@ spring.profiles.active=dev server.error.include-message=always spring.config.import=optional:file:./.env[.properties] + +vsc.github.access-token = ${GITHUB_ACCESS_TOKEN:null} +vsc.github.owner=Sybit-Education +vsc.github.repository = Diveni +vsc.github.api.version = 2022-11-28 +vsc.github.api=https://api.github.com +vsc.github.api.pr.get = ${vsc.github.api}/repos/${vsc.github.owner}/${vsc.github.repository}/pulls diff --git a/backend/src/test/java/io/diveni/backend/controller/NewsControllerTest.java b/backend/src/test/java/io/diveni/backend/controller/NewsControllerTest.java new file mode 100644 index 000000000..915433a70 --- /dev/null +++ b/backend/src/test/java/io/diveni/backend/controller/NewsControllerTest.java @@ -0,0 +1,111 @@ +package io.diveni.backend.controller; + +import io.diveni.backend.model.news.PullRequest; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +public class NewsControllerTest { + + private static final String path = "/news/pull-requests"; + + @Value("${vsc.github.api.pr.get}") + private String url; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private RestTemplate client; + + @Test + public void getPRs_InvalidState_returnsException() throws Exception { + String invalidState = "invalid"; + MockHttpServletRequestBuilder requestBuilder = get(path); + requestBuilder.param(NewsController.STATE_PARAM, invalidState); + requestBuilder.param(NewsController.PAGE_PARAM, "1"); + requestBuilder.param(NewsController.PER_PAGE_PARAM, "1"); + this.mockMvc.perform(requestBuilder) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(status().reason(ErrorMessages.invalidPullRequestStateMessage)); + } + + @Test + public void getPRs_InvalidPerPage_returnsException() throws Exception { + MockHttpServletRequestBuilder requestBuilder = get(path); + requestBuilder.param(NewsController.STATE_PARAM, "closed"); + requestBuilder.param(NewsController.PAGE_PARAM, "1"); + requestBuilder.param(NewsController.PER_PAGE_PARAM, "5000"); + this.mockMvc.perform(requestBuilder) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(status().reason(ErrorMessages.maxPullRequestsPerPageMessage)); + } + + + @Test + public void getPRs_Valid_returnsPrs() throws Exception { + PullRequest[] data = new PullRequest[]{ + new PullRequest(403, "test2.com", "test2", LocalDateTime.now(), LocalDateTime.now(), "user"), + new PullRequest(404, "test.com", "test", LocalDateTime.now(), LocalDateTime.now(), "user") + }; + + when(client.exchange(contains(url), eq(HttpMethod.GET), any(), eq(PullRequest[].class))) + .thenReturn(ResponseEntity.ok(data.clone())); + + MockHttpServletRequestBuilder requestBuilder = get(path); + requestBuilder.param(NewsController.STATE_PARAM, "closed"); + requestBuilder.param(NewsController.PAGE_PARAM, "1"); + requestBuilder.param(NewsController.PER_PAGE_PARAM, "10"); + + this.mockMvc.perform(requestBuilder) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()") + .value(Matchers.is(2))) + .andExpect(jsonPath("$[0].number").value(data[0].getNumber())) + .andExpect(jsonPath("$[0]['html_url']").value(data[0].getHtmlUrl())) + .andExpect(jsonPath("$[0]['title']").value(data[0].getTitle())) + .andExpect(jsonPath("$[0]['user_type']").value(data[0].getUserType())); + + } + + @Test + public void getPRs_Valid_returnsServiceUnavailable() throws Exception { + + when(client.exchange(contains(url), eq(HttpMethod.GET), any(), eq(PullRequest[].class))) + .thenThrow(new RestClientException("Some error")); + + MockHttpServletRequestBuilder requestBuilder = get(path); + requestBuilder.param(NewsController.STATE_PARAM, "closed"); + requestBuilder.param(NewsController.PAGE_PARAM, "1"); + requestBuilder.param(NewsController.PER_PAGE_PARAM, "10"); + + this.mockMvc.perform(requestBuilder) + .andDo(print()) + .andExpect(status().isServiceUnavailable()) + .andExpect(status().reason(ErrorMessages.serverLimitReachedMessage)); + } +} diff --git a/backend/src/test/java/io/diveni/backend/model/news/PullRequestTest.java b/backend/src/test/java/io/diveni/backend/model/news/PullRequestTest.java new file mode 100644 index 000000000..01650785b --- /dev/null +++ b/backend/src/test/java/io/diveni/backend/model/news/PullRequestTest.java @@ -0,0 +1,19 @@ +package io.diveni.backend.model.news; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +public class PullRequestTest { + + @Test + public void equal_works(){ + LocalDateTime now = LocalDateTime.now(); + PullRequest first = new PullRequest(1,"test.com","First",now, now,"Admin"); + PullRequest second = new PullRequest(1,"test.com","First",now,now, "Admin"); + PullRequest third = new PullRequest(2,"test.com","Second",LocalDateTime.now(),LocalDateTime.now(),"Usr"); + Assertions.assertEquals(first,second); + Assertions.assertNotEquals(first,third); + } +} diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties index f841722a2..2f635c195 100644 --- a/backend/src/test/resources/application.properties +++ b/backend/src/test/resources/application.properties @@ -1 +1,8 @@ spring.profiles.active=test + +vsc.github.access-token = ${GITHUB_ACCESS_TOKEN:null} +vsc.github.owner=Sybit-Education +vsc.github.repository = Diveni +vsc.github.api.version = 2022-11-28 +vsc.github.api=https://api.github.com +vsc.github.api.pr.get = ${vsc.github.api}/repos/${vsc.github.owner}/${vsc.github.repository}/pulls diff --git a/frontend/src/components/navigation/FooterBar.vue b/frontend/src/components/navigation/FooterBar.vue index 8fa6096fa..230430b7a 100644 --- a/frontend/src/components/navigation/FooterBar.vue +++ b/frontend/src/components/navigation/FooterBar.vue @@ -4,7 +4,8 @@
© 2022-{{ currentYear }} Diveni | {{ $t("general.about.docs") }} | - {{ $t("general.about.label") }} + {{ $t("general.about.label") }} | + {{ $t("page.landing.news.buttons.info.label") }}
Made with ❤️ by Diveni Development Team
diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 1ca86e24a..95adc17bc 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -58,6 +58,10 @@ class Constants { memberUpdateCloseSession = "SESSION_CLOSED"; + newsPageSize = 9; + + botUserType = "Bot"; + // eslint-disable-next-line class-methods-use-this public joinSessionRoute(sessionID: string) { return `/sessions/${sessionID}/join`; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index c859429ad..cfc59d238 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -126,6 +126,13 @@ "lastMonthTitle": "Last Month", "activeTitle": "Currently" } + }, + "news": { + "buttons": { + "info": { + "label": "What's new" + } + } } }, "join": { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index aab4a9e91..b9344055f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -53,6 +53,11 @@ const routes: Array = [ name: "AboutPage", component: () => import(/* webpackChunkName: "about" */ "../views/AboutPage.vue"), }, + { + path: "/whats-new", + name: "WhatsNewPage", + component: () => import("../views/WhatsNewPage.vue"), + }, { path: "*", component: LandingPage, diff --git a/frontend/src/services/api.service.ts b/frontend/src/services/api.service.ts index 22d9151d1..a64cf11ab 100644 --- a/frontend/src/services/api.service.ts +++ b/frontend/src/services/api.service.ts @@ -1,5 +1,5 @@ import constants from "@/constants"; -import { JiraRequestTokenDto, JiraResponseCodeDto } from "@/types"; +import { JiraRequestTokenDto, JiraResponseCodeDto, PullRequestDto } from "@/types"; import axios, { AxiosResponse } from "axios"; class ApiService { @@ -108,6 +108,31 @@ class ApiService { }; return response; } + + public async getPullRequests( + state, + sort, + direction, + isMerged, + page, + pageSize + ): Promise { + const queryParams = { + state: state, + is_merged: isMerged, + per_page: pageSize, + page: page, + sort: sort, + direction: direction, + }; + const response = await axios.get(`${constants.backendURL}/news/pull-requests`, { + headers: { + "Content-Type": "application/json", + }, + params: queryParams, + }); + return response.data; + } } export default new ApiService(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b7fa2d1cf..5e12dfd54 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -42,3 +42,12 @@ export interface JiraRequestTokenDto { token: string; url: string; } + +export interface PullRequestDto { + number: number; + html_url: string; + title: string; + merged_at: string; + user_type: string; + updated_at: string; +} diff --git a/frontend/src/utils/dateUtil.ts b/frontend/src/utils/dateUtil.ts new file mode 100644 index 000000000..cf0886211 --- /dev/null +++ b/frontend/src/utils/dateUtil.ts @@ -0,0 +1,17 @@ +import i18n from "@/i18n"; + +class DateUtil { + public convertDate(dateTime: string): string { + const date = dateTime.slice(0, dateTime.indexOf("T")); + const dateParts = date.split("-").map(Number); + const [year, month, day] = dateParts; + const options = { + year: "numeric", + month: "long", + day: "numeric", + } as const; + return new Intl.DateTimeFormat(i18n.locale, options).format(new Date(year, month - 1, day)); + } +} + +export default new DateUtil(); diff --git a/frontend/src/views/WhatsNewPage.vue b/frontend/src/views/WhatsNewPage.vue new file mode 100644 index 000000000..247cce49c --- /dev/null +++ b/frontend/src/views/WhatsNewPage.vue @@ -0,0 +1,143 @@ + + + +