diff --git a/src/config/feature-flags.ts b/src/config/feature-flags.ts index b35fb43b96..d64c679173 100644 --- a/src/config/feature-flags.ts +++ b/src/config/feature-flags.ts @@ -24,6 +24,7 @@ export enum BooleanFlags { SKIP_REQUESTED_REVIEWERS = "skip-requested-reviewers", ENABLE_SUBSCRIPTION_DEFERRED_INSTALL = "enable-subscription-deferred-install", EARLY_EXIT_ON_VALIDATION_FAILED = "early-exit-on-validation-failed", + ENABLE_CONNECTED_REPOS_VIEW="enable-connected-repos-view", USE_REST_API_FOR_DISCOVERY = "use-rest-api-for-discovery-again", ENABLE_GENERIC_CONTAINERS = "enable-generic-containers", ENABLE_GITHUB_SECURITY_IN_JIRA = "enable-github-security-in-jira", diff --git a/src/config/interfaces.ts b/src/config/interfaces.ts index c26b431d4c..8655ebdab5 100644 --- a/src/config/interfaces.ts +++ b/src/config/interfaces.ts @@ -38,6 +38,7 @@ export interface AppInstallation extends Octokit.AppsGetInstallationResponse { syncWarning?: string; totalNumberOfRepos?: number; numberOfSyncedRepos?: number; + subscriptionId?: number; backfillSince?: Date; failedSyncErrors?: Record jiraHost: string; diff --git a/src/models/reposyncstate.ts b/src/models/reposyncstate.ts index 83b1e1c445..4df88acb70 100644 --- a/src/models/reposyncstate.ts +++ b/src/models/reposyncstate.ts @@ -179,7 +179,7 @@ export class RepoSyncState extends Model implements RepoSyncStateProperties { return RepoSyncState.create(merge(values, { subscriptionId: subscription.id }), options); } - private static async countSubscriptionRepos(subscription: Subscription, options: CountOptions = {}): Promise { + static async countSubscriptionRepos(subscription: Subscription, options: CountOptions = {}): Promise { return RepoSyncState.count(merge(options, { where: { subscriptionId: subscription.id diff --git a/src/models/subscription.ts b/src/models/subscription.ts index d2d7bef392..4f2487117c 100644 --- a/src/models/subscription.ts +++ b/src/models/subscription.ts @@ -45,7 +45,7 @@ export class Subscription extends Model { numberOfSyncedRepos?: number; repositoryCursor?: string; repositoryStatus?: TaskStatus; - gitHubAppId: number | undefined; + gitHubAppId: number | undefined; // the primary key (id) of GitHubServerApp avatarUrl: string | undefined; isSecurityPermissionsAccepted: boolean; diff --git a/src/routes/jira/atlassian-connect/jira-atlassian-connect-get.ts b/src/routes/jira/atlassian-connect/jira-atlassian-connect-get.ts index 1997e66dd0..db181caa35 100644 --- a/src/routes/jira/atlassian-connect/jira-atlassian-connect-get.ts +++ b/src/routes/jira/atlassian-connect/jira-atlassian-connect-get.ts @@ -219,6 +219,14 @@ const modules = { url: "/spa", location: "none", conditions: adminCondition + }, { + url: "/jira/subscription/{ac.subscriptionId}/repos?pageNumber={ac.pageNumber}&repoName={ac.repoName}&syncStatus={ac.syncStatus}", + name: { + value: "Sync status" + }, + conditions: adminCondition, + key: "gh-addon-subscription-repos", + location: "none" } ], webSections: [ diff --git a/src/routes/jira/jira-connected-repos-get.test.ts b/src/routes/jira/jira-connected-repos-get.test.ts new file mode 100644 index 0000000000..6682da3971 --- /dev/null +++ b/src/routes/jira/jira-connected-repos-get.test.ts @@ -0,0 +1,191 @@ +import { getFrontendApp } from "~/src/app"; +import { Installation } from "models/installation"; +import { createQueryStringHash, encodeSymmetric } from "atlassian-jwt"; +import { getLogger } from "config/logger"; +import { Subscription } from "models/subscription"; +import { DatabaseStateCreator } from "test/utils/database-state-creator"; +import supertest from "supertest"; +import { booleanFlag, BooleanFlags } from "config/feature-flags"; +import { when } from "jest-when"; +import { RepoSyncState } from "models/reposyncstate"; + +jest.mock("config/feature-flags"); + +describe("jira-connected-repos-get", () => { + + let app; + let installation: Installation; + let subscription: Subscription; + let repoSyncState: RepoSyncState; + const generateJwt = async (subscriptionId: number, query: any = {}) => { + return encodeSymmetric({ + qsh: createQueryStringHash({ + method: "GET", + pathname: `/jira/subscription/${subscriptionId}/repos`, + query + }, false), + iss: installation.plainClientKey, + sub: "myAccountId" + }, await installation.decrypt("encryptedSharedSecret", getLogger("test"))); + }; + + beforeEach(async () => { + app = getFrontendApp(); + const result = (await new DatabaseStateCreator().withActiveRepoSyncState().create()); + installation = result.installation; + subscription = result.subscription; + repoSyncState = result.repoSyncState!; + + when(booleanFlag).calledWith(BooleanFlags.JIRA_ADMIN_CHECK).mockResolvedValue(true); + }); + + it("should return 403 when not an admin", async () => { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id + 1}/repos`) + .set("authorization", `JWT ${await generateJwt(subscription.id + 1)}`); + expect(resp.status).toStrictEqual(403); + }); + + it("should return 401 when no JWT was provided", async () => { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id + 1}/repos`); + expect(resp.status).toStrictEqual(401); + }); + + describe("admin and JWT are OK", () => { + beforeEach(() => { + const payload = { + accountId: "myAccountId", + globalPermissions: [ + "ADMINISTER" + ] + }; + jiraNock + .post("/rest/api/latest/permissions/check", payload) + .reply(200, { globalPermissions: ["ADMINISTER"] }); + }); + + it("should return 400 when no subscription was found", async () => { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id + 1}/repos`) + .set("authorization", `JWT ${await generateJwt(subscription.id + 1)}`); + expect(resp.status).toStrictEqual(400); + }); + + it("should return 400 if the subscription belongs to a different user", async () => { + const result = await new DatabaseStateCreator().forJiraHost("https://another-one.atlassian.net").create(); + const resp = await supertest(app) + .get(`/jira/subscription/${result.subscription.id}/repos`) + .set("authorization", `JWT ${await generateJwt(result.subscription.id)}`); + expect(resp.status).toStrictEqual(400); + }); + + it("should return 400 when unknown filtered status was provided", async () => { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id}/repos?syncStatus=blah`) + .set("authorization", `JWT ${await generateJwt(subscription.id, { syncStatus: "blah" })}`); + expect(resp.status).toStrictEqual(400); + }); + + it("should return 400 when a page size is too great", async () => { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id}/repos?pageSize=50000`) + .set("authorization", `JWT ${await generateJwt(subscription.id, { pageSize: "50000" })}`); + expect(resp.status).toStrictEqual(400); + }); + + describe("happy paths", () => { + beforeEach(async () => { + const newRepoSyncStatesData: any[] = []; + for (let newRepoStateNo = 1; newRepoStateNo < 50; newRepoStateNo++) { + const newRepoSyncState = { ...repoSyncState.dataValues }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete newRepoSyncState["id"]; + delete newRepoSyncState["commitStatus"]; + delete newRepoSyncState["branchStatus"]; + newRepoSyncState["repoId"] = repoSyncState.repoId + newRepoStateNo; + newRepoSyncState["repoName"] = repoSyncState.repoName + newRepoStateNo; + newRepoSyncState["repoFullName"] = repoSyncState.repoFullName + String(newRepoStateNo).padStart(3, "0"); + if (newRepoStateNo % 3 == 1) { + newRepoSyncState["commitStatus"] = "complete"; + newRepoSyncState["branchStatus"] = "complete"; + newRepoSyncState["pullStatus"] = "complete"; + newRepoSyncState["buildStatus"] = "complete"; + newRepoSyncState["deploymentStatus"] = "complete"; + } else if (newRepoStateNo % 3 == 2) { + newRepoSyncState["commitStatus"] = "failed"; + newRepoSyncState["branchStatus"] = "complete"; + newRepoSyncState["pullStatus"] = "complete"; + newRepoSyncState["buildStatus"] = "complete"; + newRepoSyncState["deploymentStatus"] = "failed"; + } + newRepoSyncStatesData.push(newRepoSyncState); + } + await RepoSyncState.bulkCreate(newRepoSyncStatesData); + }); + + it("should return the first page of repos by default without any filters", async ()=> { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id}/repos`) + .set("authorization", `JWT ${await generateJwt(subscription.id)}`); + expect(resp.status).toStrictEqual(200); + expect(resp.text).toContain(""); + expect(resp.text).not.toContain(""); + expect(resp.text).toContain("test-repo-name006"); + expect(resp.text).toContain(" { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id}/repos?repoName=est-repo-name048`) + .set("authorization", `JWT ${await generateJwt(subscription.id, { repoName: "est-repo-name048" })}`); + expect(resp.status).toStrictEqual(200); + expect(resp.text).toContain("test-repo-name048"); + expect(resp.text).not.toContain(""); + }); + + it("should correctly apply all status filter", async () => { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id}/repos?syncStatus=all`) + .set("authorization", `JWT ${await generateJwt(subscription.id, { syncStatus: "all" })}`); + expect(resp.status).toStrictEqual(200); + expect(resp.text).toContain(" { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id}/repos?syncStatus=pending`) + .set("authorization", `JWT ${await generateJwt(subscription.id, { syncStatus: "pending" })}`); + expect(resp.status).toStrictEqual(200); + expect(resp.text).toContain(" { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id}/repos?syncStatus=failed`) + .set("authorization", `JWT ${await generateJwt(subscription.id, { syncStatus: "failed" })}`); + expect(resp.status).toStrictEqual(200); + expect(resp.text).not.toContain(" { + const resp = await supertest(app) + .get(`/jira/subscription/${subscription.id}/repos?pageNumber=2`) + .set("authorization", `JWT ${await generateJwt(subscription.id, { pageNumber: "2" })}`); + expect(resp.status).toStrictEqual(200); + expect(resp.text).not.toContain("test-repo-name006"); + expect(resp.text).toContain("test-repo-name016"); + }); + }); + }); + +}); diff --git a/src/routes/jira/jira-connected-repos-get.ts b/src/routes/jira/jira-connected-repos-get.ts new file mode 100644 index 0000000000..16950607ec --- /dev/null +++ b/src/routes/jira/jira-connected-repos-get.ts @@ -0,0 +1,212 @@ +import { NextFunction, Request, Response } from "express"; +import { Op } from "sequelize"; +import { RepoSyncState } from "~/src/models/reposyncstate"; +import { Subscription, TaskStatus } from "~/src/models/subscription"; +import { sequelize } from "models/sequelize"; + +interface Page { + pageNum: number; + isCurrentPage: boolean; +} + +const mapFilterSyncStatusToQueryCondition = (filterStatusField: string | undefined) => { + if (!filterStatusField || filterStatusField === "all") { + return {}; + } + if (filterStatusField === "pending") { + return { + [Op.or]: [ + { branchStatus: "pending" }, + { branchStatus: null }, + { commitStatus: "pending" }, + { commitStatus: null }, + { pullStatus: "pending" }, + { pullStatus: null }, + { buildStatus: "pending" }, + { buildStatus: null }, + { deploymentStatus: "pending" }, + { deploymentStatus: null } + ] + }; + } else if (filterStatusField == "failed") { + return { + [Op.or]: [ + { branchStatus: "failed" }, + { commitStatus: "failed" }, + { pullStatus: "failed" }, + { buildStatus: "failed" }, + { deploymentStatus: "failed" } + ] + }; + } + return undefined; +}; + +export const JiraConnectedReposGet = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + + try { + const { jiraHost, nonce } = res.locals; + const subscriptionId = Number(req.params.subscriptionId); + const pageNumber = Number(req.query.pageNumber) || 1; + const pageSize = Number(req.query.pageSize) || 10; + const filterRepoName = (req.query.repoName || "") as string; + const filterSyncStatus = (req.query.syncStatus || undefined) as (string | undefined); + + if (!subscriptionId) { + req.log.error("Missing Subscription ID"); + res.status(400).send("Missing Subscription ID"); + return; + } + + if (pageSize > 100) { + req.log.error("pageSize cannot be larger than 100"); + res.status(400).send("pageSize cannot be larger than 100"); + return; + } + + const subscription = await Subscription.findByPk(subscriptionId); + + if (!subscription || subscription.jiraHost !== jiraHost) { + req.log.error("Missing Subscription"); + res.status(400).send("Missing Subscription"); + return; + } + + const syncStatusCondition = mapFilterSyncStatusToQueryCondition(filterSyncStatus); + if (syncStatusCondition === undefined) { + req.log.error({ filterStatusField: filterSyncStatus }, "invalid status field"); + res.status(400).send("invalid status field"); + return; + } + + const repoFilterCondition = { + repoFullName: { + [Op.like]: sequelize.literal(sequelize.escape(`%${filterRepoName}%`)) + } + }; + + const filterCondition = { + [Op.and]: [ + repoFilterCondition, + syncStatusCondition + ] + }; + + const reposCount = await RepoSyncState.countSubscriptionRepos(subscription, { + where: filterCondition + }); + + const offset = pageNumber == 1 ? 0 : (pageNumber - 1) * pageSize; + + const repoSyncStates = await RepoSyncState.findAllFromSubscription(subscription, { + where: filterCondition, + limit: pageSize, + order: [["repoFullName", "ASC"]], + offset + }); + const repos = repoSyncStates.map((repoSyncState) => { + return { + name: repoSyncState.repoFullName, + syncStatus: mapTaskStatus(getSyncStatus(repoSyncState)), + branchStatus: repoSyncState?.branchStatus, + commitStatus: repoSyncState?.commitStatus, + pullStatus: repoSyncState?.pullStatus, + buildStatus: repoSyncState?.buildStatus, + deploymentStatus: repoSyncState?.deploymentStatus, + failedCode: repoSyncState.failedCode + }; + }); + + res.render("jira-connected-repos.hbs", { + host: jiraHost, + repos: repos, + subscriptionId, + csrfToken: req.csrfToken(), + nonce, + ...getPaginationState(pageNumber, pageSize, reposCount) + }); + + } catch (err) { + req.log.warn({ err }, "Failed to render connected repos"); + return next(new Error(`Failed to render connected repos: ${err}`)); + } +}; + +const getPaginationState = (page: number, pageSize: number, reposCount: number) => { + + const totalPages = Math.ceil(reposCount / pageSize); + const hasPrevPage = page > 1; + const prevPageNum = page - 1; + const hasNextPage = page < totalPages; + const nextPageNum = page + 1; + + const pages = getPaginationNumbers(page, totalPages); + + return { + page, + totalPages, + hasPrevPage, + prevPageNum, + hasNextPage, + nextPageNum, + pages + }; +}; + +const getPaginationNumbers = (currentPageNum: number, totalPages: number): Page[] => { + + const maxPagesToShow = 20; + const pages: Page[] = []; + + // Determine the range of pages to show + let startPage = Math.max(currentPageNum - Math.floor(maxPagesToShow / 2), 1); + const endPage = Math.min(startPage + maxPagesToShow - 1, totalPages); + + // Adjust the range so it shows pages relative to current + if (endPage - startPage < maxPagesToShow - 1) { + startPage = Math.max(endPage - maxPagesToShow + 1, 1); + } + + // Add the pages to the array + for (let pageNum = startPage; pageNum <= endPage; pageNum++) { + pages.push({ + pageNum, + isCurrentPage: pageNum === currentPageNum + }); + } + + return pages; +}; + +const getSyncStatus = (repoSyncState: RepoSyncState): TaskStatus => { + + const statuses = [repoSyncState?.branchStatus, repoSyncState?.commitStatus, repoSyncState?.pullStatus, repoSyncState?.buildStatus, repoSyncState?.deploymentStatus]; + if (statuses.includes("pending")) { + return "pending"; + } + if (statuses.includes("failed")) { + return "failed"; + } + const completeStatusesCount = statuses.filter((status) => status == "complete").length; + if (completeStatusesCount === statuses.length) { + return "complete"; + } + return "pending"; +}; + +const mapTaskStatus = (syncStatus: TaskStatus): string => { + switch (syncStatus) { + case "pending": + return "IN PROGRESS"; + case "complete": + return "FINISHED"; + case "failed": + return "FAILED"; + default: + return "IN PROGRESS"; + } +}; diff --git a/src/routes/jira/jira-get.ts b/src/routes/jira/jira-get.ts index e507b33128..d935638edc 100644 --- a/src/routes/jira/jira-get.ts +++ b/src/routes/jira/jira-get.ts @@ -73,6 +73,7 @@ const getInstallation = async (subscription: Subscription, gitHubAppId: number | const response = await gitHubAppClient.getInstallation(gitHubInstallationId); return { ...response.data, + subscriptionId: subscription.id, syncStatus: mapSyncStatus(subscription.syncStatus), syncWarning: subscription.syncWarning, totalNumberOfRepos: subscription.totalNumberOfRepos, @@ -174,6 +175,7 @@ const renderJiraCloudAndEnterpriseServer = async (res: Response, req: Request): hasConnections, useNewSPAExperience, APP_URL: process.env.APP_URL, + enableRepoConnectedPage: await booleanFlag(BooleanFlags.ENABLE_CONNECTED_REPOS_VIEW, jiraHost), csrfToken: req.csrfToken(), nonce }); diff --git a/src/routes/jira/jira-router.ts b/src/routes/jira/jira-router.ts index 8205718558..b7da4e5ba2 100644 --- a/src/routes/jira/jira-router.ts +++ b/src/routes/jira/jira-router.ts @@ -10,6 +10,7 @@ import { JiraConnectRouter } from "routes/jira/connect/jira-connect-router"; import { body } from "express-validator"; import { returnOnValidationError } from "routes/api/api-utils"; import { jiraSymmetricJwtMiddleware } from "~/src/middleware/jira-symmetric-jwt-middleware"; +import { JiraConnectedReposGet } from "routes/jira/jira-connected-repos-get"; import { jiraAdminPermissionsMiddleware } from "middleware/jira-admin-permission-middleware"; import { JiraWorkspacesRouter } from "routes/jira/workspaces/jira-workspaces-router"; import { JiraSecurityWorkspacesRouter } from "routes/jira/security/workspaces/jira-security-workspaces-router"; @@ -37,6 +38,8 @@ JiraRouter.use("/security", jiraSymmetricJwtMiddleware, JiraSecurityWorkspacesRo JiraRouter.get("/", csrfMiddleware, jiraSymmetricJwtMiddleware, jiraAdminPermissionsMiddleware, JiraGet); +JiraRouter.get("/subscription/:subscriptionId/repos", csrfMiddleware, jiraSymmetricJwtMiddleware, jiraAdminPermissionsMiddleware, JiraConnectedReposGet); + /******************************************************************************************************************** * TODO: remove this later, keeping this for now cause its out in `Prod` * *******************************************************************************************************************/ diff --git a/static/css/global.css b/static/css/global.css index b79a6ea088..2cc434e348 100644 --- a/static/css/global.css +++ b/static/css/global.css @@ -160,6 +160,7 @@ a:focus { .jiraConfiguration__errorSummaryModalOverlay, .jiraConfiguration__restartBackfillModalOverlay, .githubSetup__newJiraSiteModalOverlay, +.jiraConnectedRepos__backfillStatusModalOverlay, .modal__modalOverlay { background: #091e42; opacity: 54%; @@ -169,6 +170,7 @@ a:focus { .jiraConfiguration__syncRetryModal, .jiraConfiguration__restartBackfillModal, .jiraConfiguration__errorSummaryModal, +.jiraConnectedRepos__backfillStatusModal, .githubSetup__newJiraSiteModal, .modal { display: none; @@ -180,6 +182,8 @@ a:focus { .jiraConfiguration__errorSummaryModalOverlay, .jiraConfiguration__restartBackfillModal, .jiraConfiguration__restartBackfillModalOverlay, +.jiraConnectedRepos__backfillStatusModal, +.jiraConnectedRepos__backfillStatusModalOverlay, .githubSetup__newJiraSiteModal, .githubSetup__newJiraSiteModalOverlay, .modal, @@ -194,6 +198,7 @@ a:focus { .jiraConfiguration__syncRetryModalContent, .jiraConfiguration__errorSummaryModalContent, .jiraConfiguration__restartBackfillModalContent, +.jiraConnectedRepos__backfillStatusModalContent, .githubSetup__newJiraSiteModalContent, .modal__modalContent { -webkit-transform: translateX(-50%); @@ -209,11 +214,16 @@ a:focus { .jiraConfiguration__syncRetryModalContent, .jiraConfiguration__errorSummaryModalContent, -.jiraConfiguration__restartBackfillModalContent { +.jiraConfiguration__restartBackfillModalContent{ top: 15%; width: 56%; } +.jiraConnectedRepos__backfillStatusModalContent { + top: 15%; + width: 550px; +} + .jiraConfiguration__restartBackfillModalContent { max-width: 540px; } @@ -230,6 +240,7 @@ a:focus { .jiraConfiguration__syncRetryModalOverlay, .jiraConfiguration__errorSummaryModalOverlay, .jiraConfiguration__restartBackfillModalOverlay, +.jiraConnectedRepos__backfillStatusModalOverlay, .githubSetup__newJiraSiteModalOverlay, .modal__modalOverlay { background: #091e42; diff --git a/static/css/jira-connected-repos.css b/static/css/jira-connected-repos.css new file mode 100644 index 0000000000..4aaff1b9c0 --- /dev/null +++ b/static/css/jira-connected-repos.css @@ -0,0 +1,243 @@ +.jiraConnectedRepos { + box-sizing: border-box; + overflow-y: hidden; + padding: 3.5em 4.5em; +} + +.jiraConnectedRepos__header { + display: flex; + align-items: center; +} + +.jiraConnectedRepos__header__title { + font-weight: 500; + margin-left: 20px; +} + +.jiraConnectedRepos_content { + margin: 0 auto; + max-width: 560px; +} + +.jiraConnectedRepos_content > .jiraConnectedRepos__tableContainer { + overflow: auto; + margin-top: 2.5em; + box-shadow: 0 1px 1px rgb(9 30 66 / 25%), 0 0 1px rgb(9 30 66 / 31%); + border-radius: 12px; + padding: 1.5em 1.5em; +} + +.jiraConnectedRepos_content > .jiraConnectedRepos__tableContainer > .jiraConnectedRepos__table { + color: #172b4d; + table-layout: fixed; + width: 100%; +} + +.jiraConnectedRepos__table__head { + color: #6b778c; + font-size: 0.8rem; +} + +.jiraConnectedRepos__table__head_row > .jiraConnectedRepos__table__head__title { + font-weight: normal; + padding: 0.3em 0; +} + +.jiraConnectedRepos__table__head__title:not(:last-child) { + +} + +.jiraConnectedRepos__table__body +> .jiraConnectedRepos__table__row +> .jiraConnectedRepos__table__cell, +.jiraConnectedRepos__table__body +> .jiraConnectedRepos__table__row +> .jiraConnectedRepos__table__cell__settings { + border-bottom: none; + border-top: none; + padding: 0.8em 0; +} + +.jiraConnectedRepos__table__syncStatus, +.syncStatusPending, +.syncStatusInProgress, +.syncStatusFinished, +.syncStatusFailed { + border-radius: 5px; + font-size: 1rem; + font-weight: bold; + padding: 0.2em 0.3em; +} + +.jiraConnectedRepos__table__pending, +.syncStatusPending { + background-color: #dfe1e6; + color: #42526e; +} + +.jiraConnectedRepos__table__in-progress, +.syncStatusInProgress { + background-color: #deebff; + color: #0747a6; +} + +.jiraConnectedRepos__table__finished, +.syncStatusFinished { + background-color: #e3fcef; + color: #006644; +} + +.jiraConnectedRepos__table__failed, +.syncStatusFailed { + background-color: #ffebe6; + color: #bf2600; +} + +.jiraConnectedRepos__table__cell__syncStatus { + display: flex; +} + +.jiraConnectedRepos__loaderContainer { + margin-left: 8px; +} + +.jiraConnectedRepos_pagination { + margin: 1rem; + display: flex; + justify-content: center; + align-items: center; + color: #42526e; +} + +.jiraConnectedRepos_pagination .page-num-link { + margin: 0px 12px; + cursor: pointer; +} + +.jiraConnectedRepos_pagination .current-page { + background: #253858; + width: 32px; + height: 32px; + color: #F4F5F7; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; +} + +.jiraConnectedRepos_pagination .next-page { + margin-left: 10px; +} + +.jiraConnectedRepos_pagination .prev-page { + margin-right: 10px; +} + +.jiraConnectedRepos_pagination .next-page span, +.jiraConnectedRepos_pagination .prev-page span { + --aui-icon-size: 20px; + color:#a5adba; +} + +.jiraConnectedRepos_pagination .next-page.page-selector span, +.jiraConnectedRepos_pagination .prev-page.page-selector span { + cursor: pointer; + color: #42526e; +} + +.jiraConnectedRepos__actionContainer { + display: flex; + justify-content: flex-start; +} + +.jiraConnectedRepos__action__statusFilter { + margin-right: 15px; +} +.jiraConnectedRepos__action__search { + position: relative; + width: 250px; + margin-right: 10px; +} + +.jiraConnectedRepos__action__search .aui-iconfont-search { + position: absolute; + right: 20px; + top: 8px; + cursor: pointer; + color: #000000a6; +} + +.jiraConnectedRepos__backfillStatusModal__header__container { + display: flex; + justify-content: space-between; + margin-bottom: 1.2em; +} + +.jiraConnectedRepos__backfillStatusModal__closeBtn { + cursor: pointer; + font-size: 20px; +} + +.jiraConfiguration__restartBackfillModal__content__desc { + display: flex; + flex-direction: column; + color: #344563; + font-size: 1rem; + margin-bottom: 1.2em +} + +.jiraConfiguration__restartBackfillModal__content__taskContainer { + display: flex; + align-items: center; + margin-bottom: 10px; +} + +.jiraConfiguration__restartBackfillModal__content__taskName { + width: 150px; + font-weight: 600; + font-size: 1.15em; +} + +.jiraConfiguration__restartBackfillModal__content__taskContainer span.aui-iconfont-approve { + color: #36B37E; + align-items: center; + --aui-icon-size: 18px; +} + +.jiraConfiguration__restartBackfillModal__content__taskContainer span.aui-iconfont-cross-circle { + color: #FF5630; + align-items: center; + --aui-icon-size: 18px; +} + +.jiraConfiguration__restartBackfillModal__content__taskContainer span.aui-iconfont-recent-filled { + color:#0065FF; + align-items: center; + --aui-icon-size: 18px; +} + +.jiraConfiguration__restartBackfillModal__content__backfillTaskStatuses{ + display: flex; + flex-direction: column; +} + +.jiraConfiguration__restartBackfillModal__content__ErrorContainer { + display: none; + margin: 10px 0px; + flex-direction: column; +} + +.jiraConfiguration__restartBackfillModal__content__error { + font-weight: 600; + margin-bottom: 5px; +} + +.jiraConfiguration__restartBackfillModal__content__divider { + width: 100%; + border: 1px solid #091E4224; + margin-bottom: 15px; +} + + + + diff --git a/static/js/jira-configuration.js b/static/js/jira-configuration.js index 99c40e4532..f42c7ef1a9 100644 --- a/static/js/jira-configuration.js +++ b/static/js/jira-configuration.js @@ -27,6 +27,18 @@ $(".add-organization-link").click(function(event) { }); }); +// TODO: passing JWT in query param is a security risk, we must either populate a session (if not already) or use cookies +$(".jiraConfiguration__table__repo_access").click(function (event) { + const subscriptionId = $(event.target.parentElement).attr('data-subscription-id'); + AP.navigator.go( + 'addonmodule', + { + moduleKey: "gh-addon-subscription-repos", + customData: { subscriptionId } + } + ); +}); + $(".add-enterprise-link").click(function(event) { event.preventDefault(); AP.navigator.go( @@ -292,6 +304,17 @@ if (genericModalClose != null) { }); } +$(".jiraConfiguration__table__repo_access").click(function (event) { + const subscriptionId = $(event.target.parentElement).attr('data-subscription-id'); + AP.navigator.go( + 'addonmodule', + { + moduleKey: "gh-addon-subscription-repos", + customData: { subscriptionId } + } + ); +}); + // When the user clicks anywhere outside of the modal, close it window.onclick = function(event) { if (event.target.className === "jiraConfiguration__syncRetryModalOverlay") { diff --git a/static/js/jira-connected-repos.js b/static/js/jira-connected-repos.js new file mode 100644 index 0000000000..72f4d88ce2 --- /dev/null +++ b/static/js/jira-connected-repos.js @@ -0,0 +1,114 @@ +$(document).ready(() => { + // to get the focus back on search bar after comp reload + $('#repo-search').focus(); + const params = new URLSearchParams(window.location.search.substring(1)); + const repoSearch = params.get("repoName"); + if (repoSearch) { + $("#repo-search").val(repoSearch); + } + const syncStatus = params.get("syncStatus"); + if(syncStatus) { + $("#status-filter").val(syncStatus); + } + + $(".page-selector").click((event) => { + const pageNumber = $(event.target).attr('data-page-num'); + loadRepos(pageNumber); + }); + + $("#repo-search-btn").click(() => { + loadRepos(1); + }); + + $("#status-filter").on("change", () => { + loadRepos(1); + }); + let repoSearchTimeoutId; + $('#repo-search').on('input', function() { + // re-render the original list after clearing the search bar + if($(this).val().length === 0) { + loadRepos(1); + } + // search bar is using de-bouncing + clearTimeout(repoSearchTimeoutId); + repoSearchTimeoutId = setTimeout(function() { + loadRepos(1); + }, 500); + }); + + const loadRepos = (pageNumber ) => { + const syncStatus =$("#status-filter").val(); + const repoName = $("#repo-search").val(); + const subscriptionId = $(".jiraConnectedRepos_pagination").attr('data-subscription-id'); + AP.navigator.go( + 'addonmodule', + { + moduleKey: "gh-addon-subscription-repos", + customData: { subscriptionId, pageNumber, repoName, syncStatus } + } + ); + }; + + $(".jiraConnectedRepos__table__cell__repoName").click((event) => { + document.getElementById("backfill-status-modal").style.display = "block"; + const repoName = event.currentTarget.getAttribute("data-repo-name"); + $("#jiraConnectedRepos__backfillStatusModal__header__repoName").text(repoName); + //set branch status icon + const branchStatus = event.currentTarget.getAttribute("data-branch-status"); + const branchStatusIconInfo = getStatusIconInfo(branchStatus); + $(".jiraConfiguration__restartBackfillModal__content__branchStatus span").attr("class", `aui-icon ${branchStatusIconInfo.cls}`); + $(".jiraConfiguration__restartBackfillModal__content__branchStatus span").attr("title", branchStatusIconInfo.title); + //set commit status icon + const commitStatus = event.currentTarget.getAttribute("data-commit-status"); + const commitStatusIconInfo = getStatusIconInfo(commitStatus); + $(".jiraConfiguration__restartBackfillModal__content__commitStatus span").attr("class", `aui-icon ${commitStatusIconInfo.cls}`); + $(".jiraConfiguration__restartBackfillModal__content__commitStatus span").attr("title", commitStatusIconInfo.title); + //set pull request status icon + const pullRequestStatus = event.currentTarget.getAttribute("data-pull-request-status"); + const pullRequestStatusIconInfo = getStatusIconInfo(pullRequestStatus); + $(".jiraConfiguration__restartBackfillModal__content__pullRequestStatus span").attr("class", `aui-icon ${pullRequestStatusIconInfo.cls}`); + $(".jiraConfiguration__restartBackfillModal__content__pullRequestStatus span").attr("title", pullRequestStatusIconInfo.title); + //set build status icon + const buildStatus = event.currentTarget.getAttribute("data-build-status"); + const buildStatusIconInfo = getStatusIconInfo(buildStatus); + $(".jiraConfiguration__restartBackfillModal__content__buildStatus span").attr("class", `aui-icon ${buildStatusIconInfo.cls}`); + $(".jiraConfiguration__restartBackfillModal__content__buildStatus span").attr("title", buildStatusIconInfo.title); + //set build status icon + const deploymentStatus = event.currentTarget.getAttribute("data-deployment-status"); + const deploymentStatusIconInfo = getStatusIconInfo(deploymentStatus); + $(".jiraConfiguration__restartBackfillModal__content__deploymentStatus span").attr("class", `aui-icon ${deploymentStatusIconInfo.cls}`); + $(".jiraConfiguration__restartBackfillModal__content__deploymentStatus span").attr("title", deploymentStatusIconInfo.title); + //set error reason + const dataFailedCode = event.currentTarget.getAttribute("data-failed-code"); + if(dataFailedCode && dataFailedCode.length > 0) { + $(".jiraConfiguration__restartBackfillModal__content__ErrorContainer").css("display", "flex"); + $(".jiraConfiguration__restartBackfillModal__content__errorReason").text(mapErrorToMessage(dataFailedCode)); + } else { + $(".jiraConfiguration__restartBackfillModal__content__ErrorContainer").css("display", "none"); + } + }); + + $(".jiraConnectedRepos__backfillStatusModal__closeBtn").click(()=> { + document.getElementById("backfill-status-modal").style.display = "none"; + }); +}); + +const getStatusIconInfo = (status) => { + if(status == "complete") { + return { cls: "aui-iconfont-approve", title: "COMPLETE" } + } + if(status == "failed") { + return { cls: "aui-iconfont-cross-circle", title: "FAILED" } + } + if(status == "pending") { + return { cls: "aui-iconfont-recent-filled", title: "IN PROGRESS" } + } +} + +const mapErrorToMessage = (errorCode) => { + if(errorCode == "CONNECTION_ERROR") { + return "This is caused by connection error. To fix this, restarting backfilling may rectify this error. If this error persists, please contact the Atlassian support team." + } + return errorCode; +}; + diff --git a/static/js/navigation.js b/static/js/navigation.js index b491759798..49158062d4 100644 --- a/static/js/navigation.js +++ b/static/js/navigation.js @@ -8,3 +8,16 @@ $(".go-back").click(function (event) { history.back(); } }); + +$(".go-main-admin").click(function (event) { + event.preventDefault(); + + AP.navigator.go( + 'addonmodule', + { + moduleKey: "gh-addon-admin", + } + ); +}); + + diff --git a/test/snapshots/app.test.ts.snap b/test/snapshots/app.test.ts.snap index 6ee9da0e00..f4685cf81c 100644 --- a/test/snapshots/app.test.ts.snap +++ b/test/snapshots/app.test.ts.snap @@ -201,6 +201,8 @@ exports[`app getFrontendApp please review routes and update snapshot when adding query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,_cookieSession,jiraSymmetricJwtMiddleware,JiraSecurityWorkspacesPost :GET ^/?(?=/|$)^/jira/?(?=/|$)^/?$ query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,_cookieSession,csrf,jiraSymmetricJwtMiddleware,jiraAdminPermissionsMiddleware,JiraGet +:GET ^/?(?=/|$)^/jira/?(?=/|$)^/subscription/(?:([^/]+?))/repos/?$ + query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,_cookieSession,csrf,jiraSymmetricJwtMiddleware,jiraAdminPermissionsMiddleware,JiraConnectedReposGet :GET ^/?(?=/|$)^/jira/?(?=/|$)^/configuration/?(?=/|$)^/?$ query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,_cookieSession,csrf,jiraSymmetricJwtMiddleware,jiraAdminPermissionsMiddleware,JiraGet :DELETE ^/?(?=/|$)^/jira/?(?=/|$)^/configuration/?(?=/|$)^/?$ diff --git a/test/snapshots/routes/jira/atlassian-connect/jira-atlassian-connect-get.test.ts.snap b/test/snapshots/routes/jira/atlassian-connect/jira-atlassian-connect-get.test.ts.snap index 0d1db32e20..ad08bee729 100644 --- a/test/snapshots/routes/jira/atlassian-connect/jira-atlassian-connect-get.test.ts.snap +++ b/test/snapshots/routes/jira/atlassian-connect/jira-atlassian-connect-get.test.ts.snap @@ -151,6 +151,19 @@ Object { }, "url": "/spa", }, + Object { + "conditions": Array [ + Object { + "condition": "user_is_admin", + }, + ], + "key": "gh-addon-subscription-repos", + "location": "none", + "name": Object { + "value": "Sync status", + }, + "url": "/jira/subscription/{ac.subscriptionId}/repos?pageNumber={ac.pageNumber}&repoName={ac.repoName}&syncStatus={ac.syncStatus}", + }, ], "jiraBuildInfoProvider": Object { "homeUrl": "https://github.com/features/actions", diff --git a/test/snapshots/routes/maintenance/maintenance-router.test.ts.snap b/test/snapshots/routes/maintenance/maintenance-router.test.ts.snap index 57622cbda0..f5918a0635 100644 --- a/test/snapshots/routes/maintenance/maintenance-router.test.ts.snap +++ b/test/snapshots/routes/maintenance/maintenance-router.test.ts.snap @@ -151,6 +151,19 @@ Object { }, "url": "/spa", }, + Object { + "conditions": Array [ + Object { + "condition": "user_is_admin", + }, + ], + "key": "gh-addon-subscription-repos", + "location": "none", + "name": Object { + "value": "Sync status", + }, + "url": "/jira/subscription/{ac.subscriptionId}/repos?pageNumber={ac.pageNumber}&repoName={ac.repoName}&syncStatus={ac.syncStatus}", + }, ], "jiraBuildInfoProvider": Object { "homeUrl": "https://github.com/features/actions", diff --git a/test/utils/database-state-creator.ts b/test/utils/database-state-creator.ts index ec880c1310..5076a463bd 100644 --- a/test/utils/database-state-creator.ts +++ b/test/utils/database-state-creator.ts @@ -28,6 +28,7 @@ export class DatabaseStateCreator { private pendingForSecretScanningAlerts: boolean; private pendingForCodeScanningAlerts: boolean; private securityPermissionsAccepted: boolean; + private jiraHost = jiraHost; private buildsCustomCursor: string | undefined; private prsCustomCursor: string | undefined; @@ -44,6 +45,11 @@ export class DatabaseStateCreator { return this; } + public forJiraHost(newJiraHost: string) { + this.jiraHost = newJiraHost; + return this; + } + public withActiveRepoSyncState() { this.withActiveRepoSyncStateFlag = true; return this; @@ -109,7 +115,7 @@ export class DatabaseStateCreator { return this; } - public static createServerApp(installationIdPk: number): Promise { + public static createServerApp(installationIdPk: number, aJiraHost: string = jiraHost): Promise { return GitHubServerApp.install({ uuid: v4(), appId: 12321, @@ -120,24 +126,24 @@ export class DatabaseStateCreator { privateKey: fs.readFileSync(path.resolve(__dirname, "../../test/setup/test-key.pem"), { encoding: "utf8" }), gitHubAppName: "app-name", installationId: installationIdPk - }, jiraHost); + }, aJiraHost); } public async create(): Promise { const installation = await Installation.create({ - jiraHost, + jiraHost: this.jiraHost, encryptedSharedSecret: "secret", clientKey: getHashedKey("client-key"), plainClientKey: "client-key" }); const gitHubServerApp = this.forServerFlag - ? await DatabaseStateCreator.createServerApp(installation.id) + ? await DatabaseStateCreator.createServerApp(installation.id, this.jiraHost) : undefined; const subscription = await Subscription.create({ gitHubInstallationId: DatabaseStateCreator.GITHUB_INSTALLATION_ID, - jiraHost, + jiraHost: this.jiraHost, syncStatus: "ACTIVE", repositoryStatus: "complete", gitHubAppId: gitHubServerApp?.id, diff --git a/views/jira-configuration.hbs b/views/jira-configuration.hbs index 0712d45da1..cfe08acdb6 100644 --- a/views/jira-configuration.hbs +++ b/views/jira-configuration.hbs @@ -154,7 +154,7 @@ name="backfill-fullsync-checkbox" />
@@ -165,7 +165,6 @@
- {{#if hasCloudAndEnterpriseServers}}
@@ -272,6 +272,7 @@ successfulConnections=app.successfulConnections failedConnections=app.failedConnections gitHubAppId=app.id + enableRepoConnectedPage=enableRepoConnectedPage }} {{else}}
diff --git a/views/jira-connected-repos.hbs b/views/jira-connected-repos.hbs new file mode 100644 index 0000000000..3611250c65 --- /dev/null +++ b/views/jira-connected-repos.hbs @@ -0,0 +1,170 @@ + + + + + + + + {{title}} + + + + + + + + + +
+
+
+ +
+
+ +
+
+

+ Connected repositories +

+
+
+
+ +
+ +
+
+
+
+ + + + + + + + + + {{#each repos}} + + + {{! Repo name }} + + + {{! Backfill status }} + + + + {{/each}} + +
RepositoryBackfill Status
+ + {{name}} + + +
+ + {{syncStatus}} + +
+
+
+
+ {{#if hasPrevPage}} +
+ {{else}} +
+ {{/if}} + + {{#each pages}} + {{#if isCurrentPage}} +
{{pageNum}}
+ {{else}} + + {{/if}} + {{/each}} + + {{#if hasNextPage}} +
+ {{else}} +
+ {{/if}} +
+
+ + +
+
+
+

+ +
+
+
Here are the following statuses of each backfill task within this repository.
+
+
+
Branches
+
+
+
+
+
Commits
+
+
+
+
+
Pull Requests
+
+
+
+
+
Builds
+
+
+
+
+
Deployments
+
+
+
+
+
+
+
Reason for failure
+
+
+
+
+
+
+
+ + + + + + + + + + diff --git a/views/partials/jira-configuration-table.hbs b/views/partials/jira-configuration-table.hbs index 0ca00ba603..bfc1d766cc 100644 --- a/views/partials/jira-configuration-table.hbs +++ b/views/partials/jira-configuration-table.hbs @@ -34,10 +34,20 @@ {{! Repos Synced }} - {{#if connection.isGlobalInstall}} - All repos + {{#if ../enableRepoConnectedPage}} + + {{#if connection.isGlobalInstall}} + All repos + {{else}} + Only select repos + {{/if}} + {{else}} + {{#if connection.isGlobalInstall}} + All repos + {{else}} Only select repos + {{/if}} {{/if}} {{#if connection.totalNumberOfRepos}}