diff --git a/.vscode/launch.json b/.vscode/launch.json index 5f8b27c25..7e23b33ca 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -35,6 +35,17 @@ "run", "update" ], + }, + { + "type": "node", + "request": "launch", + "name": "Fullcheck", + "runtimeExecutable": "yarn", + "cwd": "${workspaceFolder}", + "runtimeArgs": [ + "run", + "fullcheck" + ], } ] } \ No newline at end of file diff --git a/api/package.json b/api/package.json index 395c44ca7..8542a81c5 100644 --- a/api/package.json +++ b/api/package.json @@ -12,7 +12,7 @@ "migrate": "dotenv -e ../.env -- kysely migrate", "migrate:down": "dotenv -e ../.env -- kysely migrate down", "db:up": "yarn migrate latest", - "test": "vitest --watch=false --no-file-parallelism", + "test": "dotenv -e ../.env -- vitest --watch=false --no-file-parallelism", "dev": "yarn build && yarn start", "build": "tsc", "start": "yarn db:up && dotenv -e ../.env -- node dist/src/entrypoints/start-api.js", @@ -71,6 +71,7 @@ "vitest": "^1.2.2" }, "dependencies": { + "@gitbeaker/core": "^42.1.0", "@octokit/graphql": "^7.0.2", "@trpc/server": "^10.18.0", "@types/pg": "^8.11.6", diff --git a/api/src/core/adapters/GitHub/api/repo.ts b/api/src/core/adapters/GitHub/api/repo.ts new file mode 100644 index 000000000..09fba9ab5 --- /dev/null +++ b/api/src/core/adapters/GitHub/api/repo.ts @@ -0,0 +1,78 @@ +import { Octokit } from "@octokit/rest"; + +import { env } from "../../../../env"; + +export const repoGitHubEndpointMaker = (repoUrl: string | URL) => { + const octokit = new Octokit({ + auth: env.githubPersonalAccessTokenForApiRateLimit + }); + let repoUrlObj = typeof repoUrl === "string" ? URL.parse(repoUrl) : repoUrl; + if (!repoUrlObj) return undefined; + + // Case .git at the end + if (repoUrlObj.pathname.endsWith("/")) repoUrlObj.pathname = repoUrlObj.pathname.slice(0, -1); + if (repoUrlObj.pathname.endsWith(".git")) repoUrlObj.pathname = repoUrlObj.pathname.slice(0, -4); + + const parsed = repoUrlObj.pathname.split("/").filter(text => text); + + const repo = parsed[1]; + const owner = parsed[0]; + + return { + issues: { + getLastClosedIssue: async () => { + try { + const resIssues = await octokit.request("GET /repos/{owner}/{repo}/issues", { + owner, + repo, + headers: { + "X-GitHub-Api-Version": "2022-11-28" + }, + direction: "desc", + state: "closed" + }); + + return resIssues.data[0]; + } catch (error) { + return undefined; + } + } + }, + commits: { + getLastCommit: async () => { + try { + const resCommit = await octokit.request("GET /repos/{owner}/{repo}/commits", { + owner, + repo, + headers: { + "X-GitHub-Api-Version": "2022-11-28" + }, + direction: "desc" + }); + return resCommit.data[0]; + } catch (error) { + return undefined; + } + } + }, + mergeRequests: { + getLast: async () => { + try { + const resPull = await octokit.request("GET /repos/{owner}/{repo}/pulls", { + owner, + repo, + headers: { + "X-GitHub-Api-Version": "2022-11-28" + }, + direction: "desc", + state: "closed" + }); + + return resPull.data[0]; + } catch (error) { + return undefined; + } + } + } + }; +}; diff --git a/api/src/core/adapters/GitLab/api/project.ts b/api/src/core/adapters/GitLab/api/project.ts new file mode 100644 index 000000000..a281253b9 --- /dev/null +++ b/api/src/core/adapters/GitLab/api/project.ts @@ -0,0 +1,54 @@ +import { CommitSchema, IssueSchema, MergeRequestSchema } from "@gitbeaker/core"; +import { repoUrlToAPIUrl } from "./utils"; + +const getApiCallTakeFirst = async (url: string): Promise => { + const res = await fetch(url, { + signal: AbortSignal.timeout(10000) + }).catch(err => { + console.error(url, err); + }); + + if (!res) { + return undefined; + } + if (res.status === 404) { + console.error("Ressource not available"); + return undefined; + } + if (res.status === 403) { + console.info(`You don't seems to be allowed on ${url}`); + return undefined; + } + + const result: T[] = await res.json(); + + return result[0]; +}; + +const getLastClosedIssue = async (projectUrl: string) => { + return getApiCallTakeFirst(`${projectUrl}/issues?sort=desc&state=closed`); +}; + +const getLastCommit = async (projectUrl: string) => { + return getApiCallTakeFirst(`${projectUrl}/repository/commits?sort=desc`); +}; + +const getLastMergeRequest = async (projectUrl: string) => { + return getApiCallTakeFirst(`${projectUrl}/merge_requests?state=closed&sort=desc`); +}; + +export const projectGitLabApiMaker = (repoUrl: string | URL) => { + const apiProjectEndpoint = repoUrlToAPIUrl(repoUrl); + + return { + issues: { + getLastClosedIssue: () => getLastClosedIssue(apiProjectEndpoint) + }, + commits: { + getLastCommit: () => getLastCommit(apiProjectEndpoint) + }, + mergeRequests: { + getLast: () => getLastMergeRequest(apiProjectEndpoint) + } + }; +}; diff --git a/api/src/core/adapters/GitLab/api/utils.ts b/api/src/core/adapters/GitLab/api/utils.ts new file mode 100644 index 000000000..17c4bbd0e --- /dev/null +++ b/api/src/core/adapters/GitLab/api/utils.ts @@ -0,0 +1,30 @@ +export const repoUrlToAPIUrl = (projectUrl: string | URL): string => { + let url = projectUrl; + + if (typeof url === "string") { + // Case git+ at the beging + if (url.startsWith("git+")) url = url.substring(4); + + // Case ssh protocol + if (url.startsWith("git@")) url = url.replace(":", "/").replace("git@", "https://"); + + // Case .git at the end + if (url.endsWith(".git")) url = url.slice(0, -4); + } + + const urlObj = typeof projectUrl === "string" ? URL.parse(url) : projectUrl; + + if (!urlObj) { + throw new Error("Bad URL"); + } + + const base = urlObj.origin; + + let projectPath = urlObj.pathname.substring(1); + if (projectPath.includes("/-/")) projectPath = projectPath.split("-")[0]; + // Case / at the end + if (projectPath.endsWith("/")) projectPath = projectPath.slice(0, -1); + projectPath = projectPath.replaceAll("/", "%2F"); + + return `${base}/api/v4/projects/${projectPath}`; +}; diff --git a/api/src/core/adapters/dbApi/kysely/createPgSoftwareExternalDataRepository.ts b/api/src/core/adapters/dbApi/kysely/createPgSoftwareExternalDataRepository.ts index 1fd7c634b..28f9b19ad 100644 --- a/api/src/core/adapters/dbApi/kysely/createPgSoftwareExternalDataRepository.ts +++ b/api/src/core/adapters/dbApi/kysely/createPgSoftwareExternalDataRepository.ts @@ -14,6 +14,7 @@ export const createPgSoftwareExternalDataRepository = (db: Kysely): So programmingLanguages: JSON.stringify(softwareExternalData.programmingLanguages), referencePublications: JSON.stringify(softwareExternalData.referencePublications), identifiers: JSON.stringify(softwareExternalData.identifiers), + repoMetadata: JSON.stringify(softwareExternalData.repoMetadata), description: JSON.stringify(softwareExternalData.description) }; diff --git a/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts b/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts index f045a6958..a3970fe13 100644 --- a/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts +++ b/api/src/core/adapters/dbApi/kysely/createPgSoftwareRepository.ts @@ -5,6 +5,7 @@ import { SoftwareRepository } from "../../../ports/DbApiV2"; import { Software } from "../../../usecases/readWriteSillData"; import { Database } from "./kysely.database"; import { stripNullOrUndefinedValues, jsonBuildObject } from "./kysely.utils"; +import { SILL } from "../../../../types/SILL"; const dateParser = (str: string | Date | undefined | null) => { if (str && typeof str === "string") { @@ -16,6 +17,19 @@ const dateParser = (str: string | Date | undefined | null) => { } }; +const computeRepoMetadata = (repoMetadata: SILL.RepoMetadata | undefined | null): SILL.RepoMetadata | undefined => { + const newMedata = repoMetadata; + if (!newMedata || !newMedata.healthCheck) return undefined; + + let score = 0; + if (repoMetadata.healthCheck?.lastClosedIssue) score += 1; + if (repoMetadata.healthCheck?.lastClosedIssuePullRequest) score += 1; + if (repoMetadata.healthCheck?.lastCommit) score += 1; + newMedata.healthCheck.score = score / 3; + + return newMedata; +}; + export const createPgSoftwareRepository = (db: Kysely): SoftwareRepository => { const getBySoftwareId = makeGetSoftwareById(db); return { @@ -195,6 +209,7 @@ export const createPgSoftwareRepository = (db: Kysely): SoftwareReposi programmingLanguages: softwareExternalData?.programmingLanguages ?? [], referencePublications: softwareExternalData?.referencePublications, identifiers: softwareExternalData?.identifiers, + repoMetadata: computeRepoMetadata(softwareExternalData?.repoMetadata), applicationCategories: software.categories.concat( softwareExternalData?.applicationCategories ?? [] ), @@ -303,7 +318,8 @@ export const createPgSoftwareRepository = (db: Kysely): SoftwareReposi categories: undefined, // merged in applicationCategories, set to undefined to remove it programmingLanguages: softwareExternalData?.programmingLanguages ?? [], referencePublications: softwareExternalData?.referencePublications, - identifiers: softwareExternalData?.identifiers + identifiers: softwareExternalData?.identifiers, + repoMetadata: computeRepoMetadata(softwareExternalData?.repoMetadata) }); } ); @@ -427,6 +443,7 @@ const makeGetSoftwareBuilder = (db: Kysely) => applicationCategories: ref("ext.applicationCategories"), referencePublications: ref("ext.referencePublications"), identifiers: ref("ext.identifiers"), + repoMetadata: ref("ext.repoMetadata"), keywords: ref("ext.keywords"), softwareVersion: ref("ext.softwareVersion"), publicationTime: ref("ext.publicationTime") @@ -563,6 +580,7 @@ const makeGetSoftwareById = programmingLanguages: softwareExternalData?.programmingLanguages ?? [], referencePublications: softwareExternalData?.referencePublications, identifiers: softwareExternalData?.identifiers, + repoMetadata: computeRepoMetadata(softwareExternalData?.repoMetadata), applicationCategories: filterDuplicate( software.categories.concat(softwareExternalData?.applicationCategories ?? []) ), diff --git a/api/src/core/adapters/dbApi/kysely/kysely.database.ts b/api/src/core/adapters/dbApi/kysely/kysely.database.ts index 3ef34f985..443b72dd0 100644 --- a/api/src/core/adapters/dbApi/kysely/kysely.database.ts +++ b/api/src/core/adapters/dbApi/kysely/kysely.database.ts @@ -108,6 +108,7 @@ type SoftwareExternalDatasTable = { referencePublications: JSONColumnType | null; publicationTime: Date | null; identifiers: JSONColumnType | null; + repoMetadata: JSONColumnType | null; }; type SoftwareType = diff --git a/api/src/core/adapters/dbApi/kysely/migrations/1741091782480_add-repo-metadata.ts b/api/src/core/adapters/dbApi/kysely/migrations/1741091782480_add-repo-metadata.ts new file mode 100644 index 000000000..278b02096 --- /dev/null +++ b/api/src/core/adapters/dbApi/kysely/migrations/1741091782480_add-repo-metadata.ts @@ -0,0 +1,9 @@ +import { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema.alterTable("software_external_datas").addColumn("repoMetadata", "jsonb").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("software_external_datas").dropColumn("repoMetadata").execute(); +} diff --git a/api/src/core/adapters/dbApi/kysely/pgDbApi.integration.test.ts b/api/src/core/adapters/dbApi/kysely/pgDbApi.integration.test.ts index 6fd3a8701..150a4ea0c 100644 --- a/api/src/core/adapters/dbApi/kysely/pgDbApi.integration.test.ts +++ b/api/src/core/adapters/dbApi/kysely/pgDbApi.integration.test.ts @@ -445,6 +445,7 @@ describe("pgDbApi", () => { applicationCategories: JSON.stringify(softExtData.applicationCategories), programmingLanguages: JSON.stringify(softExtData.programmingLanguages), identifiers: JSON.stringify(softExtData.identifiers), + repoMetadata: JSON.stringify(softExtData.repoMetadata), referencePublications: JSON.stringify(softExtData.referencePublications) })) ) diff --git a/api/src/core/adapters/fetchExternalData.test.ts b/api/src/core/adapters/fetchExternalData.test.ts index 8417886dd..336e5b2a0 100644 --- a/api/src/core/adapters/fetchExternalData.test.ts +++ b/api/src/core/adapters/fetchExternalData.test.ts @@ -231,6 +231,7 @@ describe("fetches software extra data (from different providers)", () => { programmingLanguages: [], referencePublications: null, identifiers: [], + repoMetadata: null, softwareVersion: "5.0.1", publicationTime: new Date("2022-04-12T00:00:00.000Z") }, @@ -261,6 +262,7 @@ describe("fetches software extra data (from different providers)", () => { programmingLanguages: ["JavaScript"], referencePublications: null, identifiers: [], + repoMetadata: null, softwareVersion: expect.any(String), publicationTime: expect.any(Date) } @@ -324,6 +326,7 @@ describe("fetches software extra data (from different providers)", () => { websiteUrl: "https://httpd.apache.org/", referencePublications: null, identifiers: [], + repoMetadata: null, programmingLanguages: ["C"], softwareVersion: "2.5.0-alpha", publicationTime: new Date("2017-11-08T00:00:00.000Z") diff --git a/api/src/core/adapters/hal/getHalSoftware.test.ts b/api/src/core/adapters/hal/getHalSoftware.test.ts index 0b2d0e1ba..99ead15e8 100644 --- a/api/src/core/adapters/hal/getHalSoftware.test.ts +++ b/api/src/core/adapters/hal/getHalSoftware.test.ts @@ -48,6 +48,13 @@ describe("HAL", () => { "programmingLanguages": undefined, "applicationCategories": ["Computer Science [cs]"], "referencePublications": undefined, + "repoMetadata": { + "healthCheck": { + "lastClosedIssue": undefined, + "lastClosedIssuePullRequest": undefined, + "lastCommit": 1729459216000 + } + }, "identifiers": [ { "@type": "PropertyValue", diff --git a/api/src/core/adapters/hal/getHalSoftwareExternalData.ts b/api/src/core/adapters/hal/getHalSoftwareExternalData.ts index 388c63d47..68050294d 100644 --- a/api/src/core/adapters/hal/getHalSoftwareExternalData.ts +++ b/api/src/core/adapters/hal/getHalSoftwareExternalData.ts @@ -7,6 +7,9 @@ import { halAPIGateway } from "./HalAPI"; import { HAL } from "./HalAPI/types/HAL"; import { crossRefSource } from "./CrossRef"; import { getScholarlyArticle } from "./getScholarlyArticle"; +import { repoAnalyser, RepoType } from "../../../tools/repoAnalyser"; +import { projectGitLabApiMaker } from "../GitLab/api/project"; +import { repoGitHubEndpointMaker } from "../GitHub/api/repo"; const buildParentOrganizationTree = async ( structureIdArray: number[] | string[] | undefined @@ -196,6 +199,60 @@ export const getHalSoftwareExternalData: GetSoftwareExternalData = memoize( } }) ?? []; + const repoType = await repoAnalyser(halRawSoftware?.softCodeRepository_s?.[0]); + + const getRepoMetadata = async (repoType: RepoType | undefined) => { + switch (repoType) { + case "GitLab": + const gitLabProjectapi = projectGitLabApiMaker(halRawSoftware?.softCodeRepository_s?.[0]); + const lastGLCommit = await gitLabProjectapi.commits.getLastCommit(); + const lastFLIssue = await gitLabProjectapi.issues.getLastClosedIssue(); + const lastGLMergeRequest = await gitLabProjectapi.mergeRequests.getLast(); + return { + healthCheck: { + lastCommit: lastGLCommit ? new Date(lastGLCommit.created_at).valueOf() : undefined, + lastClosedIssue: + lastFLIssue && lastFLIssue.closed_at + ? new Date(lastFLIssue.closed_at).valueOf() + : undefined, + lastClosedIssuePullRequest: lastGLMergeRequest + ? new Date(lastGLMergeRequest.updated_at).valueOf() + : undefined + } + }; + case "GitHub": + const gitHubApi = repoGitHubEndpointMaker(halRawSoftware?.softCodeRepository_s?.[0]); + if (!gitHubApi) { + console.error("Bad URL string"); + return undefined; + } + + const lastGHCommit = await gitHubApi.commits.getLastCommit(); + const lastGHCloseIssue = await gitHubApi.issues.getLastClosedIssue(); + const lastGHClosedPull = await gitHubApi.mergeRequests.getLast(); + + return { + healthCheck: { + lastCommit: lastGHCommit?.commit?.author?.date + ? new Date(lastGHCommit.commit.author.date).valueOf() + : undefined, + lastClosedIssue: lastGHCloseIssue?.closed_at + ? new Date(lastGHCloseIssue.closed_at).valueOf() + : undefined, + lastClosedIssuePullRequest: lastGHClosedPull?.closed_at + ? new Date(lastGHClosedPull.closed_at).valueOf() + : undefined + } + }; + + case undefined: + return undefined; + default: + repoType satisfies never; + return undefined; + } + }; + return { externalId: halRawSoftware.docid, sourceSlug: source.slug, @@ -229,7 +286,8 @@ export const getHalSoftwareExternalData: GetSoftwareExternalData = memoize( halRawSoftware.relatedPublication_s.map(id => buildReferencePublication(parseScolarId(id), id)) ) ).filter(val => val !== undefined), - identifiers: identifiers + identifiers: identifiers, + repoMetadata: await getRepoMetadata(repoType) }; }, { diff --git a/api/src/core/ports/GetSoftwareExternalData.ts b/api/src/core/ports/GetSoftwareExternalData.ts index e42ea75b8..47299aeec 100644 --- a/api/src/core/ports/GetSoftwareExternalData.ts +++ b/api/src/core/ports/GetSoftwareExternalData.ts @@ -34,6 +34,13 @@ export type SoftwareExternalData = { publicationTime: Date; referencePublications: SILL.ScholarlyArticle[]; identifiers: SILL.Identification[]; + repoMetadata?: { + healthCheck?: { + lastCommit?: number; + lastClosedIssue?: number; + lastClosedIssuePullRequest?: number; + }; + }; }>; export type SimilarSoftwareExternalData = Pick< diff --git a/api/src/core/usecases/readWriteSillData/types.ts b/api/src/core/usecases/readWriteSillData/types.ts index 3e846f473..960a45c2c 100644 --- a/api/src/core/usecases/readWriteSillData/types.ts +++ b/api/src/core/usecases/readWriteSillData/types.ts @@ -57,6 +57,7 @@ export type Software = { programmingLanguages: string[]; referencePublications?: SILL.ScholarlyArticle[]; identifiers?: SILL.Identification[]; + repoMetadata?: SILL.RepoMetadata; }; export type Source = { diff --git a/api/src/tools/repoAnalyser.test.ts b/api/src/tools/repoAnalyser.test.ts new file mode 100644 index 000000000..f277c12cd --- /dev/null +++ b/api/src/tools/repoAnalyser.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from "vitest"; +import { repoAnalyser } from "./repoAnalyser"; + +describe("repoAnalyser", () => { + it("should return undefined if url is undefined", async () => { + const result = await repoAnalyser(undefined); + expect(result).toBeUndefined(); + }); + + it('should return "GitHub" for GitHub URLs', async () => { + const result = await repoAnalyser("https://github.com/"); + expect(result).toBe("GitHub"); + }); + + it('should return "GitHub" for GitHub URLs', async () => { + const result = await repoAnalyser("git+https://github.com/agorajs/agora-gml.git"); + expect(result).toBe("GitHub"); + }); + + it('should return "GitLab" for GitLab URLs', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + headers: new Headers({ + "x-gitlab-meta": "true" + }) + }); + + global.fetch = mockFetch; + + const result = await repoAnalyser("https://gitlab.com/"); + expect(result).toBe("GitLab"); + }); + + it('should return "GitLab" for gite.lirmm.fr URLs', async () => { + const result = await repoAnalyser("https://gite.lirmm.fr/doccy/RedOak"); + expect(result).toBe("GitLab"); + }); + + it('should return "GitLab" for gricad-gitlab URLs', async () => { + const result = await repoAnalyser("https://gricad-gitlab.univ-grenoble-alpes.fr/kraifo/ailign"); + expect(result).toBe("GitLab"); + }); + + it('should return "GitLab" for gitlab.lis-lab.fr/ URLs', async () => { + const result = await repoAnalyser("https://gitlab.lis-lab.fr/dev/mincoverpetri"); + expect(result).toBe("GitLab"); + }); + + it('should return "GitLab" for inria gitlab URLs', async () => { + const result = await repoAnalyser("https://forgemia.inra.fr/lisc/easyabc"); + expect(result).toBe("GitLab"); + }); + + it('should return "GitLab" for gricad-gitlab URLs', async () => { + const result = await repoAnalyser("https://gricad-gitlab.univ-grenoble-alpes.fr/kraifo/ailign"); + expect(result).toBe("GitLab"); + }); + + it('should return "GitLab" for gricad-gitlab URLs', async () => { + const result = await repoAnalyser("https://gricad-gitlab.univ-grenoble-alpes.fr/kraifo/ailign"); + expect(result).toBe("GitLab"); + }); + + it("should return undefined for unknown URLs", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + headers: new Headers() + }); + + global.fetch = mockFetch; + + const result = await repoAnalyser("https://unknown.com/"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for pari.math.u-bordeaux.fr URLs", async () => { + const result = await repoAnalyser("https://pari.math.u-bordeaux.fr/git/pari.git"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for codeberg URLs", async () => { + const result = await repoAnalyser("https://codeberg.org/tesselle/aion"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for CRAN.R-project.org URLs", async () => { + const result = await repoAnalyser("https://CRAN.R-project.org/package=glober"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for Zenodo DOI URLs", async () => { + const result = await repoAnalyser("https://doi.org/10.5281/zenodo.11069161"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for who.rocq.inria.fr URLs", async () => { + const result = await repoAnalyser( + "https://who.rocq.inria.fr/Jean-Charles.Gilbert/modulopt/optimization-routines/n1cv2/n1cv2.html" + ); + expect(result).toBeUndefined(); + }); +}); diff --git a/api/src/tools/repoAnalyser.ts b/api/src/tools/repoAnalyser.ts new file mode 100644 index 000000000..de2d6b6aa --- /dev/null +++ b/api/src/tools/repoAnalyser.ts @@ -0,0 +1,28 @@ +export type RepoType = "GitHub" | "GitLab"; + +export const repoAnalyser = async (url: string | URL | undefined): Promise => { + if (!url) return undefined; + + const urlObj = typeof url === "string" ? URL.parse(url.substring(0, 4) === "git+" ? url.substring(4) : url) : url; + + if (!urlObj) { + return undefined; + } + + if (urlObj.origin === "https://github.com") { + return "GitHub"; + } + + const urlToGitLab = `${urlObj.origin}/api/v4/metadata`; + const res = await fetch(urlToGitLab, { + signal: AbortSignal.timeout(10000) + }).catch(err => { + console.error(url, err); + }); + + if (res && res.headers && res.headers.has("x-gitlab-meta")) { + return "GitLab"; + } + + return undefined; +}; diff --git a/api/src/types/SILL.ts b/api/src/types/SILL.ts index 19d1e8c3c..0187460b1 100644 --- a/api/src/types/SILL.ts +++ b/api/src/types/SILL.ts @@ -61,4 +61,14 @@ export namespace SILL { url?: string; affiliations?: Organization[]; }; + + // Created from nowhere + export type RepoMetadata = { + healthCheck?: { + score?: number; + lastCommit?: number; + lastClosedIssue?: number; + lastClosedIssuePullRequest?: number; + }; + }; } diff --git a/web/src/core/usecases/softwareDetails/state.ts b/web/src/core/usecases/softwareDetails/state.ts index e623ef0e2..0b4603af5 100644 --- a/web/src/core/usecases/softwareDetails/state.ts +++ b/web/src/core/usecases/softwareDetails/state.ts @@ -90,6 +90,7 @@ export namespace State { referencePublications?: ApiTypes.SILL.ScholarlyArticle[]; softwareType: ApiTypes.SoftwareType; identifiers: ApiTypes.SILL.Identification[]; + repoMetadata?: ApiTypes.SILL.RepoMetadata; }; } diff --git a/web/src/core/usecases/softwareDetails/thunks.ts b/web/src/core/usecases/softwareDetails/thunks.ts index cf61653a7..e4fb3a96d 100644 --- a/web/src/core/usecases/softwareDetails/thunks.ts +++ b/web/src/core/usecases/softwareDetails/thunks.ts @@ -198,7 +198,8 @@ function apiSoftwareToSoftware(params: { keywords, referencePublications, applicationCategories, - identifiers + identifiers, + repoMetadata } = apiSoftware; return { @@ -301,6 +302,7 @@ function apiSoftwareToSoftware(params: { applicationCategories, referencePublications, softwareType, - identifiers: identifiers ?? [] + identifiers: identifiers ?? [], + repoMetadata }; } diff --git a/web/src/ui/config-ui.json b/web/src/ui/config-ui.json index ae8aea9ba..2e3f0924b 100644 --- a/web/src/ui/config-ui.json +++ b/web/src/ui/config-ui.json @@ -95,6 +95,9 @@ "softwareType": true } }, + "repoMetadata": { + "enabled": false + }, "links": { "enabled": true }, diff --git a/web/src/ui/datetimeUtils.ts b/web/src/ui/datetimeUtils.ts index 9c63fc168..f08d11969 100644 --- a/web/src/ui/datetimeUtils.ts +++ b/web/src/ui/datetimeUtils.ts @@ -36,13 +36,14 @@ export const { getFormattedDate } = (() => { export function useFormattedDate(params: { time: number; doAlwaysShowYear?: boolean; + showTime?: boolean; }): string { - const { time, doAlwaysShowYear } = params; + const { time, doAlwaysShowYear, showTime } = params; const { lang } = useLang(); return useMemo( - () => getFormattedDate({ time, lang, doAlwaysShowYear }), + () => getFormattedDate({ time, lang, doAlwaysShowYear, showTime }), [time, lang] ); } diff --git a/web/src/ui/i18n/sill_en.json b/web/src/ui/i18n/sill_en.json index 82a712335..c39257906 100644 --- a/web/src/ui/i18n/sill_en.json +++ b/web/src/ui/i18n/sill_en.json @@ -273,7 +273,11 @@ "softwareType-desktop/mobile": "Software / mobile app", "softwareType-stack": "Stack", "softwareType-cloud": "Cloud Hosted App", - "supportedOS": "Supported OS" + "supportedOS": "Supported OS", + "repoMetadata": "Metadata from repository", + "repoLastCommit": "Last commit", + "repoLastClosedIssuePullRequest": "Last merged request", + "repoLastClosedIssue": "Last closed issue" }, "referencedInstancesTab": { "publicInstanceCount": "{{instanceCount}} maintained public $t(referencedInstancesTab.instance, {\"count\": {{instanceCount}} }) by {{organizationCount}} public $t(referencedInstancesTab.organization, {\"count\": {{organizationCount}} })", diff --git a/web/src/ui/i18n/sill_fr.json b/web/src/ui/i18n/sill_fr.json index d70a4d957..e61739990 100644 --- a/web/src/ui/i18n/sill_fr.json +++ b/web/src/ui/i18n/sill_fr.json @@ -276,7 +276,11 @@ "softwareType-desktop/mobile": "Logiciel Ordinateur / Application mobile", "softwareType-stack": "Stack", "softwareType-cloud": "Application cloud", - "supportedOS": "Système d'exploitation supporté" + "supportedOS": "Système d'exploitation supporté", + "repoMetadata": "Metadonnées de la forge logicielle", + "repoLastCommit": "Dernier commit", + "repoLastClosedIssuePullRequest": "Dernière demande de merge", + "repoLastClosedIssue": "Dernier ticket fermé" }, "referencedInstancesTab": { "publicInstanceCount": "{{instanceCount}} $t(referencedInstancesTab.instance, {\"count\": {{instanceCount}} }) web $t(referencedInstancesTab.maintain, {\"count\": {{instanceCount}} }) par {{organizationCount}} $t(referencedInstancesTab.organization, {\"count\": {{organizationCount}} }) $t(referencedInstancesTab.public, {\"count\": {{organizationCount}} }", diff --git a/web/src/ui/pages/softwareDetails/PreviewTab.tsx b/web/src/ui/pages/softwareDetails/PreviewTab.tsx index 039cbfc2d..ca9abe2c5 100644 --- a/web/src/ui/pages/softwareDetails/PreviewTab.tsx +++ b/web/src/ui/pages/softwareDetails/PreviewTab.tsx @@ -2,13 +2,13 @@ import { useLang } from "ui/i18n"; import { Trans, useTranslation } from "react-i18next"; import { fr } from "@codegouvfr/react-dsfr"; import { tss } from "tss-react"; -import { shortEndMonthDate, monthDate } from "ui/datetimeUtils"; +import { shortEndMonthDate, monthDate, useFormattedDate } from "ui/datetimeUtils"; import Tooltip from "@mui/material/Tooltip"; import { capitalize } from "tsafe/capitalize"; import { CnllServiceProviderModal } from "./CnllServiceProviderModal"; import { assert, type Equals } from "tsafe/assert"; import config from "../../config-ui.json"; -import { SILL, SoftwareType } from "api/dist/src/lib/ApiTypes"; +import type { ApiTypes } from "api"; import { SoftwareTypeTable } from "ui/shared/SoftwareTypeTable"; import { LogoURLButton } from "ui/shared/LogoURLButton"; @@ -39,9 +39,10 @@ export type Props = { programmingLanguages: string[]; keywords?: string[]; applicationCategories: string[]; - softwareType: SoftwareType; - identifiers: SILL.Identification[]; + softwareType: ApiTypes.SoftwareType; + identifiers: ApiTypes.SILL.Identification[]; officialWebsiteUrl?: string; + repoMetadata?: ApiTypes.SILL.RepoMetadata; }; export const PreviewTab = (props: Props) => { const { @@ -66,7 +67,8 @@ export const PreviewTab = (props: Props) => { applicationCategories, softwareType, identifiers, - officialWebsiteUrl + officialWebsiteUrl, + repoMetadata } = props; const { classes, cx } = useStyles(); @@ -400,6 +402,57 @@ export const PreviewTab = (props: Props) => { )} )} + {config.softwareDetails.repoMetadata.enabled && repoMetadata && ( +
+

+ {t("previewTab.repoMetadata")} +

+ {repoMetadata?.healthCheck?.lastClosedIssue && ( +

+ + {t("previewTab.repoLastClosedIssue")} :{" "} + + + {useFormattedDate({ + time: repoMetadata.healthCheck.lastClosedIssue, + showTime: false, + doAlwaysShowYear: true + })} + +

+ )} + {repoMetadata?.healthCheck?.lastClosedIssuePullRequest && ( +

+ + {t("previewTab.repoLastClosedIssuePullRequest")}{" "} + :{" "} + + + {useFormattedDate({ + time: repoMetadata.healthCheck + .lastClosedIssuePullRequest, + showTime: false, + doAlwaysShowYear: true + })} + +

+ )} + {repoMetadata?.healthCheck?.lastCommit && ( +

+ + {t("previewTab.repoLastCommit")} :{" "} + + + {useFormattedDate({ + time: repoMetadata.healthCheck.lastCommit, + showTime: false, + doAlwaysShowYear: true + })} + +

+ )} +
+ )} ) }, diff --git a/yarn.lock b/yarn.lock index 5e73228cc..5d7c13e5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1703,6 +1703,25 @@ resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== +"@gitbeaker/core@^42.1.0": + version "42.1.0" + resolved "https://registry.yarnpkg.com/@gitbeaker/core/-/core-42.1.0.tgz#eff80d43a29cd9485e34d3785294e1dd46f4d127" + integrity sha512-xoP3mUjiGyUdN+utmQ+wDh9r7b4bcf3wa8jxkDTZTiuyd7Tg+354nJhwBNBsq2vFfyQvONOyOT1hsFjTGbTpBA== + dependencies: + "@gitbeaker/requester-utils" "^42.1.0" + qs "^6.12.2" + xcase "^2.0.1" + +"@gitbeaker/requester-utils@^42.1.0": + version "42.1.0" + resolved "https://registry.yarnpkg.com/@gitbeaker/requester-utils/-/requester-utils-42.1.0.tgz#84bebe8f9eded5c26db1be746b8490ac21211b07" + integrity sha512-q5NXy563UUM2AisM/V6Z3A92hIVQNMyx/VBj5Mg7gJkEtIYL+pEyibjIQxcq6nQ3bnj6bkM8NYguCs5tg7GR0Q== + dependencies: + picomatch-browser "^2.2.6" + qs "^6.12.2" + rate-limiter-flexible "^4.0.1" + xcase "^2.0.1" + "@humanwhocodes/config-array@^0.13.0": version "0.13.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" @@ -12299,6 +12318,11 @@ picocolors@^1.0.0, picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== +picomatch-browser@^2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/picomatch-browser/-/picomatch-browser-2.2.6.tgz#e0626204575eb49f019f2f2feac24fc3b53e7a8a" + integrity sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.0, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" @@ -12765,7 +12789,7 @@ qs@6.13.0: dependencies: side-channel "^1.0.6" -qs@^6.10.0, qs@^6.12.3: +qs@^6.10.0, qs@^6.12.2, qs@^6.12.3: version "6.14.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== @@ -12807,6 +12831,11 @@ range-parser@^1.2.1, range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== +rate-limiter-flexible@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/rate-limiter-flexible/-/rate-limiter-flexible-4.0.1.tgz#79b0ce111abe9c5da41d6fddf7cca93cedd3a8fc" + integrity sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ== + raw-body@2.5.2: version "2.5.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" @@ -16116,6 +16145,11 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xcase@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/xcase/-/xcase-2.0.1.tgz#c7fa72caa0f440db78fd5673432038ac984450b9" + integrity sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw== + xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"