| const Issue = require('./src/issue'); |
| const text = require('./src/text'); |
| const labelText = require('./src/label'); |
| const logger = require('./src/logger'); |
| const { isCommitter } = require('./src/coreCommitters'); |
| const { |
| replaceAll, |
| removeHTMLComment, |
| isMissingDocInfo, |
| isOptionChecked |
| } = require('./src/util'); |
| const { GraphqlResponseError } = require('@octokit/graphql'); |
| |
| /** |
| * @typedef {import('probot').Probot} Probot |
| * @typedef {import('probot').Context} Context |
| * @typedef {import('@octokit/graphql-schema').ReportedContentClassifiers} ReportedContentClassifiers |
| * @typedef {import('@octokit/graphql-schema').IssueClosedStateReason} IssueClosedStateReason |
| */ |
| |
| module.exports = (/** @type {Probot} */ app) => { |
| app.on(['issues.opened'], async context => { |
| const issue = new Issue(context); |
| |
| await issue.init(); |
| |
| // issue.response && await commentIssue(context, issue.response); |
| |
| // add and remove label |
| await Promise.all([ |
| addLabels(context, issue.addLabels), |
| removeLabels(context, issue.removeLabels) |
| ]); |
| |
| const invalid = issue.addLabels.includes(labelText.INVALID) |
| || issue.addLabels.includes(labelText.MISSING_TITLE); |
| |
| if (!invalid) { |
| // update title |
| issue.isTitleChanged && await updateIssueTitle(context, issue.title); |
| // translate finally if valid |
| await translateIssue(context, issue); |
| } |
| }); |
| |
| app.on(['issues.edited'], async context => { |
| if (context.payload.sender.type === 'Bot') { |
| logger.info('skip to handle current `issues.edited` event as it is from bot'); |
| return; |
| } |
| const ctxIssue = context.payload.issue; |
| const labels = ctxIssue.labels; |
| if (labels && labels.findIndex(label => label.name === labelText.MISSING_TITLE) > -1) { |
| // issue was closed for missing-title |
| if (ctxIssue.state === 'closed') { |
| const issue = new Issue(context); |
| await issue.init(); |
| const invalid = issue.addLabels.includes(labelText.INVALID) |
| || issue.addLabels.includes(labelText.MISSING_TITLE); |
| // issue title has been provided and uses the template, reopen it |
| if (!invalid) { |
| // add labels |
| await addLabels(context, issue.addLabels); |
| // reopen issue |
| await openIssue(context); |
| // update title |
| issue.isTitleChanged && await updateIssueTitle(context, issue.title); |
| // translate |
| await translateIssue(context, issue); |
| } |
| } |
| } |
| }); |
| |
| app.on(['issues.closed'], context => { |
| // unlabel waiting-for: community if issue was closed by the author self |
| if (context.payload.issue.user.login === context.payload.sender.login) { |
| return removeLabels(context, [labelText.WAITING_FOR_COMMUNITY]); |
| } |
| }); |
| |
| app.on(['issues.reopened'], async context => { |
| // unlabel invalid & missing-title when reopened by bot or committers |
| if (context.payload.issue.user.login !== context.payload.sender.login) { |
| await removeLabels(context, [ |
| labelText.INVALID, |
| labelText.MISSING_TITLE |
| ]); |
| minimizeComment(context, text.MISSING_TITLE); |
| minimizeComment(context, text.NOT_USING_TEMPLATE); |
| } |
| }); |
| |
| app.on('issues.labeled', async context => { |
| const labelName = context.payload.label.name; |
| const issue = context.payload.issue; |
| const issueAuthor = issue.user.login; |
| if (labelName !== labelText.RESOLVED && isCommitter(issue.author_association, issueAuthor)) { |
| // do nothing if issue author is committer |
| return; |
| } |
| |
| const replaceAt = function (comment) { |
| return replaceAll( |
| comment, |
| 'AT_ISSUE_AUTHOR', |
| '@' + issueAuthor |
| ); |
| }; |
| |
| switch (labelName) { |
| case labelText.INVALID: |
| return Promise.all([ |
| commentIssue(context, text.NOT_USING_TEMPLATE), |
| closeIssue(context) |
| ]); |
| |
| case labelText.HOWTO: |
| return Promise.all([ |
| commentIssue(context, text.LABEL_HOWTO), |
| closeIssue(context) |
| ]); |
| |
| case labelText.INACTIVE: |
| return Promise.all([ |
| commentIssue(context, text.INACTIVE_ISSUE), |
| closeIssue(context) |
| ]); |
| |
| case labelText.MISSING_DEMO: |
| return Promise.all([ |
| commentIssue(context, replaceAt(text.MISSING_DEMO)), |
| removeLabels(context, [labelText.WAITING_FOR_COMMUNITY]), |
| addLabels(context, [labelText.WAITING_FOR_AUTHOR]) |
| ]); |
| |
| // case labelText.WAITING_FOR_AUTHOR: |
| // return commentIssue(context, replaceAt(text.ISSUE_TAGGED_WAITING_AUTHOR)); |
| |
| case labelText.DIFFICULTY_EASY: |
| return commentIssue(context, replaceAt(text.ISSUE_TAGGED_EASY)); |
| |
| case labelText.PRIORITY_HIGH: |
| return commentIssue(context, replaceAt(text.ISSUE_TAGGED_PRIORITY_HIGH)); |
| |
| case labelText.RESOLVED: |
| case labelText.DUPLICATE: |
| return Promise.all([ |
| closeIssue(context, labelName === labelText.RESOLVED), |
| removeLabels(context, [labelText.WAITING_FOR_COMMUNITY]) |
| ]); |
| |
| case labelText.MISSING_TITLE: |
| return Promise.all([ |
| commentIssue(context, text.MISSING_TITLE), |
| closeIssue(context) |
| ]); |
| } |
| }); |
| |
| app.on('issue_comment.created', async context => { |
| const isPr = context.payload.issue.html_url.indexOf('/pull/') > -1; |
| if (isPr) { |
| // Do nothing when pr is commented |
| return; |
| } |
| |
| const comment = context.payload.comment; |
| const commenter = comment.user.login; |
| const isCommenterAuthor = commenter === context.payload.issue.user.login; |
| const isCore = isCommitter(comment.author_association, commenter); |
| let removeLabel; |
| let addLabel; |
| if (isCore && !isCommenterAuthor) { |
| // add `duplicate` label when a committer comments with the `Duplicate of/with` keyword on the issue |
| if (/Duplicated? (of|with) #/i.test(comment.body)) { |
| addLabel = labelText.DUPLICATE; |
| } |
| else { |
| // New comment from core committers |
| removeLabel = labelText.WAITING_FOR_COMMUNITY; |
| } |
| } |
| else if (isCommenterAuthor) { |
| // New comment from issue author |
| removeLabel = labelText.WAITING_FOR_AUTHOR; |
| // addLabel = labelText.WAITING_FOR_COMMUNITY; |
| } |
| return Promise.all([ |
| removeLabels(context, [removeLabel]), |
| addLabel && addLabels(context, [addLabel]) |
| ]); |
| }); |
| |
| app.on(['pull_request.opened'], async context => { |
| const pr = context.payload.pull_request; |
| const isCore = isCommitter(pr.author_association, pr.user.login); |
| let commentText = isCore |
| ? text.PR_OPENED_BY_COMMITTER |
| : text.PR_OPENED; |
| |
| const labelList = []; |
| const removeLabelList = []; |
| const isDraft = pr.draft; |
| if (!isDraft) { |
| labelList.push(labelText.PR_AWAITING_REVIEW); |
| } |
| if (isCore) { |
| labelList.push(labelText.PR_AUTHOR_IS_COMMITTER); |
| } |
| |
| const content = pr.body || ''; |
| |
| commentText = checkDoc(content, commentText, labelList, removeLabelList); |
| |
| if (content.toLowerCase().includes('[x] This PR depends on ZRender changes'.toLowerCase())) { |
| commentText += text.PR_ZRENDER_CHANGED; |
| } |
| |
| if (await isFirstTimeContributor(context)) { |
| labelList.push(labelText.PR_FIRST_TIME_CONTRIBUTOR); |
| } |
| |
| return Promise.all([ |
| commentIssue(context, commentText), |
| addLabels(context, labelList), |
| removeLabels(context, removeLabelList) |
| ]); |
| }); |
| |
| app.on(['pull_request.ready_for_review'], async context => { |
| return addLabels(context, [labelText.PR_AWAITING_REVIEW]); |
| }); |
| |
| app.on(['pull_request.converted_to_draft'], async context => { |
| return removeLabels(context, [labelText.PR_AWAITING_REVIEW]); |
| }); |
| |
| app.on(['pull_request.edited'], async context => { |
| const pr = context.payload.pull_request; |
| const isOpen = pr.state === 'open'; |
| |
| const addLabel = []; |
| const removeLabel = []; |
| |
| if (pr.draft) { |
| removeLabel.push(labelText.PR_AWAITING_REVIEW); |
| } |
| else if (isOpen) { |
| addLabel.push(labelText.PR_AWAITING_REVIEW); |
| } |
| |
| const content = pr.body || ''; |
| |
| const commentText = checkDoc(content, '', addLabel, removeLabel); |
| |
| return Promise.all([ |
| commentIssue(context, commentText), |
| removeLabels(context, removeLabel), |
| addLabels(context, addLabel) |
| ]); |
| }); |
| |
| app.on(['pull_request.synchronize'], async context => { |
| const removeLabel = removeLabels(context, [labelText.PR_REVISION_NEEDED]); |
| const addLabel = context.payload.pull_request.draft |
| || addLabels(context, [labelText.PR_AWAITING_REVIEW]); |
| return Promise.all([removeLabel, addLabel]); |
| }); |
| |
| app.on(['pull_request.closed'], async context => { |
| const actions = [ |
| removeLabels(context, [ |
| labelText.PR_REVISION_NEEDED, |
| labelText.PR_AWAITING_REVIEW |
| ]) |
| ]; |
| const isMerged = context.payload.pull_request.merged; |
| if (isMerged) { |
| actions.push(commentIssue(context, text.PR_MERGED)); |
| } |
| return Promise.all(actions); |
| }); |
| |
| app.on(['pull_request_review.submitted'], async context => { |
| const review = context.payload.review; |
| const addLabel = []; |
| const removeLabel = []; |
| if (isCommitter(review.author_association, review.user.login)) { |
| if (review.state === 'changes_requested') { |
| return Promise.all([ |
| addLabels(context, [labelText.PR_REVISION_NEEDED]), |
| removeLabels(context, [labelText.PR_AWAITING_REVIEW]) |
| ]); |
| } |
| else if (review.state === 'approved') { |
| const pr = context.payload.pull_request; |
| const content = pr.body || ''; |
| const commentText = checkDoc(content, '', addLabel, removeLabel); |
| return Promise.all([ |
| commentIssue(context, commentText), |
| removeLabels(context, [ |
| labelText.PR_AWAITING_REVIEW, |
| labelText.PR_REVISION_NEEDED |
| ]) |
| ]); |
| } |
| } |
| }); |
| |
| app.onError(e => { |
| logger.error('bot occurred an error'); |
| logger.error(e); |
| }); |
| } |
| |
| /** |
| * @param {Context} context |
| * @param {string} labelNames label names to be removed |
| */ |
| function removeLabels(context, labelNames) { |
| return labelNames && labelNames.length && Promise.all( |
| labelNames.map( |
| label => context.octokit.issues.removeLabel( |
| context.issue({ |
| name: label |
| }) |
| ).catch(err => { |
| // Ignore error caused by not existing. |
| // if (err.message !== 'Not Found') { |
| // throw(err); |
| // } |
| }) |
| ) |
| ); |
| } |
| |
| /** |
| * @param {Context} context |
| * @param {Array<string>} labelNames label names to be added |
| */ |
| function addLabels(context, labelNames) { |
| return labelNames && labelNames.length && context.octokit.issues.addLabels( |
| context.issue({ |
| labels: labelNames |
| }) |
| ) |
| } |
| |
| /** |
| * @param {Context} context |
| * @param {boolean?} completed |
| */ |
| async function closeIssue(context, completed) { |
| // close issue |
| return await context.octokit.issues.update( |
| context.issue({ |
| state: 'closed', |
| // PENDING: not list in the documentation |
| state_reason: completed ? 'completed' : 'not_planned' |
| }) |
| ); |
| // use GraphQL to close the issue with specified reason |
| // const res = await context.octokit.graphql( |
| // ` |
| // mutation closeIssue($id: ID!, $reason: IssueClosedStateReason) { |
| // closeIssue(input: { issueId: $id, stateReason: $reason }) { |
| // clientMutationId |
| // issue { |
| // number |
| // closed |
| // state |
| // stateReason |
| // } |
| // } |
| // } |
| // `, |
| // { |
| // id: context.payload.issue.node_id, |
| // /** |
| // * @type {IssueClosedStateReason} |
| // */ |
| // reason: completed ? 'COMPLETED' : 'NOT_PLANNED' |
| // } |
| // ); |
| // logger.info('close issue result: \n' + JSON.stringify(res, null, 2)); |
| // return res; |
| } |
| |
| /** |
| * @param {Context} context |
| */ |
| function openIssue(context) { |
| // open issue |
| return context.octokit.issues.update( |
| context.issue({ |
| state: 'open' |
| }) |
| ); |
| } |
| |
| /** |
| * @param {Context} context |
| * @param {string} title |
| */ |
| function updateIssueTitle(context, title) { |
| return context.octokit.issues.update( |
| context.issue({ |
| title |
| }) |
| ); |
| } |
| |
| /** |
| * @param {Context} context |
| * @param {string} commentText |
| */ |
| async function commentIssue(context, commentText) { |
| commentText = commentText && commentText.trim(); |
| if (!commentText) { |
| return; |
| } |
| try { |
| if (await hasCommented(context, commentText)) { |
| logger.info('skip current comment as it has been submitted'); |
| return; |
| } |
| return await context.octokit.issues.createComment( |
| context.issue({ |
| body: commentText |
| }) |
| ); |
| } catch (e) { |
| logger.error('failed to comment') |
| logger.error(e); |
| } |
| } |
| |
| /** |
| * @param {Context} context |
| */ |
| async function isFirstTimeContributor(context) { |
| try { |
| const response = await context.octokit.issues.listForRepo( |
| context.repo({ |
| state: 'all', |
| creator: context.payload.pull_request.user.login |
| }) |
| ); |
| return response.data.filter(data => data.pull_request).length === 1; |
| } |
| catch (e) { |
| logger.error('failed to check first-time contributor'); |
| logger.error(e); |
| } |
| } |
| |
| /** |
| * @param {Context} context |
| * @param {Issue} createdIssue |
| */ |
| async function translateIssue(context, createdIssue) { |
| if (!createdIssue) { |
| return; |
| } |
| |
| const { |
| title, body, |
| translatedTitle, translatedBody |
| } = createdIssue; |
| |
| const titleNeedsTranslation = translatedTitle && translatedTitle[0] !== title; |
| const bodyNeedsTranslation = translatedBody && translatedBody[0] !== removeHTMLComment(body); |
| const needsTranslation = titleNeedsTranslation || bodyNeedsTranslation; |
| |
| logger.info('issue needs translation: ' + needsTranslation); |
| |
| // translate the issue if needed |
| if (needsTranslation) { |
| const translateTip = replaceAll( |
| text.ISSUE_COMMENT_TRANSLATE_TIP, |
| 'AT_ISSUE_AUTHOR', |
| '@' + createdIssue.issue.user.login |
| ); |
| const translateComment = `${translateTip}\n<details><summary><b>TRANSLATED</b></summary><br>${titleNeedsTranslation ? '\n\n**TITLE**\n\n' + translatedTitle[0] : ''}${bodyNeedsTranslation ? '\n\n**BODY**\n\n' + fixMarkdown(translatedBody[0]) : ''}\n</details>`; |
| await commentIssue(context, translateComment); |
| } |
| } |
| |
| /** |
| * @param {string} body |
| */ |
| function fixMarkdown(body) { |
| return body.replace(/\! \[/g, '![').replace(/\] \(/g, ']('); |
| } |
| |
| /** |
| * @param {string} content |
| * @param {string} commentText |
| * @param {Array.<string>} addLabelList |
| * @param {Array.<string>} removeLabelList |
| */ |
| function checkDoc(content, commentText, addLabelList, removeLabelList) { |
| if (isMissingDocInfo(content)) { |
| if (!content.includes(text.PR_DOC_LATER)) { |
| commentText += '\n\n' + text.PR_DOC_LEGACY; |
| } |
| else { |
| commentText += text.PR_MISSING_DOC_INFO; |
| } |
| } |
| else { |
| if (isOptionChecked(content, text.PR_DOC_READY)) { |
| addLabelList.push(labelText.PR_DOC_READY); |
| removeLabelList.push( |
| labelText.PR_DOC_UNCHANGED, |
| labelText.PR_AWAITING_DOC |
| ); |
| } |
| else if (isOptionChecked(content, text.PR_DOC_UNCHANGED)) { |
| addLabelList.push(labelText.PR_DOC_UNCHANGED); |
| removeLabelList.push( |
| labelText.PR_DOC_READY, |
| labelText.PR_AWAITING_DOC |
| ); |
| } |
| else if (isOptionChecked(content, text.PR_DOC_LATER)) { |
| addLabelList.push(labelText.PR_AWAITING_DOC); |
| removeLabelList.push( |
| labelText.PR_DOC_UNCHANGED, |
| labelText.PR_DOC_READY |
| ); |
| commentText += text.PR_AWAITING_DOC; |
| } |
| } |
| return commentText; |
| } |
| |
| /** |
| * Check if a comment has submitted |
| * @param {Context} context |
| * @param {string} commentText |
| */ |
| async function hasCommented(context, commentText) { |
| const comments = (await context.octokit.issues.listComments(context.issue())).data; |
| return comments.findIndex(comment => |
| comment.user.type === 'Bot' |
| && comment.body |
| && comment.body.replace(/\r\n/g, '\n').includes(commentText) |
| ) > -1; |
| } |
| |
| /** |
| * Minimize a comment with specified classifier |
| * |
| * FIXME: unlike hiding via the UI, it doesn't show the classifier in the information |
| * |
| * @param {Context} context |
| * @param {string} commentText |
| * @param {ReportedContentClassifiers?} classifier |
| */ |
| async function minimizeComment(context, commentText, classifier) { |
| const comments = (await context.octokit.issues.listComments(context.issue())).data; |
| const comment = comments.find(comment => comment.user.type === 'Bot' && comment.body === commentText); |
| if (!comment) { |
| return; |
| } |
| try { |
| const res = await context.octokit.graphql( |
| ` |
| mutation minimizeComment($id: ID!, $classifier: ReportedContentClassifiers!) { |
| minimizeComment(input: { subjectId: $id, classifier: $classifier }) { |
| clientMutationId |
| minimizedComment { |
| isMinimized |
| minimizedReason |
| viewerCanMinimize |
| } |
| } |
| } |
| `, |
| { |
| id: comment.node_id, |
| classifier: classifier || 'OUTDATED' |
| } |
| ); |
| logger.info('minimize comment result: \n' + JSON.stringify(res, null, 2)); |
| } catch (e) { |
| if (e instanceof GraphqlResponseError) { |
| logger.error('GraphQL Request Failed'); |
| logger.error(JSON.stringify(e.request, null, 2)); |
| } |
| logger.error(e); |
| } |
| } |