From 3a55d5f54ca641720933ce726a58baf6eb95095d Mon Sep 17 00:00:00 2001 From: Johnathan Gilday Date: Mon, 3 Jun 2024 10:43:49 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Apply=20Formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 38 ++++++++++++++++--------- __tests__/action.test.ts | 19 +++++++------ __tests__/pixee-platform.test.ts | 2 +- action.yml | 4 ++- src/action.ts | 49 +++++++++++++++++++------------- src/contrast.ts | 29 +++++++++---------- src/defect-dojo.ts | 12 +++----- src/github.ts | 28 ++++++++---------- src/inputs.ts | 30 +++++++++++++++---- src/pixee-platform.ts | 4 +-- src/sonar.ts | 34 +++++++++++----------- 11 files changed, 142 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 41569dc..12086df 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Pixeebot Code Scanning Tool Integration -GitHub Action for upload code scanning results to [Pixeebot](https://pixee.ai/) so it can fix the issues they found. +GitHub Action for upload code scanning results to [Pixeebot](https://pixee.ai/) +so it can fix the issues they found. ## For Sonar Users @@ -9,14 +10,17 @@ to execute when the Sonar GitHub App completes a check. The `sonar-pixeebot.yml` example workflow includes the requisite configuration and is generic enough to apply to most repositories without modification. -1. Copy the [example sonar-pixeebot.yml](./examples/sonar-pixeebot.yml) workflow to the repository's `.github/workflows` directory. +1. Copy the [example sonar-pixeebot.yml](./examples/sonar-pixeebot.yml) workflow + to the repository's `.github/workflows` directory. 1. Set the `SONAR_TOKEN` secret. Create a SonarCloud token at - [https://sonarcloud.io/account/security](https://sonarcloud.io/account/security). See + [https://sonarcloud.io/account/security](https://sonarcloud.io/account/security). + See [Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions). ## Inputs -Detailed description of the inputs exposed by the `pixee/upload-tool-results-action`: +Detailed description of the inputs exposed by the +`pixee/upload-tool-results-action`: ```yaml - uses: pixee/upload-tool-results-action @@ -41,7 +45,7 @@ Detailed description of the inputs exposed by the `pixee/upload-tool-results-act # Token for authenticating requests to DefectDojo. defectdojo-token: - # Key identifying the DefectDojo product (repository) to be analyzed. + # Key identifying the DefectDojo product (repository) to be analyzed. defectdojo-product-name: # Base URL of the DefectDojo API. @@ -49,16 +53,16 @@ Detailed description of the inputs exposed by the `pixee/upload-tool-results-act # Base URL of the Contrast API. contrast-api-url: - + # Unique identifier for the organization in Contrast that needs to be analyzed. contrast-org-id: - + # Unique identifier for the specific application within Contrast. contrast-app-id: - + # Api key for authenticating requests to Contrast. contrast-api-key: - + # Token for authenticating requests to Contrast. contrast-token: @@ -69,7 +73,8 @@ Detailed description of the inputs exposed by the `pixee/upload-tool-results-act ## How Does It Work? -The following diagram illustrates how the action orchestrates the results from Sonar, to Pixeebot, and then back to GitHub. +The following diagram illustrates how the action orchestrates the results from +Sonar, to Pixeebot, and then back to GitHub. ```mermaid sequenceDiagram @@ -86,7 +91,8 @@ sequenceDiagram Pixeebot-->>GitHub: Automatically Fix Issues ``` -The code scanning results will feed both Pixeebot's _continuous improvement_ and _pull request hardening_ features. +The code scanning results will feed both Pixeebot's _continuous improvement_ and +_pull request hardening_ features. - When the code quality tool finds issues on an open PR, Pixeebot opens another PR to fix those issues. @@ -96,7 +102,9 @@ The code scanning results will feed both Pixeebot's _continuous improvement_ and ## Example -The following represents an example GitHub Actions workflow that uploads SonarCloud results to Pixeebot. It runs each time the SonarCloud GitHub App completes a check: +The following represents an example GitHub Actions workflow that uploads +SonarCloud results to Pixeebot. It runs each time the SonarCloud GitHub App +completes a check: ```yaml name: "Publish Sonar JSON to Pixee" @@ -121,4 +129,8 @@ jobs: sonar-component-key: ${{ secrets.SONAR_COMPONENT_KEY }} ``` -Note the use of the repository secrets `SONAR_TOKEN` and `SONAR_COMPONENT_KEY`. The `SONAR_TOKEN` secret is required for private repositories. The `SONAR_COMPONENT_KEY` secret is optional and only necessary if deviating from SonarCloud's established convention. If used, each secret must be defined in the repository's settings. +Note the use of the repository secrets `SONAR_TOKEN` and `SONAR_COMPONENT_KEY`. +The `SONAR_TOKEN` secret is required for private repositories. The +`SONAR_COMPONENT_KEY` secret is optional and only necessary if deviating from +SonarCloud's established convention. If used, each secret must be defined in the +repository's settings. diff --git a/__tests__/action.test.ts b/__tests__/action.test.ts index 2fe3230..4d55cfe 100644 --- a/__tests__/action.test.ts +++ b/__tests__/action.test.ts @@ -45,7 +45,7 @@ describe("action", () => { .spyOn(github, "getRepositoryInfo") .mockImplementation(); retrieveSonarCloudIssuesMock.mockResolvedValue({ total: 1 }); - retrieveSonarCloudHotspotsMock.mockResolvedValue({ paging: {total: 1} }); + retrieveSonarCloudHotspotsMock.mockResolvedValue({ paging: { total: 1 } }); }); it("triggers PR analysis when the PR number is available", async () => { @@ -67,7 +67,7 @@ describe("action", () => { }); getRepositoryInfoMock.mockReturnValue({ owner: "owner", - repo: "repo" + repo: "repo", }); triggerPrAnalysisMock.mockResolvedValue(undefined); @@ -95,12 +95,15 @@ describe("action", () => { }); getRepositoryInfoMock.mockReturnValue({ owner: "owner", - repo: "repo" + repo: "repo", }); await run(); - expect(uploadInputFileMock).toHaveBeenCalledWith("sonar_issues", "file.json"); + expect(uploadInputFileMock).toHaveBeenCalledWith( + "sonar_issues", + "file.json", + ); }); it("should upload the given semgrep file", async () => { @@ -121,7 +124,7 @@ describe("action", () => { }); getRepositoryInfoMock.mockReturnValue({ owner: "owner", - repo: "repo" + repo: "repo", }); await run(); @@ -147,7 +150,7 @@ describe("action", () => { sha: "sha", }); - expect(run()).rejects.toThrow("Tool \"semgrep\" requires a file input"); + expect(run()).rejects.toThrow('Tool "semgrep" requires a file input'); }); it("should retrieve the SonarCloud results, when the tool is Sonar", async () => { @@ -167,7 +170,7 @@ describe("action", () => { getRepositoryInfoMock.mockReturnValue({ owner: "owner", - repo: "repo" + repo: "repo", }); await run(); @@ -176,7 +179,7 @@ describe("action", () => { expect(retrieveSonarCloudHotspotsMock).toHaveBeenCalled(); expect(uploadInputFileMock).toHaveBeenCalledWith( "sonar_issues", - expect.stringMatching(/sonar-issues.json$/) + expect.stringMatching(/sonar-issues.json$/), ); }); }); diff --git a/__tests__/pixee-platform.test.ts b/__tests__/pixee-platform.test.ts index f31b63b..3eec935 100644 --- a/__tests__/pixee-platform.test.ts +++ b/__tests__/pixee-platform.test.ts @@ -48,7 +48,7 @@ describe("pixee-platform", () => { Authorization: "Bearer token", // Assert the authorization header "content-type": expect.stringContaining("multipart/form-data"), // Assert the content type header }, - } + }, ); }); }); diff --git a/action.yml b/action.yml index 08f368f..731fd03 100644 --- a/action.yml +++ b/action.yml @@ -37,7 +37,9 @@ inputs: description: Base URL of the Contrast API. required: false contrast-org-id: - description: Unique identifier for the organization in Contrast that needs to be analyzed. + description: + Unique identifier for the organization in Contrast that needs to be + analyzed. required: false contrast-app-id: description: Unique identifier for the specific application within Contrast. diff --git a/src/action.ts b/src/action.ts index 9400b1b..017b35c 100644 --- a/src/action.ts +++ b/src/action.ts @@ -2,10 +2,15 @@ import * as core from "@actions/core"; import fs from "fs"; import { Tool, getTool } from "./inputs"; import { triggerPrAnalysis, uploadInputFile } from "./pixee-platform"; -import { SONAR_RESULT, getSonarCloudInputs, retrieveSonarCloudHotspots, retrieveSonarCloudIssues } from "./sonar"; +import { + SONAR_RESULT, + getSonarCloudInputs, + retrieveSonarCloudHotspots, + retrieveSonarCloudIssues, +} from "./sonar"; import { getDefectDojoInputs, retrieveDefectDojoResults } from "./defect-dojo"; import { getGitHubContext, getTempDir } from "./github"; -import {getContrastInputs, retrieveContrastResults} from "./contrast"; +import { getContrastInputs, retrieveContrastResults } from "./contrast"; /** * Runs the action. @@ -17,7 +22,7 @@ import {getContrastInputs, retrieveContrastResults} from "./contrast"; export async function run() { const tool = getTool(); - switch(tool){ + switch (tool) { case "contrast": const contrastFile = await fetchOrLocateContrastResultsFile(); await uploadInputFile(tool, contrastFile); @@ -29,11 +34,11 @@ export async function run() { core.info(`Uploaded ${file} to Pixeebot for analysis`); break; case "sonar": - const issuesfile = await fetchOrLocateSonarResultsFile("issues"); + const issuesfile = await fetchOrLocateSonarResultsFile("issues"); await uploadInputFile("sonar_issues", issuesfile); core.info(`Uploaded ${issuesfile} to Pixeebot for analysis`); - const hotspotFile = await fetchOrLocateSonarResultsFile("hotspots"); + const hotspotFile = await fetchOrLocateSonarResultsFile("hotspots"); await uploadInputFile("sonar_hotspots", hotspotFile); core.info(`Uploaded ${hotspotFile} to Pixeebot for analysis`); break; @@ -55,11 +60,9 @@ export async function run() { } async function fetchOrLocateDefectDojoResultsFile() { - let results = await fetchDefectDojoFindings(); let fileName = "defectdojo.findings.json"; - return fetchOrLocateResultsFile("defectdojo", results, fileName); } @@ -70,14 +73,22 @@ async function fetchOrLocateContrastResultsFile() { return fetchOrLocateResultsFile("contrast", results, fileName, false); } -async function fetchOrLocateSonarResultsFile(resultType : SONAR_RESULT) { - let results = resultType == "issues" ? await fetchSonarCloudIssues() : await fetchSonarCloudHotspots(); +async function fetchOrLocateSonarResultsFile(resultType: SONAR_RESULT) { + let results = + resultType == "issues" + ? await fetchSonarCloudIssues() + : await fetchSonarCloudHotspots(); let fileName = `sonar-${resultType}.json`; return fetchOrLocateResultsFile("sonar", results, fileName); } -async function fetchOrLocateResultsFile(tool: Tool, results: any, fileName: string, stringifyResults: boolean = true) { +async function fetchOrLocateResultsFile( + tool: Tool, + results: any, + fileName: string, + stringifyResults: boolean = true, +) { let file = core.getInput("file"); if (file !== "") { return file; @@ -95,42 +106,42 @@ async function fetchOrLocateResultsFile(tool: Tool, results: any, fileName: stri return file; } -async function fetchSonarCloudIssues(){ +async function fetchSonarCloudIssues() { const sonarCloudInputs = getSonarCloudInputs(); const results = await retrieveSonarCloudIssues(sonarCloudInputs); core.info( - `Found ${results.total} SonarCloud issues for component ${sonarCloudInputs.componentKey}` + `Found ${results.total} SonarCloud issues for component ${sonarCloudInputs.componentKey}`, ); if (results.total === 0) { core.info( - `When the SonarCloud token is incorrect, SonarCloud responds with an empty response indistinguishable from cases where there are no issues. If you expected issues, please check the token.` + `When the SonarCloud token is incorrect, SonarCloud responds with an empty response indistinguishable from cases where there are no issues. If you expected issues, please check the token.`, ); } return results; } -async function fetchSonarCloudHotspots(){ +async function fetchSonarCloudHotspots() { const sonarCloudInputs = getSonarCloudInputs(); const results = await retrieveSonarCloudHotspots(sonarCloudInputs); core.info( - `Found ${results.paging.total} SonarCloud hotspots for component ${sonarCloudInputs.componentKey}` + `Found ${results.paging.total} SonarCloud hotspots for component ${sonarCloudInputs.componentKey}`, ); return results; } -async function fetchDefectDojoFindings(){ +async function fetchDefectDojoFindings() { const inputs = getDefectDojoInputs(); - const findings = await retrieveDefectDojoResults(inputs); + const findings = await retrieveDefectDojoResults(inputs); core.info( - `Found ${findings.count} DefectDojo findings for component ${inputs.productName}` + `Found ${findings.count} DefectDojo findings for component ${inputs.productName}`, ); return findings; } -async function fetchContrastFindings(): Promise{ +async function fetchContrastFindings(): Promise { const inputs = getContrastInputs(); const results = await retrieveContrastResults(inputs); diff --git a/src/contrast.ts b/src/contrast.ts index 1a86427..bab85b1 100644 --- a/src/contrast.ts +++ b/src/contrast.ts @@ -1,44 +1,41 @@ import * as core from "@actions/core"; import axios from "axios"; -import JSZip from 'jszip'; - +import JSZip from "jszip"; export async function retrieveContrastResults( - contrastInputs: ContrastInputs + contrastInputs: ContrastInputs, ): Promise { - const { token, apiKey } = contrastInputs + const { token, apiKey } = contrastInputs; const url = buildContrastUrl(contrastInputs); core.info(`Retrieving contrast results from ${url}`); return axios - .post(url, null,{ + .post(url, null, { headers: { Authorization: token, - 'API-Key': apiKey, - Accept: 'application/x-zip-compressed' + "API-Key": apiKey, + Accept: "application/x-zip-compressed", }, - responseType: 'arraybuffer', + responseType: "arraybuffer", }) .then(async (response) => { try { const zip = await JSZip.loadAsync(response.data); const xmlFileName = Object.keys(zip.files)[0]; - const xmlContent = await zip.file(xmlFileName)?.async('string'); + const xmlContent = await zip.file(xmlFileName)?.async("string"); if (core.isDebug()) { - core.info( - `Retrieved contrast results: ${xmlContent}` - ); + core.info(`Retrieved contrast results: ${xmlContent}`); } return xmlContent; } catch (error) { - console.error('Error extracting ZIP file:', error); + console.error("Error extracting ZIP file:", error); throw error; } }) - .catch(error => { - console.error('Error fetching the ZIP file:', error); + .catch((error) => { + console.error("Error fetching the ZIP file:", error); throw error; }); } @@ -62,7 +59,7 @@ export function getContrastInputs(): ContrastInputs { } function buildContrastUrl(inputs: ContrastInputs): string { - const { apiUrl, orgId, appId } = inputs + const { apiUrl, orgId, appId } = inputs; return `${apiUrl}/Contrast/api/ng/${orgId}/traces/${appId}/export/xml/all`; } diff --git a/src/defect-dojo.ts b/src/defect-dojo.ts index 8b116f4..2b5b2fe 100644 --- a/src/defect-dojo.ts +++ b/src/defect-dojo.ts @@ -1,6 +1,6 @@ import * as core from "@actions/core"; import axios from "axios"; -import {getRepositoryInfo} from "./github"; +import { getRepositoryInfo } from "./github"; /** * Response from DefectDojo API search endpoint. @@ -10,7 +10,7 @@ interface DefectDojoSearchResults { } export async function retrieveDefectDojoResults( - defectDojoInputs: DefectDojoInputs + defectDojoInputs: DefectDojoInputs, ) { const { token } = defectDojoInputs; const url = buildDefectDojoUrl(defectDojoInputs); @@ -26,11 +26,10 @@ export async function retrieveDefectDojoResults( }) .then((response) => { core.debug( - `Retrieved DefectDojo results: ${JSON.stringify(response.data)}` + `Retrieved DefectDojo results: ${JSON.stringify(response.data)}`, ); return response.data as DefectDojoSearchResults; }); - } interface DefectDojoInputs { @@ -53,10 +52,7 @@ export function getDefectDojoInputs(): DefectDojoInputs { return { token, productName, apiUrl }; } -function buildDefectDojoUrl({ - apiUrl, - productName, -}: DefectDojoInputs): string { +function buildDefectDojoUrl({ apiUrl, productName }: DefectDojoInputs): string { // TODO define which queries need to be applied const url = `${apiUrl}/api/v2/findings/?product_name=${productName}&limit=100`; return url; diff --git a/src/github.ts b/src/github.ts index f1061dc..339179c 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,11 +1,11 @@ import * as github from "@actions/github"; -import {Context} from "@actions/github/lib/context"; +import { Context } from "@actions/github/lib/context"; import * as core from "@actions/core"; /** * Normalized GitHub event context. */ -export type GitHubContext = RepositoryInfo & PullRequestInfo +export type GitHubContext = RepositoryInfo & PullRequestInfo; export interface RepositoryInfo { owner: string; @@ -25,13 +25,15 @@ export interface PullRequestInfo { * @returns The normalized GitHub context. */ export function getGitHubContext(): GitHubContext { - const context = github.context - const { eventName, sha} = context; + const context = github.context; + const { eventName, sha } = context; const commitInfo = - eventName !== 'workflow_dispatch' ? eventHandlers[eventName](context) : {sha} + eventName !== "workflow_dispatch" + ? eventHandlers[eventName](context) + : { sha }; - return { ...getRepositoryInfo(), ...commitInfo}; + return { ...getRepositoryInfo(), ...commitInfo }; } /** @@ -54,18 +56,14 @@ export function getTempDir(): string { return temp; } -function getPullRequestContext( - context: Context -): PullRequestInfo { +function getPullRequestContext(context: Context): PullRequestInfo { const prNumber = context.issue.number; const sha = context.payload.pull_request?.head.sha; return { prNumber, sha }; } -function getCheckRunContext( - context: Context -): PullRequestInfo { +function getCheckRunContext(context: Context): PullRequestInfo { const actionEvent = context.payload.check_run; const prNumber = actionEvent.pull_requests?.[0]?.number; const sha = actionEvent.head_sha; @@ -74,10 +72,8 @@ function getCheckRunContext( } const eventHandlers: { - [eventName: string]: ( - context: Context - ) => PullRequestInfo; + [eventName: string]: (context: Context) => PullRequestInfo; } = { check_run: getCheckRunContext, - pull_request: getPullRequestContext + pull_request: getPullRequestContext, }; diff --git a/src/inputs.ts b/src/inputs.ts index af0a971..5c62d4f 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -1,9 +1,22 @@ import * as core from "@actions/core"; import { UserError } from "./errors"; -export type Tool = "sonar" | "codeql" | "semgrep" | "appscan" | "defectdojo" | "contrast"; +export type Tool = + | "sonar" + | "codeql" + | "semgrep" + | "appscan" + | "defectdojo" + | "contrast"; -export type TOOL_PATH = "sonar_issues" | "sonar_hotspots" | "codeql" | "semgrep" | "appscan" | "defectdojo" | "contrast"; +export type TOOL_PATH = + | "sonar_issues" + | "sonar_hotspots" + | "codeql" + | "semgrep" + | "appscan" + | "defectdojo" + | "contrast"; /** * Helper function to get the selected tool from the action's inputs. @@ -20,10 +33,17 @@ function validateTool(tool: Tool) { if (!VALID_TOOLS.includes(tool)) { throw new UserError( `Invalid tool "${tool}". The tool must be one of: ${VALID_TOOLS.join( - ", " - )}.` + ", ", + )}.`, ); } } -const VALID_TOOLS: Tool[] = ["sonar", "codeql", "semgrep", "appscan", "defectdojo", "contrast"]; +const VALID_TOOLS: Tool[] = [ + "sonar", + "codeql", + "semgrep", + "appscan", + "defectdojo", + "contrast", +]; diff --git a/src/pixee-platform.ts b/src/pixee-platform.ts index 500996d..f8d5c56 100644 --- a/src/pixee-platform.ts +++ b/src/pixee-platform.ts @@ -3,7 +3,7 @@ import axios from "axios"; import fs from "fs"; import FormData from "form-data"; import { TOOL_PATH } from "./inputs"; -import {getGitHubContext, getRepositoryInfo} from "./github"; +import { getGitHubContext, getRepositoryInfo } from "./github"; export async function uploadInputFile(tool: TOOL_PATH, file: string) { const fileContent = fs.readFileSync(file, "utf-8"); @@ -12,7 +12,7 @@ export async function uploadInputFile(tool: TOOL_PATH, file: string) { const pixeeUrl = core.getInput("pixee-api-url"); const token = await core.getIDToken(pixeeUrl); - const url = buildUploadApiUrl(tool) + const url = buildUploadApiUrl(tool); return axios .put(url, form, { diff --git a/src/sonar.ts b/src/sonar.ts index 13d1c7e..ea6a49b 100644 --- a/src/sonar.ts +++ b/src/sonar.ts @@ -1,6 +1,6 @@ import * as core from "@actions/core"; import axios from "axios"; -import {getGitHubContext, getRepositoryInfo} from "./github"; +import { getGitHubContext, getRepositoryInfo } from "./github"; /** * Response from SonarCloud API search endpoint. Sparse implementation, because we only care about the total number of issues. @@ -22,23 +22,23 @@ export type SONAR_RESULT = "issues" | "hotspots"; const MAX_PAGE_SIZE = 500; export async function retrieveSonarCloudIssues( - sonarCloudInputs: SonarCloudInputs -) : Promise { - const url = buildSonarCloudIssuesUrl(sonarCloudInputs); - return retrieveSonarCloudResults(sonarCloudInputs, url, "issues") + sonarCloudInputs: SonarCloudInputs, +): Promise { + const url = buildSonarCloudIssuesUrl(sonarCloudInputs); + return retrieveSonarCloudResults(sonarCloudInputs, url, "issues"); } export async function retrieveSonarCloudHotspots( - sonarCloudInputs: SonarCloudInputs -) : Promise { - const url = buildSonarCloudHotspotsUrl(sonarCloudInputs); - return retrieveSonarCloudResults(sonarCloudInputs, url, "hotspots") + sonarCloudInputs: SonarCloudInputs, +): Promise { + const url = buildSonarCloudHotspotsUrl(sonarCloudInputs); + return retrieveSonarCloudResults(sonarCloudInputs, url, "hotspots"); } async function retrieveSonarCloudResults( - {token}: SonarCloudInputs, + { token }: SonarCloudInputs, url: string, - resultType: SONAR_RESULT + resultType: SONAR_RESULT, ) { core.info(`Retrieving SonarCloud ${resultType} from ${url}`); return axios @@ -52,7 +52,7 @@ async function retrieveSonarCloudResults( .then((response) => { if (core.isDebug()) { core.info( - `Retrieved SonarCloud ${resultType}: ${JSON.stringify(response.data)}` + `Retrieved SonarCloud ${resultType}: ${JSON.stringify(response.data)}`, ); } return response.data; @@ -79,11 +79,10 @@ export function getSonarCloudInputs(): SonarCloudInputs { function buildSonarCloudIssuesUrl({ apiUrl, componentKey, -}: SonarCloudInputs -): string { +}: SonarCloudInputs): string { const { prNumber } = getGitHubContext(); const url = `${apiUrl}/issues/search?componentKeys=${encodeURIComponent( - componentKey + componentKey, )}&resolved=false&ps=${MAX_PAGE_SIZE}`; return prNumber ? `${url}&pullRequest=${prNumber}` : url; } @@ -91,11 +90,10 @@ function buildSonarCloudIssuesUrl({ function buildSonarCloudHotspotsUrl({ apiUrl, componentKey, -}: SonarCloudInputs -): string { +}: SonarCloudInputs): string { const { prNumber } = getGitHubContext(); const url = `${apiUrl}/hotspots/search?projectKey=${encodeURIComponent( - componentKey + componentKey, )}&resolved=false&ps=${MAX_PAGE_SIZE}`; return prNumber ? `${url}&pullRequest=${prNumber}` : url; }