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) => {