Skip to content
This repository has been archived by the owner on Sep 19, 2024. It is now read-only.

Blame #778

Open
wants to merge 7 commits into
base: development
Choose a base branch
from
Open

Blame #778

Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 241 additions & 0 deletions src/handlers/comment/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
getTokenSymbol,
getAllIssueAssignEvents,
calculateWeight,
getAllPullRequests,
getAllPullRequestReviews,
} from "../../../helpers";
import { getBotConfig, getBotContext, getLogger } from "../../../bindings";
import {
Expand All @@ -38,6 +40,7 @@ import { autoPay } from "./payout";
import { getTargetPriceLabel } from "../../shared";
import Decimal from "decimal.js";
import { ErrorDiff } from "../../../utils/helpers";
import { lastActivityTime } from "../../wildcard";

export * from "./assign";
export * from "./wallet";
Expand Down Expand Up @@ -155,6 +158,244 @@ export const issueCreatedCallback = async (): Promise<void> => {
}
};

/**
* Callback for issues reopened - Blame Processor
* @notice Identifies the changes in main that broke the features of the issue
* @notice This is to assign responsibility to the person who broke the feature
* @dev The person in fault will be penalized...
*/
export const issueReopenedBlameCallback = async (): Promise<void> => {
const logger = getLogger();
const context = getBotContext();
// const config = getBotConfig();
const payload = context.payload as Payload;
const issue = payload.issue;
const repository = payload.repository;

if (!issue) return;
if (!repository) return;

const allRepoCommits = await context.octokit.repos
.listCommits({
owner: repository.owner.login,
repo: repository.name,
})
.then((res) => res.data);

const currentCommit = allRepoCommits[0];
const currentCommitSha = currentCommit.sha;
const lastActivity = await lastActivityTime(issue, await getAllIssueComments(issue.number));

const allClosedPulls = await getAllPullRequests(context, "closed");
const mergedPulls = allClosedPulls.filter((pull) => pull.merged_at && pull.merged_at > lastActivity.toISOString());
const mergedSHAs = mergedPulls.map((pull) => pull.merge_commit_sha);
const commitsThatMatch = allRepoCommits.filter((commit) => mergedSHAs.includes(commit.sha)).reverse();

const pullsThatCommitsMatch = await Promise.all(
commitsThatMatch.map((commit) =>
context.octokit.repos
.listPullRequestsAssociatedWithCommit({
owner: repository.owner.login,
repo: repository.name,
commit_sha: commit.sha,
})
.then((res) => res.data)
)
);

const onlyPRsNeeded = pullsThatCommitsMatch.map((pulls) => pulls.map((pull) => pull.number)).reduce((acc, val) => acc.concat(val), []);

const issueRegex = new RegExp(`#${issue.number}`, "g");
const matchingPull = mergedPulls.find((pull) => pull.body?.match(issueRegex));

if (!matchingPull) {
logger.info(`No matching pull found for issue #${issue.number}`);
return;
}

const pullDiff = await context.octokit.repos
.compareCommitsWithBasehead({
owner: repository.owner.login,
repo: repository.name,
basehead: matchingPull?.merge_commit_sha + "..." + currentCommitSha,
mediaType: {
format: "diff",
},
})
.then((res) => res.data);

const diffs = [];
const fileLens: number[] = [];

for (const sha of mergedSHAs) {
if (!sha) continue;
const diff = await context.octokit.repos
.compareCommitsWithBasehead({
owner: repository.owner.login,
repo: repository.name,
basehead: sha + "..." + currentCommitSha,
})
.then((res) => res.data);

const fileLen = diff.files?.length;

fileLens.push(fileLen || 0);
diffs.push(diff);

if (diff.files && diff.files.length > 0) {
logger.info(`Found ${diff.files.length} files changed in commit ${sha}`);
} else {
logger.info(`No files changed in commit ${sha}`);
}
}

interface Blamed {
author: string;
count: number;
pr?: number[];
}

if (pullDiff.files && pullDiff.files.length > 0) {
logger.info(`Found ${pullDiff.files.length} files changed in commit ${matchingPull?.merge_commit_sha}`);
}

const pullReviewsMap: Record<number, string[]> = {};
const blamedHunters: Blamed[] = [];
const blamedReviewers: Blamed[] = [];

for (const pull of mergedPulls) {
const reviews = await getAllPullRequestReviews(context, pull.number);
const reviewers = reviews
.filter((review) => review.state === "APPROVED")
.map((review) => review.user?.login)
.filter((reviewer) => reviewer !== undefined) as string[];
if (reviewers.length) {
pullReviewsMap[pull.number] = reviewers;
}
}

for (const pullNumber of onlyPRsNeeded) {
const reviewers = pullReviewsMap[pullNumber];
if (reviewers) {
for (const reviewer of reviewers) {
const blame = blamedReviewers.find((b) => b.author === reviewer);
if (blame) {
blame.count += 1;
blame.pr?.push(pullNumber);
} else {
blamedReviewers.push({ author: reviewer || "", count: 1, pr: [pullNumber] });
Keyrxng marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
}

const matchingSlice = matchingPull?.merge_commit_sha?.slice(0, 8);
const currentSlice = currentCommitSha.slice(0, 8);

const twoDotUrl = `<code>[${matchingSlice}..${currentSlice}](${repository.html_url}/compare/${matchingPull?.merge_commit_sha}..${currentCommitSha})</code>`;

const countNonWhitespaceChanges = (patch: string): number => {
const lines = patch.split("\n");
const changedLines = lines.filter((line) => line.startsWith("+") || line.startsWith("-"));
const count = changedLines.reduce((acc, line) => acc + line.slice(1).replace(/\s/g, "").length, 0);

return count;
};

for (const diff of diffs) {
if (!diff.files) continue;
for (const file of diff.files) {
const charsChanged = countNonWhitespaceChanges(file.patch || "");
const author = diff.base_commit?.author?.login;
const blamer = blamedHunters.find((b) => b.author === author);
if (blamer) {
blamer.count += charsChanged || 0;
} else {
blamedHunters.push({ author: author || "", count: charsChanged || 0 });
}
}
}

const generateHunterTable = (blamers: Blamed[]) => `
| **Blame** | **%** |
| --- | --- |
${Array.from(new Set(blamers))
.filter((blamed) => blamed.count > 0)
.sort((a, b) => b.count - a.count)
.map((blamed) => {
const linesChanged = blamed.count;
const totalLinesChanged = blamers.reduce((acc, val) => acc + val.count, 0);
const percent = Math.round((linesChanged / totalLinesChanged) * 100);
return `| ${blamed.author} | ${percent}% |`;
})
.join("\n")}
`;

const generateReviewerTable = (blamers: Blamed[]) => `
| **Blame** | **%** | PRs |
| --- | --- | --- |
${Array.from(new Set(blamers))
.filter((blamed) => blamed.count > 0)
.sort((a, b) => b.count - a.count)
.map((blamed) => {
const linesChanged = blamed.count;
const totalLinesChanged = blamers.reduce((acc, val) => acc + val.count, 0);
const percent = Math.round((linesChanged / totalLinesChanged) * 100);
Keyrxng marked this conversation as resolved.
Show resolved Hide resolved
return `| ${blamed.author} | ${percent}% | ${blamed.pr?.map((pr) => `[#${pr}](${repository.html_url}/pull/${pr})`).join(", ")} |`;
})
.join("\n")}
`;

const huntersTable = generateHunterTable(blamedHunters);
const reviewersTable = generateReviewerTable(blamedReviewers);

const blameQuantifier = blamedHunters.length > 1 ? "suspects" : "suspect";

const comment = `
<details>
<summary>Merged Pulls Since Issue Close</summary><br/>

${onlyPRsNeeded
.sort()
.map((pullNumber) => `\n<ul><li>#${pullNumber}</li></ul>`)
.join("\n")}
</details>


<details>
<summary>Merged Commits Since Issue Close</summary><br/>
${diffs
.map((diff, i) => {
const sha = mergedSHAs[i];
const slice = sha?.slice(0, 7);
const url = `${repository.html_url}/commit/${sha}`;
return `\n<code><a href="${url}">${slice}</a></code> - ${diff.files?.length} files changed - ${repository.html_url}/compare/${sha}...${currentCommitSha}`;
})
.join("\n")}
</details>

<details>
<summary>Assigned Blame</summary><br/>

The following ${blameQuantifier} may be responsible for breaking this issue:

**Hunters**
${huntersTable}

**Reviewers**
${reviewersTable}

</details>

<hr>

2 dot: ${twoDotUrl}
3 dot: ${repository.html_url}/compare/${matchingPull?.merge_commit_sha}...${currentCommitSha}
`;

await addCommentToIssue(comment, issue.number);
};

/**
* Callback for issues reopened - Processor
*/
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { closePullRequestForAnIssue, commentWithAssignMessage } from "./assign";
import { pricingLabelLogic, validatePriceLabels } from "./pricing";
import { checkBountiesToUnassign, collectAnalytics, checkWeeklyUpdate } from "./wildcard";
import { nullHandler } from "./shared";
import { handleComment, issueClosedCallback, issueCreatedCallback, issueReopenedCallback } from "./comment";
import { handleComment, issueClosedCallback, issueCreatedCallback, issueReopenedBlameCallback, issueReopenedCallback } from "./comment";
import { checkPullRequests } from "./assign/auto";
import { createDevPoolPR } from "./pull-request";
import { runOnPush, validateConfigChange } from "./push";
Expand All @@ -18,7 +18,7 @@ export const processors: Record<string, Handler> = {
},
[GithubEvent.ISSUES_REOPENED]: {
pre: [nullHandler],
action: [issueReopenedCallback],
action: [issueReopenedBlameCallback, issueReopenedCallback],
post: [nullHandler],
0x4007 marked this conversation as resolved.
Show resolved Hide resolved
},
[GithubEvent.ISSUES_LABELED]: {
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/wildcard/unassign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const checkBountyToUnassign = async (issue: Issue): Promise<boolean> => {
return false;
};

const lastActivityTime = async (issue: Issue, comments: Comment[]): Promise<Date> => {
export const lastActivityTime = async (issue: Issue, comments: Comment[]): Promise<Date> => {
const logger = getLogger();
logger.info(`Checking the latest activity for the issue, issue_number: ${issue.number}`);
const assignees = issue.assignees.map((i) => i.login);
Expand Down
Loading