DigitalConsumer.ISS/playwright-tests/impl/reporter/JiraWritebackReporter.ts
chase-safelite 55bb84cca4
Jira writeback reporter to add subtasks with test results (#935)
* Progress toward Jira integration

* Jira Writeback reporter working

* Added correct timeouts

* Added Jira Writeback reporter to pipeline

* Added tag for testing

* Removed unnecessary async

* Fixed rate limit issue

* Moved to only transition existing test case subtasks

* Changing to transition issues by key

* Enabled mocking for scenario 2

* Added better error handling in api util

* Changed workers to 1 to eliminate duplicate test runs

* Streamlined batch creation and retry check

* Modified test data structure to remove undeterministic sort

* Increased workers

* Increased heap size for jira upload

* Cleanup

* Made logs more specific for JiraWritebackReporter
2025-04-09 10:36:30 -04:00

339 lines
No EOL
13 KiB
TypeScript

import JiraApiUtil from "@impl/api/JiraApiUtil";
import type { FullResult, Reporter, TestCase, TestResult } from "@playwright/test/reporter";
import { formatDate } from "@impl/utils/DateUtils";
import { GetIssueResponse, JiraIssue, JiraIssueFields } from "@business-logic/types/JiraApi";
const jiraCardNumber = process.env.JIRA_CARD_NUMBER!;
const jiraProjectKey = 'INSR';
const passTransitionId = '111';
const failTransitionId = '101';
export default class JiraWritebackReporter implements Reporter {
readonly jiraApiUtil: JiraApiUtil = new JiraApiUtil();
readonly loadIssueCalls: (() => void)[] = []; // Calls to loadIssue must be deferred because they depend on information from the API.
readonly issuesToPass: string[] = []; // Issues to transition to "Pass".
readonly issuesToFail: string[] = []; // Issues to transition to "Fail".
readonly issuesToCreate: JiraIssue[] = []; // Issues to be batch created.
existingSubtasks: JiraIssue[] = []; // Array to hold existing subtasks of the dev card.
testCaseTypeId: string|undefined = undefined; // ID of the test case subtask type in Jira. Will be filled by API call.
bugTypeId:string|undefined = undefined; // ID of the bug subtask type.
parentCard: GetIssueResponse | undefined = undefined; // Variable to hold the parent card. Will be filled by API call.
// Will need this if we want to move ortoni report upload into this reporter.
// readonly ortoniReport = new OrtoniReport(reportConfig);
/**
* This function loads the IDs for the Jira Issue Types we use.
*
*/
async loadJiraIssueTypes() {
console.log(`JiraWritebackReporter >> Loading Jira Issue Types for project '${jiraProjectKey}'...`);
const issueTypesRes = await this.jiraApiUtil.getIssueTypes(jiraProjectKey);
const issueTypes = issueTypesRes.data;
this.testCaseTypeId = issueTypes.issueTypes.find(issueType => {
return issueType.subtask === true && issueType.name === 'Test Case Sub-task'
})?.id;
this.bugTypeId = issueTypes.issueTypes.find(issueType => {
return issueType.subtask === true && issueType.name === 'Bug Sub-task'
})?.id;
console.log('JiraWritebackReporter >> Loaded issue types.');
}
/**
* This function generates a test subtask based on the test results we pass in.
* @param test TestCase from onTestEnd()
* @param result TestResult from onTestEnd()
* @returns Jira Test Subtask based on test result.
*/
getCreateTestSubtask(test: TestCase, result: TestResult) {
if (result.status === 'skipped') {
return undefined;
}
const testStepTitles = result.steps.map(step => {
return `-\t${step.title}`;
}).join('\n');
const allErrors = result.errors.map(value => {
return value.message
}).join('\n');
const description = `Most recent test status: ${result.status}.\nDuration: ${result.duration/1000} seconds.\nErrors:\n${allErrors}`
const testSubtaskBody: JiraIssueFields = {
summary: test.title,
description: {
content: [
{
"content": [
{
"text": description,
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
},
project: {
key: jiraProjectKey
},
issuetype: {
id: this.testCaseTypeId!
},
parent: {
key: jiraCardNumber
},
customfield_14857: {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: testStepTitles
}
]
}
]
}
};
return testSubtaskBody;
}
/**
* This function generates a bug subtask based on the test results we pass in.
* @param test TestCase from onTestEnd()
* @param result TestResult from onTestEnd()
* @returns Jira Bug Subtask based on test result.
*/
getCreateBug(test: TestCase, result: TestResult) {
if (result.status === 'passed' || result.status === 'skipped') {
return undefined;
}
const allErrors = result.errors.map(value => {
return value.message
}).join('\n');
const bug: JiraIssueFields = {
summary: `TEST FAILED ${formatDate(new Date())}: ${test.title}`,
description: {
content: [
{
"content": [
{
"text": allErrors,
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
},
project: {
key: jiraProjectKey
},
issuetype: {
id: this.bugTypeId!
},
parent: {
key: jiraCardNumber
}
};
return bug;
}
/**
* This function gives us the correct transition for a test subtask based on this test result.
* @param result TestResult from onTestEnd()
* @returns Correct transition to pass to the Jira API.
*/
getSubtaskTransition(result: TestResult): { id: string } | undefined {
if (result.status === 'skipped') {
return undefined;
}
if (result.status === 'passed') {
return {
id: passTransitionId
};
} else {
return {
id: failTransitionId
};
}
}
/**
* Grabs parent card and existing subtasks.
*/
async loadReporterData() {
await this.loadJiraIssueTypes();
console.log('JiraWritebackReporter >> Searching for parent card...');
const parentRes = await this.jiraApiUtil.getIssue(jiraCardNumber);
const parentCard = parentRes.data;
console.log(`JiraWritebackReporter >> Retrieved parent card: ${parentCard.key}: ${parentCard.fields.summary}`);
console.log(`JiraWritebackReporter >> Searching for all subtasks of parent card '${jiraCardNumber}'...`);
const subtaskIds = parentCard.fields.subtasks?.map(subtask => {
return subtask.id;
});
if (subtaskIds && subtaskIds.length > 0) {
for (let i = 0; i < subtaskIds.length; i+=50) {
const batch = subtaskIds.slice(i, i+50);
const subTasksRes = await this.jiraApiUtil.postBulkFetchIssues({ issueIdsOrKeys: batch });
this.existingSubtasks.push(...subTasksRes.data.issues);
}
console.log('JiraWritebackReporter >> Subtasks retrieved.');
} else {
console.log('JiraWritebackReporter >> No subtasks were found.');
}
}
/**
* Bulk creates test cases and bugs in batches of up to 50. Bulk transitions existing test case subtasks.
*/
async writeResults() {
for (let i = 0; i < this.issuesToCreate.length; i+=50) {
const batch = this.issuesToCreate.slice(i, i + 50);
console.log('JiraWritebackReporter >> Creating issue batch...');
// fire request
await this.jiraApiUtil.postBulkCreateIssue({ issueUpdates: batch });
console.log('JiraWritebackReporter >> Issue batch created.');
}
if (this.issuesToPass.length > 0) {
console.log('JiraWritebackReporter >> Transitioning passed test cases to "Pass"...')
await this.jiraApiUtil.postBulkTransitionIssues( {
bulkTransitionInputs: [{
selectedIssueIdsOrKeys: this.issuesToPass,
transitionId: passTransitionId
}],
sendBulkNotification: false
});
console.log('JiraWritebackReporter >> Transition success.');
}
if (this.issuesToFail.length > 0) {
console.log('JiraWritebackReporter >> Transitioning failed test cases to "Fail"...')
await this.jiraApiUtil.postBulkTransitionIssues( {
bulkTransitionInputs: [{
selectedIssueIdsOrKeys: this.issuesToFail,
transitionId: failTransitionId
}],
sendBulkNotification: false
});
console.log('JiraWritebackReporter >> Transition success.')
}
}
/**
* Builds and loads appropriate response based on test result. This can be a test subtask/bug subtask or a call to transition a test subtask.
* @param test TestCase from onTestEnd()
* @param result TestResult from onTestEnd()
* @returns Void
*/
loadIssue(test: TestCase, result: TestResult) {
if (result.status === 'skipped') return;
if (result.status !== 'passed' && result.retry < test.retries) return; // Skip if test case failed and this isn't the last retry.
let existingTestSubtask: JiraIssue | undefined = undefined;
let existingBug: JiraIssue | undefined = undefined;
const subtaskTransition = this.getSubtaskTransition(result)!; // Get either a Pass or Fail transition depending on test results.
console.log('JiraWritebackReporter >> Checking for existing subtasks for this test...');
for (const card of this.existingSubtasks) {
if (card.fields.issuetype?.id === this.testCaseTypeId) {
if (card.fields.summary === test.title) {
existingTestSubtask = card;
console.log('JiraWritebackReporter >> Found existing test subtask.');
}
} else if (card.fields.issuetype?.id === this.bugTypeId) {
if (new RegExp(`^TEST FAILED [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]: ${test.title}$`).test(card.fields.summary)) {
existingBug = card;
console.log('JiraWritebackReporter >> Found existing bug.');
// TODO: Handle multiple matches for bug/test case. Currently picks one in that case.
}
}
}
if (!(existingTestSubtask || existingBug)) {
console.log('JiraWritebackReporter >> No existing test subtask or bug was found.')
}
const subTask = this.getCreateTestSubtask(test, result); // Will return undefined if test status is 'skipped'
const bug = this.getCreateBug(test, result); // Will return undefined if we don't need one
if (subTask) {
// Create or Edit Subtask
if (existingTestSubtask) {
if (result.status === 'passed') {
this.issuesToPass.push(existingTestSubtask.key!);
} else {
this.issuesToFail.push(existingTestSubtask.key!);
}
} else {
this.issuesToCreate.push({
fields: subTask,
transition: subtaskTransition
});
}
}
if (bug) {
// Create or Edit Bug
if (existingBug) {
// Leave it alone. This reporter should not modify existing bugs.
} else {
this.issuesToCreate.push({
fields: bug
});
}
}
}
/**
* This function loads calls to loadIssue() in an array to be executed when the necessary information from the Jira API is available.
* @param test
* @param result
*/
onTestEnd(test: TestCase, result: TestResult) {
// this.ortoniReport.onTestEnd(test, result);
this.loadIssueCalls.push(() => { this.loadIssue(test, result); });
}
/**
* Makes calls to load and execute API calls to Jira.
* @param result
* @returns Promise to write results to Jira.
*/
onEnd(result: FullResult): Promise<{ status?: FullResult["status"]; } | undefined | void>|void {
// this.ortoniReport.onEnd(result);
return this.loadReporterData().then(async () => {
this.loadIssueCalls.map(fn => fn());
await this.writeResults();
console.log(`JiraWritebackReporter >> Posted necessary changes for '${jiraCardNumber}'`);
});
}
// These overloads will become necessary if we move the ortoni report upload into this reporter
// onBegin(config: FullConfig, suite: Suite): void {
// this.ortoniReport.onBegin(config, suite);
// }
// onError(error: TestError): void {
// this.ortoniReport.onError(error);
// }
// onExit(): Promise<void> {
// return this.ortoniReport.onExit();
// }
// onStdOut(chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void {
// this.ortoniReport.onStdOut(chunk, test, result);
// }
}