From 36f68d405e1558894a2fc82e503113cd3255dad0 Mon Sep 17 00:00:00 2001 From: ptvrajsk <39243082+ptvrajsk@users.noreply.github.com> Date: Wed, 25 Sep 2019 14:23:27 +0800 Subject: [PATCH] Refactor issue model (#201) * Create templating classes for issue model * Add string representation of sections * Add return types to base issue * Migrate bug reporting phase to use the new model * Remove unused import * Fix typo * Update import style * Rank Labels * Rename methods * Lint Fix: const instead of let * Migrated TeamResponsePhase to use BaseIssue createIssueModel has been modified to work with baseIssue for the TeamResponsePhase. * Empty commit * Fix Team Response Parsing * TeamResp. Mostly working, left comment error * Refactor of Issue Model Creation for TeamResponse IssueModel Creation for TeamResponse has been refactored to follow the new mode. The description reading bug has also been fixed as of this commit. * Fixed Duplicate Info Display Duplicate information was not being correctly displayed on toggle. Issue has now been fixed. * Fixed Severity Toggling Severity Toggle Used to reset the issue in some cases. That issue has been resolved. * Fix Issue Duplicate Removing Description * Fix same issue with Assignees * Lint Fix --- src/app/core/models/base-issue.model.ts | 104 ++++++++++++++++ src/app/core/models/github-comment.model.ts | 16 +++ src/app/core/models/github-issue.model.ts | 113 ++++++++++++++++++ src/app/core/models/issue.model.ts | 4 +- src/app/core/models/team.model.ts | 2 +- .../sections/duplicate-of-section.model.ts | 25 ++++ .../sections/issue-dispute-section.model.ts | 29 +++++ .../sections/moderation-section.model.ts | 38 ++++++ .../templates/sections/section.model.ts | 39 ++++++ .../sections/test-response-section.model.ts | 31 +++++ .../templates/team-response-template.model.ts | 44 +++++++ .../core/models/templates/template.model.ts | 48 ++++++++ .../tester-response-template.model.ts | 31 +++++ .../tutor-moderation-issue-template.model.ts | 38 ++++++ .../tutor-moderation-todo-template.model.ts | 23 ++++ src/app/core/models/user.model.ts | 2 +- src/app/core/services/data.service.ts | 3 +- src/app/core/services/github.service.ts | 29 ++--- .../core/services/issue-comment.service.ts | 12 +- src/app/core/services/issue.service.ts | 30 ++++- .../issue/assignee/assignee.component.ts | 10 +- .../duplicateOf/duplicate-of.component.ts | 5 +- src/app/shared/issue/label/label.component.ts | 12 +- .../new-team-response.component.ts | 4 +- 24 files changed, 653 insertions(+), 39 deletions(-) create mode 100644 src/app/core/models/base-issue.model.ts create mode 100644 src/app/core/models/github-comment.model.ts create mode 100644 src/app/core/models/github-issue.model.ts create mode 100644 src/app/core/models/templates/sections/duplicate-of-section.model.ts create mode 100644 src/app/core/models/templates/sections/issue-dispute-section.model.ts create mode 100644 src/app/core/models/templates/sections/moderation-section.model.ts create mode 100644 src/app/core/models/templates/sections/section.model.ts create mode 100644 src/app/core/models/templates/sections/test-response-section.model.ts create mode 100644 src/app/core/models/templates/team-response-template.model.ts create mode 100644 src/app/core/models/templates/template.model.ts create mode 100644 src/app/core/models/templates/tester-response-template.model.ts create mode 100644 src/app/core/models/templates/tutor-moderation-issue-template.model.ts create mode 100644 src/app/core/models/templates/tutor-moderation-todo-template.model.ts diff --git a/src/app/core/models/base-issue.model.ts b/src/app/core/models/base-issue.model.ts new file mode 100644 index 000000000..bbe8b2132 --- /dev/null +++ b/src/app/core/models/base-issue.model.ts @@ -0,0 +1,104 @@ +import { TeamResponseTemplate } from './templates/team-response-template.model'; +import { Issue } from './issue.model'; +import { IssueDispute } from './issue-dispute.model'; +import { IssueComment } from './comment.model'; +import { TutorModerationIssueTemplate } from './templates/tutor-moderation-issue-template.model'; +import { TutorModerationTodoTemplate } from './templates/tutor-moderation-todo-template.model'; +import { Team } from './team.model'; +import { TesterResponse } from './tester-response.model'; +import { TesterResponseTemplate } from './templates/tester-response-template.model'; +import { GithubIssue, GithubLabel } from './github-issue.model'; +import * as moment from 'moment'; +import { GithubComment } from './github-comment.model'; +import { DataService } from '../services/data.service'; + +export class BaseIssue implements Issue { + + /** Basic Fields */ + readonly id: number; + readonly created_at: string; + title: string; + description: string; + + /** Fields derived from Labels */ + severity: string; + type: string; + responseTag?: string; + duplicated?: boolean; + status?: string; + pending?: string; + unsure?: boolean; + teamAssigned?: Team; + + /** Depending on the phase, assignees attribute can be derived from Github's assignee feature OR from the Github's issue description */ + assignees?: string[]; + + /** Fields derived from parsing of Github's issue description */ + duplicateOf?: number; + todoList?: string[]; + teamResponse?: string; + tutorResponse?: string; + testerResponses?: TesterResponse[]; + issueComment?: IssueComment; // Issue comment is used for Tutor Response and Tester Response + issueDisputes?: IssueDispute[]; + + protected constructor(githubIssue: GithubIssue) { + /** Basic Fields */ + this.id = +githubIssue.number; + this.created_at = moment(githubIssue.created_at).format('lll'); + this.title = githubIssue.title; + this.description = githubIssue.body; + + /** Fields derived from Labels */ + this.severity = githubIssue.findLabel(GithubLabel.LABELS.severity); + this.type = githubIssue.findLabel(GithubLabel.LABELS.type); + this.responseTag = githubIssue.findLabel(GithubLabel.LABELS.response); + this.duplicated = !!githubIssue.findLabel(GithubLabel.LABELS.duplicated, false); + this.status = githubIssue.findLabel(GithubLabel.LABELS.status); + this.pending = githubIssue.findLabel(GithubLabel.LABELS.pending); + } + + private static constructTeamData(githubIssue: GithubIssue, dataService: DataService): Team { + const teamId = githubIssue.findLabel(GithubLabel.LABELS.tutorial).concat('-').concat(githubIssue.findLabel(GithubLabel.LABELS.team)); + return dataService.getTeam(teamId); + } + + public static createPhaseBugReportingIssue(githubIssue: GithubIssue): BaseIssue { + return new BaseIssue(githubIssue); + } + + public static createPhaseTeamResponseIssue(githubIssue: GithubIssue, githubComments: GithubComment[] + , teamData: Team): Issue { + const issue = new BaseIssue(githubIssue); + const template = new TeamResponseTemplate(githubComments); + + issue.teamAssigned = teamData; + issue.issueComment = template.comment; + issue.teamResponse = template.teamResponse !== undefined ? template.teamResponse.content : undefined; + issue.duplicateOf = template.duplicateOf !== undefined ? template.duplicateOf.issueNumber : undefined; + issue.duplicated = issue.duplicateOf !== undefined && issue.duplicateOf !== null; + issue.assignees = githubIssue.assignees.map(assignee => assignee.login); + + return issue; + } + + public static createPhaseTesterResponseIssue(githubIssue: GithubIssue, githubComments: GithubComment[]): Issue { + const issue = new BaseIssue(githubIssue); + const template = new TesterResponseTemplate(githubComments); + issue.teamResponse = template.teamResponse.content; + issue.testerResponses = template.testerResponse.testerResponses; + return issue; + } + + public static createPhaseModerationIssue(githubIssue: GithubIssue, githubComments: GithubComment[]): Issue { + const issue = new BaseIssue(githubIssue); + const issueTemplate = new TutorModerationIssueTemplate(githubIssue); + const todoTemplate = new TutorModerationTodoTemplate(githubComments); + + issue.description = issueTemplate.description.content; + issue.teamResponse = issueTemplate.teamResponse.content; + issue.issueDisputes = issueTemplate.dispute.disputes; + issue.todoList = todoTemplate.moderation.todoList; + return issue; + } +} diff --git a/src/app/core/models/github-comment.model.ts b/src/app/core/models/github-comment.model.ts new file mode 100644 index 000000000..49d952307 --- /dev/null +++ b/src/app/core/models/github-comment.model.ts @@ -0,0 +1,16 @@ +export interface GithubComment { + author_association: string; + body: string; + created_at: string; + html_url: string; + id: number; + issue_url: string; + updated_at: string; + url: string; // api url + user: { + login: string, + id: number, + avatar_url: string, + url: string, + }; +} diff --git a/src/app/core/models/github-issue.model.ts b/src/app/core/models/github-issue.model.ts new file mode 100644 index 000000000..c937ccf3c --- /dev/null +++ b/src/app/core/models/github-issue.model.ts @@ -0,0 +1,113 @@ +export class GithubLabel { + static readonly LABEL_ORDER = { + severity: { Low: 0, Medium: 1, High: 2 }, + type: { DocumentationBug: 0, FunctionalityBug: 1 }, + }; + static readonly LABELS = { + severity: 'severity', + type: 'type', + response: 'response', + duplicated: 'duplicated', + status: 'status', + unsure: 'unsure', + pending: 'pending', + team: 'team', + tutorial: 'tutorial' + }; + + color: string; + id: number; + name: string; + url: string; + + constructor(githubLabels: {}) { + Object.assign(this, githubLabels); + } + + getCategory(): string { + if (this.isCategorical()) { + return this.name.split('.')[0]; + } else { + return this.name; + } + } + + getValue(): string { + if (this.isCategorical()) { + return this.name.split('.')[1]; + } else { + return this.name; + } + } + + isCategorical(): boolean { + const regex = /^\S+.\S+$/; + return regex.test(this.name); + } +} + +export class GithubIssue { + id: number; // Github's backend's id + number: number; // Issue's display id + assignees: Array<{ + id: number, + login: string, + url: string, + }>; + body: string; + created_at: string; + labels: Array; + state: string; + title: string; + updated_at: string; + url: string; + user: { // Author of the issue + login: string, + id: number, + avatar_url: string, + url: string, + }; + + constructor(githubIssue: {}) { + Object.assign(this, githubIssue); + this.labels = []; + for (const label of githubIssue['labels']) { + this.labels.push(new GithubLabel(label)); + } + } + + /** + * + * @param name Depending on the isCategorical flag, this name either refers to the category name of label or the exact name of label. + * @param isCategorical Whether the label is categorical. + */ + findLabel(name: string, isCategorical: boolean = true): string { + if (!isCategorical) { + const label = this.labels.find(l => (!l.isCategorical() && l.name === name)); + return label ? label.getValue() : undefined; + } + + // Find labels with the same category name as what is specified in the parameter. + const labels = this.labels.filter(l => (l.isCategorical() && l.getCategory() === name)); + if (labels.length === 0) { + return undefined; + } else if (labels.length === 1) { + return labels[0].getValue(); + } else { + // If Label order is not specified, return the first label value else + // If Label order is specified, return the highest ranking label value + if (!GithubLabel.LABEL_ORDER[name]) { + return labels[0].getValue(); + } else { + const order = GithubLabel.LABEL_ORDER[name]; + return labels.reduce((result, currLabel) => { + return order[currLabel.getValue()] > order[result.getValue()] ? currLabel : result; + }).getValue(); + } + } + } + + findTeamId(): string { + return `${this.findLabel('team')}.${this.findLabel('tutorial')}`; + } +} diff --git a/src/app/core/models/issue.model.ts b/src/app/core/models/issue.model.ts index d9d18a952..83c718a1e 100644 --- a/src/app/core/models/issue.model.ts +++ b/src/app/core/models/issue.model.ts @@ -1,6 +1,6 @@ -import {Team} from './team.model'; +import { Team } from './team.model'; import { TesterResponse } from './tester-response.model'; -import { IssueComment, IssueComments } from './comment.model'; +import { IssueComment } from './comment.model'; import { IssueDispute } from './issue-dispute.model'; export interface Issue { diff --git a/src/app/core/models/team.model.ts b/src/app/core/models/team.model.ts index 88cb5a807..c7fd1280e 100644 --- a/src/app/core/models/team.model.ts +++ b/src/app/core/models/team.model.ts @@ -1,4 +1,4 @@ -import {User} from './user.model'; +import { User } from './user.model'; export interface Team { id: string; diff --git a/src/app/core/models/templates/sections/duplicate-of-section.model.ts b/src/app/core/models/templates/sections/duplicate-of-section.model.ts new file mode 100644 index 000000000..86f7f9fa0 --- /dev/null +++ b/src/app/core/models/templates/sections/duplicate-of-section.model.ts @@ -0,0 +1,25 @@ +import { Section, SectionalDependency } from './section.model'; + +export class DuplicateOfSection extends Section { + private readonly duplicateOfRegex = /Duplicate of\s*#(\d+)/i; + issueNumber: number; + + constructor(sectionalDependency: SectionalDependency, unprocessedContent: string) { + super(sectionalDependency, unprocessedContent); + if (!this.parseError) { + this.issueNumber = this.parseDuplicateOfValue(this.content); + } + } + + private parseDuplicateOfValue(toParse): number { + const result = this.duplicateOfRegex.exec(toParse); + return result ? +result[1] : null; + } + + toString(): string { + let toString = ''; + toString += `${this.header}\n`; + toString += this.parseError ? '--' : `Duplicate of ${this.issueNumber}\n`; + return toString; + } +} diff --git a/src/app/core/models/templates/sections/issue-dispute-section.model.ts b/src/app/core/models/templates/sections/issue-dispute-section.model.ts new file mode 100644 index 000000000..f00f47d05 --- /dev/null +++ b/src/app/core/models/templates/sections/issue-dispute-section.model.ts @@ -0,0 +1,29 @@ +import { IssueDispute } from '../../issue-dispute.model'; +import { Section, SectionalDependency } from './section.model'; + +export class IssueDisputeSection extends Section { + disputes: IssueDispute[]; + + constructor(sectionalDependency: SectionalDependency, unprocessedContent: string) { + super(sectionalDependency, unprocessedContent); + if (!this.parseError) { + let matches; + const regex = /#{2} *:question: *(.*)[\r\n]*([\s\S]*?(?=-{19}))/gi; + while (matches = regex.exec(this.content)) { + if (matches) { + const [regexString, title, description] = matches; + this.disputes.push(new IssueDispute(title, description.trim())); + } + } + } + } + + toString(): string { + let toString = ''; + toString += `${this.header.toString()}\n`; + for (const dispute of this.disputes) { + toString += `${dispute.toString()}\n`; + } + return toString; + } +} diff --git a/src/app/core/models/templates/sections/moderation-section.model.ts b/src/app/core/models/templates/sections/moderation-section.model.ts new file mode 100644 index 000000000..0dbeb4019 --- /dev/null +++ b/src/app/core/models/templates/sections/moderation-section.model.ts @@ -0,0 +1,38 @@ +import { IssueDispute } from '../../issue-dispute.model'; +import { Section, SectionalDependency } from './section.model'; + +export class ModerationSection extends Section { + disputesToResolve: IssueDispute[]; + + constructor(sectionalDependency: SectionalDependency, unprocessedContent: string) { + super(sectionalDependency, unprocessedContent); + if (!this.parseError) { + let matches; + const regex = /#{2} *:question: *(.*)[\n\r]*(.*)[\n\r]*([\s\S]*?(?=-{19}))/gi; + while (matches = regex.exec(this.content)) { + if (matches) { + const [regexString, title, todo, tutorResponse] = matches; + const description = `${todo}\n${tutorResponse}`; + + const newDispute = new IssueDispute(title, description); + newDispute.todo = todo; + newDispute.tutorResponse = tutorResponse.trim(); + this.disputesToResolve.push(newDispute); + } + } + } + } + + get todoList(): string[] { + return this.disputesToResolve.map(e => e.todo); + } + + toString(): string { + let toString = ''; + toString += `${this.header.toString()}\n`; + for (const dispute of this.disputesToResolve) { + toString += `${dispute.toTutorResponseString()}\n`; + } + return toString; + } +} diff --git a/src/app/core/models/templates/sections/section.model.ts b/src/app/core/models/templates/sections/section.model.ts new file mode 100644 index 000000000..1b4d3dbe4 --- /dev/null +++ b/src/app/core/models/templates/sections/section.model.ts @@ -0,0 +1,39 @@ +/** + * A SectionalDependency defines a format that is needed to create a successful Section in a template. + * It will require the Section's header to be defined and the other headers that are present in the template. + * + * Reason for the dependencies on other headers: We need them to create a regex expression that is capable of parsing the current + * section out of the string. + */ +import { Header } from '../template.model'; + +export interface SectionalDependency { + sectionHeader: Header; + remainingTemplateHeaders: Header[]; +} + +export class Section { + header: Header; + sectionRegex: RegExp; + content: string; + parseError: string; + + /** + * + * @param sectionalDependency The dependency that is need to create a section's regex + * @param unprocessedContent The string that stores the section's amongst other things + */ + constructor(sectionalDependency: SectionalDependency, unprocessedContent: string) { + this.header = sectionalDependency.sectionHeader; + this.sectionRegex = new RegExp(`(${this.header})\\s+([\\s\\S]*?)(?=${sectionalDependency.remainingTemplateHeaders.join('|')}|$)`, 'i'); + const matches = this.sectionRegex.exec(unprocessedContent); + if (matches) { + const [originalString, header, description] = matches; + this.content = description.trim(); + this.parseError = null; + } else { + this.content = null; + this.parseError = `Unable to extract ${this.header.name} Section`; + } + } +} diff --git a/src/app/core/models/templates/sections/test-response-section.model.ts b/src/app/core/models/templates/sections/test-response-section.model.ts new file mode 100644 index 000000000..f59eae8e0 --- /dev/null +++ b/src/app/core/models/templates/sections/test-response-section.model.ts @@ -0,0 +1,31 @@ +import { TesterResponse } from '../../tester-response.model'; +import { Section, SectionalDependency } from './section.model'; + +export class TesterResponseSection extends Section { + testerResponses: TesterResponse[] = []; + + constructor(sectionalDependency: SectionalDependency, unprocessedContent: string) { + super(sectionalDependency, unprocessedContent); + if (!this.parseError) { + let matches; + const regex: RegExp = new RegExp('#{2} *:question: *([\\w ]+)[\\r\\n]*(Team Chose.*[\\r\\n]* *Originally.*' + + '|Team Chose.*[\\r\\n]*)[\\r\\n]*(- \\[x? ?\\] I disagree)[\\r\\n]*\\*\\*Reason *for *disagreement:\\*\\* *([\\s\\S]*?)-{19}', + 'gi'); + while (matches = regex.exec(this.content)) { + if (matches) { + const [regexString, title, description, disagreeCheckbox, reasonForDisagreement] = matches; + this.testerResponses.push(new TesterResponse(title, description, disagreeCheckbox, reasonForDisagreement.trim())); + } + } + } + } + + toString(): string { + let toString = ''; + toString += `${this.header.toString()}\n`; + for (const response of this.testerResponses) { + toString += `${response.toString()}\n`; + } + return toString; + } +} diff --git a/src/app/core/models/templates/team-response-template.model.ts b/src/app/core/models/templates/team-response-template.model.ts new file mode 100644 index 000000000..b4b7f53dc --- /dev/null +++ b/src/app/core/models/templates/team-response-template.model.ts @@ -0,0 +1,44 @@ +import { DuplicateOfSection } from './sections/duplicate-of-section.model'; +import { Header, Template } from './template.model'; +import { Section } from './sections/section.model'; +import { GithubIssue } from '../github-issue.model'; +import { GithubComment } from '../github-comment.model'; +import { IssueComment } from '../comment.model'; + +const teamResponseHeaders = { + teamResponse: new Header('Team\'s Response', 1), + duplicateOf: new Header('Duplicate status \\(if any\\):', 2), +}; + +export class TeamResponseTemplate extends Template { + teamResponse: Section; + duplicateOf: DuplicateOfSection; + comment: IssueComment; + + constructor(githubComments: GithubComment[]) { + super(Object.values(teamResponseHeaders)); + + const comment = githubComments.find( + (githubComment: GithubComment) => this.test(githubComment.body)); + if (comment === undefined) { + return; + } + this.comment = { + ...comment, + description: comment.body, + createdAt: comment.created_at, + updatedAt: comment.updated_at + }; + const commentsContent: string = comment.body; + this.teamResponse = this.parseTeamResponse(commentsContent); + this.duplicateOf = this.parseDuplicateOf(commentsContent); + } + + parseTeamResponse(toParse: string): Section { + return new Section(this.getSectionalDependency(teamResponseHeaders.teamResponse), toParse); + } + + parseDuplicateOf(toParse: string): DuplicateOfSection { + return new DuplicateOfSection(this.getSectionalDependency(teamResponseHeaders.duplicateOf), toParse); + } +} diff --git a/src/app/core/models/templates/template.model.ts b/src/app/core/models/templates/template.model.ts new file mode 100644 index 000000000..83a76078d --- /dev/null +++ b/src/app/core/models/templates/template.model.ts @@ -0,0 +1,48 @@ +import { SectionalDependency } from './sections/section.model'; + +export abstract class Template { + headers: Header[]; + regex: RegExp; + + protected constructor(headers: Header[]) { + this.headers = headers; + + const headerString = headers.join('|'); + this.regex = new RegExp(`(${headerString})\\s+([\\s\\S]*?)(?=${headerString}|$)`, 'gi'); + } + + getSectionalDependency(header: Header): SectionalDependency { + const otherHeaders = this.headers.filter(e => !e.equals(header)); + return { + sectionHeader: header, + remainingTemplateHeaders: otherHeaders + }; + } + + /** + * Check whether the given string conforms to the template. + */ + test(toTest: string): boolean { + return this.regex.test(toTest); + } +} + +export class Header { + name: string; + headerHash: string; + prefix?: string; + + constructor(name, headerSize, prefix: string = '') { + this.name = name; + this.headerHash = '\#'.repeat(headerSize); + this.prefix = prefix; + } + + toString(): string { + return this.headerHash.concat(this.prefix === '' ? ' ' : (' ' + this.prefix + ' ')).concat(this.name); + } + + equals(section: Header): boolean { + return this.name === section.name; + } +} diff --git a/src/app/core/models/templates/tester-response-template.model.ts b/src/app/core/models/templates/tester-response-template.model.ts new file mode 100644 index 000000000..5d2265794 --- /dev/null +++ b/src/app/core/models/templates/tester-response-template.model.ts @@ -0,0 +1,31 @@ +import { Header, Template } from './template.model'; +import { TesterResponseSection } from './sections/test-response-section.model'; +import { Section } from './sections/section.model'; +import { GithubComment } from '../github-comment.model'; + + +const testerResponseHeaders = { + teamResponse: new Header('Team\'s Response', 1), + testerResponses: new Header('Items for the Tester to Verify', 1), +}; + +export class TesterResponseTemplate extends Template { + teamResponse: Section; + testerResponse: TesterResponseSection; + + constructor(githubIssueComments: GithubComment[]) { + super(Object.values(testerResponseHeaders)); + + const templateConformingComment = githubIssueComments.find(comment => this.test(comment.body)).body; + this.teamResponse = this.parseTeamResponse(templateConformingComment); + this.testerResponse = this.parseTesterResponse(templateConformingComment); + } + + parseTeamResponse(toParse: string): Section { + return new Section(this.getSectionalDependency(testerResponseHeaders.teamResponse), toParse); + } + + parseTesterResponse(toParse: string): TesterResponseSection { + return new TesterResponseSection(this.getSectionalDependency(testerResponseHeaders.testerResponses), toParse); + } +} diff --git a/src/app/core/models/templates/tutor-moderation-issue-template.model.ts b/src/app/core/models/templates/tutor-moderation-issue-template.model.ts new file mode 100644 index 000000000..e7e09f9c2 --- /dev/null +++ b/src/app/core/models/templates/tutor-moderation-issue-template.model.ts @@ -0,0 +1,38 @@ +import { Header, Template } from './template.model'; +import { IssueDisputeSection } from './sections/issue-dispute-section.model'; +import { Section } from './sections/section.model'; +import { GithubIssue } from '../github-issue.model'; + + +const tutorModerationIssueDescriptionHeaders = { + description: new Header('Description', 1), + teamResponse: new Header('Team\'s Response', 1), + disputes: new Header('Disputes', 1) +}; + +export class TutorModerationIssueTemplate extends Template { + description: Section; + teamResponse: Section; + dispute: IssueDisputeSection; + + constructor(githubIssue: GithubIssue) { + super(Object.values(tutorModerationIssueDescriptionHeaders)); + + const issueContent = githubIssue.body; + this.description = this.parseDescription(issueContent); + this.teamResponse = this.parseTeamResponse(issueContent); + this.dispute = this.parseDisputes(issueContent); + } + + parseDescription(toParse: string): Section { + return new Section(this.getSectionalDependency(tutorModerationIssueDescriptionHeaders.description), toParse); + } + + parseTeamResponse(toParse: string): Section { + return new Section(this.getSectionalDependency(tutorModerationIssueDescriptionHeaders.teamResponse), toParse); + } + + parseDisputes(toParse: string): IssueDisputeSection { + return new IssueDisputeSection(this.getSectionalDependency(tutorModerationIssueDescriptionHeaders.disputes), toParse); + } +} diff --git a/src/app/core/models/templates/tutor-moderation-todo-template.model.ts b/src/app/core/models/templates/tutor-moderation-todo-template.model.ts new file mode 100644 index 000000000..b3ac25107 --- /dev/null +++ b/src/app/core/models/templates/tutor-moderation-todo-template.model.ts @@ -0,0 +1,23 @@ +import { Header, Template } from './template.model'; +import { ModerationSection } from './sections/moderation-section.model'; +import { GithubComment } from '../github-comment.model'; + + +const tutorModerationTodoHeaders = { + todo: new Header('Tutor Moderation', 1), +}; + +export class TutorModerationTodoTemplate extends Template { + moderation: ModerationSection; + + constructor(githubComments: GithubComment[]) { + super(Object.values(tutorModerationTodoHeaders)); + + const templateConformingComment = githubComments.find(comment => this.test(comment.body)).body; + this.moderation = this.parseModeration(templateConformingComment); + } + + parseModeration(toParse: string): ModerationSection { + return new ModerationSection(this.getSectionalDependency(tutorModerationTodoHeaders.todo), toParse); + } +} diff --git a/src/app/core/models/user.model.ts b/src/app/core/models/user.model.ts index 185efc526..06787b447 100644 --- a/src/app/core/models/user.model.ts +++ b/src/app/core/models/user.model.ts @@ -1,4 +1,4 @@ -import {Team} from './team.model'; +import { Team } from './team.model'; export interface User { loginId: string; diff --git a/src/app/core/services/data.service.ts b/src/app/core/services/data.service.ts index 47c3c2755..a29df71e4 100644 --- a/src/app/core/services/data.service.ts +++ b/src/app/core/services/data.service.ts @@ -22,7 +22,8 @@ export class DataService { }), map((jsonData: {}) => { this.dataFile = { - teamStructure: this.extractTeamStructure(jsonData)}; + teamStructure: this.extractTeamStructure(jsonData) + }; return jsonData; }) ); diff --git a/src/app/core/services/github.service.ts b/src/app/core/services/github.service.ts index 52bbfbf02..30012f705 100644 --- a/src/app/core/services/github.service.ts +++ b/src/app/core/services/github.service.ts @@ -5,6 +5,8 @@ import { githubPaginatorParser } from '../../shared/lib/github-paginator-parser' import { IssueComment } from '../models/comment.model'; import { shell } from 'electron'; import { ERRORCODE_NOT_FOUND, ErrorHandlingService } from './error-handling.service'; +import { GithubIssue } from '../models/github-issue.model'; + const Octokit = require('@octokit/rest'); @@ -44,7 +46,7 @@ export class GithubService { /** * Will return an Observable with array of issues in JSON format. */ - fetchIssues(filter?: {}): Observable> { + fetchIssues(filter?: {}): Observable> { return this.getNumberOfIssuePages(filter).pipe( mergeMap((numOfPages) => { const apiCalls = []; @@ -55,12 +57,11 @@ export class GithubService { return forkJoin(apiCalls); }), map((resultArray) => { - let collatedData = []; + const collatedData = []; for (const response of resultArray) { - collatedData = [ - ...collatedData, - ...response['data'], - ]; + for (const issue of response['data']) { + collatedData.push(new GithubIssue(issue)); + } } return collatedData; }) @@ -93,10 +94,10 @@ export class GithubService { octokit.repos.createForAuthenticatedUser({name: name}); } - fetchIssue(id: number): Observable<{}> { + fetchIssue(id: number): Observable { return from(octokit.issues.get({owner: ORG_NAME, repo: REPO, number: id})).pipe( map((response) => { - return response['data']; + return new GithubIssue(response['data']); }) ); } @@ -149,18 +150,18 @@ export class GithubService { octokit.issues.updateLabel({owner: ORG_NAME, repo: REPO, name: labelName, current_name: labelName, color: labelColor}); } - closeIssue(id: number): Observable<{}> { + closeIssue(id: number): Observable { return from(octokit.issues.update({owner: ORG_NAME, repo: REPO, issue_number: id, state: 'closed'})).pipe( map(response => { - return response['data']; + return new GithubIssue(response['data']); }) ); } - createIssue(title: string, description: string, labels: string[]): Observable<{}> { + createIssue(title: string, description: string, labels: string[]): Observable { return from(octokit.issues.create({owner: ORG_NAME, repo: REPO, title: title, body: description, labels: labels})).pipe( map(response => { - return response['data']; + return new GithubIssue(response['data']); }) ); } @@ -174,11 +175,11 @@ export class GithubService { ); } - updateIssue(id: number, title: string, description: string, labels: string[], assignees?: string[]): Observable<{}> { + updateIssue(id: number, title: string, description: string, labels: string[], assignees?: string[]): Observable { return from(octokit.issues.update({owner: ORG_NAME, repo: REPO, issue_number: id, title: title, body: description, labels: labels, assignees: assignees})).pipe( map(response => { - return response['data']; + return new GithubIssue(response['data']); }) ); } diff --git a/src/app/core/services/issue-comment.service.ts b/src/app/core/services/issue-comment.service.ts index fdb65cc4e..8ac65e6db 100644 --- a/src/app/core/services/issue-comment.service.ts +++ b/src/app/core/services/issue-comment.service.ts @@ -1,11 +1,12 @@ import {Injectable} from '@angular/core'; -import {Observable, of, BehaviorSubject} from 'rxjs'; +import {Observable, of} from 'rxjs'; import {GithubService} from './github.service'; import {IssueComment, IssueComments} from '../models/comment.model'; import {map} from 'rxjs/operators'; import * as moment from 'moment'; import { TesterResponse } from '../models/tester-response.model'; import { IssueDispute } from '../models/issue-dispute.model'; +import { GithubComment } from '../models/github-comment.model'; @Injectable({ providedIn: 'root', @@ -24,6 +25,15 @@ export class IssueCommentService { return of(this.comments.get(issueId)); } + getGithubComments(issueId: number, isIssueReloaded: boolean): Observable { + this.initializeIssueComments(issueId).subscribe(); + return this.githubService.fetchIssueComments(issueId).pipe( + map(rawJsonDataArray => rawJsonDataArray.map(rawJsonData => { + ...rawJsonData + })) + ); + } + createIssueComment(issueId: number, description: string): Observable { return this.githubService.createIssueComment({ id: issueId, diff --git a/src/app/core/services/issue.service.ts b/src/app/core/services/issue.service.ts index 449ce42d6..368b64d06 100644 --- a/src/app/core/services/issue.service.ts +++ b/src/app/core/services/issue.service.ts @@ -25,6 +25,9 @@ import { TesterResponse } from '../models/tester-response.model'; import { IssueComments, IssueComment } from '../models/comment.model'; import { IssueDispute } from '../models/issue-dispute.model'; import { UserRole } from '../../core/models/user.model'; +import { BaseIssue } from '../models/base-issue.model'; +import { GithubIssue, GithubLabel } from '../models/github-issue.model'; +import { GithubComment } from '../models/github-comment.model'; @Injectable({ providedIn: 'root', @@ -82,7 +85,7 @@ export class IssueService { createIssue(title: string, description: string, severity: string, type: string): Observable { const labelsArray = [this.createLabel('severity', severity), this.createLabel('type', type)]; return this.githubService.createIssue(title, description, labelsArray).pipe( - flatMap((response) => { + flatMap((response: GithubIssue) => { return this.createIssueModel(response); }) ); @@ -92,7 +95,7 @@ export class IssueService { const assignees = this.phaseService.currentPhase === Phase.phaseModeration ? [] : issue.assignees; return this.githubService.updateIssue(issue.id, issue.title, this.createGithubIssueDescription(issue), this.createLabelsForIssue(issue), assignees).pipe( - flatMap((response) => { + flatMap((response: GithubIssue) => { return this.createIssueModel(response); }) ); @@ -105,8 +108,6 @@ export class IssueService { */ private createGithubIssueDescription(issue: Issue): string { switch (this.phaseService.currentPhase) { - case Phase.phaseTeamResponse: - return `# Description\n${issue.description}\n`; case Phase.phaseModeration: return `# Description\n${issue.description}\n# Team\'s Response\n${issue.teamResponse}\n ` + // `## State the duplicated issue here, if any\n${issue.duplicateOf ? `Duplicate of #${issue.duplicateOf}` : `--`}\n` + @@ -134,7 +135,7 @@ export class IssueService { deleteIssue(id: number): Observable { return this.githubService.closeIssue(id).pipe( - flatMap((response) => { + flatMap((response: GithubIssue) => { return this.createIssueModel(response).pipe( map(deletedIssue => { this.deleteFromLocalStore(deletedIssue); @@ -423,7 +424,24 @@ export class IssueService { return `${prepend}.${value}`; } - private createIssueModel(issueInJson: {}): Observable { + private extractTeamIdFromGithubIssue(githubIssue: GithubIssue): string { + return githubIssue.findLabel(GithubLabel.LABELS.tutorial).concat('-').concat(githubIssue.findLabel(GithubLabel.LABELS.team)); + } + + private createIssueModel(githubIssue: GithubIssue): Observable { + if (this.phaseService.currentPhase === Phase.phaseBugReporting) { + return of(BaseIssue.createPhaseBugReportingIssue(githubIssue)); + } if (this.phaseService.currentPhase === Phase.phaseTeamResponse) { + return this.issueCommentService.getGithubComments(githubIssue.number, this.isIssueReloaded).pipe( + flatMap((githubComments: GithubComment[]) => { + return of(BaseIssue.createPhaseTeamResponseIssue(githubIssue, githubComments, + this.dataService.getTeam(this.extractTeamIdFromGithubIssue(githubIssue)))); + }) + ); + } + + // A temp fix due to the refactoring process. After refactoring is complete, we can remove this whole chunk of code below. + const issueInJson = { ...githubIssue }; this.getParsedBody(issueInJson); const issueId = +issueInJson['number']; return this.issueCommentService.getIssueComments(issueId, this.isIssueReloaded).pipe( diff --git a/src/app/shared/issue/assignee/assignee.component.ts b/src/app/shared/issue/assignee/assignee.component.ts index 676d510fc..2ca2b9f8c 100644 --- a/src/app/shared/issue/assignee/assignee.component.ts +++ b/src/app/shared/issue/assignee/assignee.component.ts @@ -5,6 +5,7 @@ import {Team} from '../../../core/models/team.model'; import {IssueService} from '../../../core/services/issue.service'; import {ErrorHandlingService} from '../../../core/services/error-handling.service'; import {PermissionService} from '../../../core/services/permission.service'; +import { BaseIssue } from '../../../core/models/base-issue.model'; @Component({ selector: 'app-assignee-component', @@ -43,11 +44,12 @@ export class AssigneeComponent implements OnInit { } updateAssignee(event): void { - this.issueService.updateIssue({ + const latestIssue = { ...this.issue, - assignees: event.value, - }).subscribe((updatedIssue: Issue) => { - this.issueUpdated.emit(updatedIssue); + assignees: event.value + }; + this.issueService.updateIssue(latestIssue).subscribe((updatedIssue: Issue) => { + this.issueUpdated.emit(latestIssue); }, (error) => { this.errorHandlingService.handleHttpError(error); }); diff --git a/src/app/shared/issue/duplicateOf/duplicate-of.component.ts b/src/app/shared/issue/duplicateOf/duplicate-of.component.ts index a2e25d5c5..7fece5f1b 100644 --- a/src/app/shared/issue/duplicateOf/duplicate-of.component.ts +++ b/src/app/shared/issue/duplicateOf/duplicate-of.component.ts @@ -81,10 +81,9 @@ export class DuplicateOfComponent implements OnInit { this.issueService.updateIssue(latestIssue).subscribe((updatedIssue) => { this.issueCommentService.updateIssueComment(latestIssue.issueComment).subscribe((updatedComment) => { - updatedIssue.duplicateOf = latestIssue.duplicateOf; - updatedIssue.issueComment = updatedComment; + latestIssue.issueComment = updatedComment; this.commentUpdated.emit(updatedComment); - this.issueUpdated.emit(updatedIssue); + this.issueUpdated.emit(latestIssue); }); }, (error) => { this.errorHandlingService.handleHttpError(error); diff --git a/src/app/shared/issue/label/label.component.ts b/src/app/shared/issue/label/label.component.ts index d0a4631e0..22e38ea8b 100644 --- a/src/app/shared/issue/label/label.component.ts +++ b/src/app/shared/issue/label/label.component.ts @@ -6,6 +6,7 @@ import { ErrorHandlingService } from '../../../core/services/error-handling.serv import { PermissionService } from '../../../core/services/permission.service'; import { Label } from '../../../core/models/label.model'; import { LabelService } from '../../../core/services/label.service'; +import { BaseIssue } from '../../../core/models/base-issue.model'; @Component({ selector: 'app-issue-label', @@ -39,11 +40,12 @@ export class LabelComponent implements OnInit, OnChanges { } updateLabel(value: string) { - this.issueService.updateIssue({ - ...this.issue, - [this.attributeName]: value, - }).subscribe((editedIssue: Issue) => { - this.issueUpdated.emit(editedIssue); + const latestIssue = { + ...this.issue, + [this.attributeName]: value + }; + this.issueService.updateIssue(latestIssue).subscribe((editedIssue: Issue) => { + this.issueUpdated.emit(latestIssue); this.labelColor = this.labelService.getColorOfLabel(editedIssue[this.attributeName]); }, (error) => { this.errorHandlingService.handleHttpError(error); diff --git a/src/app/shared/new-team-respond/new-team-response.component.ts b/src/app/shared/new-team-respond/new-team-response.component.ts index 40ef9eb86..08afc81cf 100644 --- a/src/app/shared/new-team-respond/new-team-response.component.ts +++ b/src/app/shared/new-team-respond/new-team-response.component.ts @@ -71,9 +71,11 @@ export class NewTeamResponseComponent implements OnInit { const newCommentDescription = this.issueCommentService.createGithubTeamResponse(this.description.value, this.duplicateOf.value); this.issueCommentService.createIssueComment(this.issue.id, newCommentDescription) .subscribe((newComment: IssueComment) => { - this.updatedCommentEmitter.emit(newComment); + latestIssue.teamResponse = this.description.value; + latestIssue.duplicateOf = this.duplicateOf.value === '' ? undefined : this.duplicateOf.value; latestIssue.issueComment = newComment; this.issueUpdated.emit(latestIssue); + this.updatedCommentEmitter.emit(newComment); form.resetForm(); }); }, (error) => {