Skip to content

Commit

Permalink
Merge pull request #8 from gentlementlegen/main
Browse files Browse the repository at this point in the history
feat: linked pull request activity is now taken into account
  • Loading branch information
gentlementlegen authored Jul 6, 2024
2 parents ed28bfe + f195b05 commit 812ec5f
Show file tree
Hide file tree
Showing 5 changed files with 1,180 additions and 6 deletions.
78 changes: 78 additions & 0 deletions src/handlers/collect-linked-pulls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { parseGitHubUrl } from "../helpers/github-url";
import { Context } from "../types/context";
import { GitHubLinkEvent, GitHubTimelineEvent, isGitHubLinkEvent } from "../types/github-types";

export type IssueParams = ReturnType<typeof parseGitHubUrl>;

export async function collectLinkedPullRequests(context: Context, issue: IssueParams) {
const onlyPullRequests = await collectLinkedPulls(context, issue);
return onlyPullRequests.filter((event) => {
if (!event.source.issue.body) {
return false;
}
// Matches all keywords according to the docs:
// https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword
// Works on multiple linked issues, and matches #<number> or URL patterns
const linkedIssueRegex =
/\b(?:Close(?:s|d)?|Fix(?:es|ed)?|Resolve(?:s|d)?):?\s+(?:#(\d+)|https?:\/\/(?:www\.)?github\.com\/(?:[^/\s]+\/[^/\s]+\/(?:issues|pull)\/(\d+)))\b/gi;
const linkedPrUrls = event.source.issue.body.match(linkedIssueRegex);
if (!linkedPrUrls) {
return false;
}
let isClosingPr = false;
for (let i = 0; i < linkedPrUrls.length && !isClosingPr; ++i) {
const idx = linkedPrUrls[i].indexOf("#");
if (idx !== -1) {
isClosingPr = Number(linkedPrUrls[i].slice(idx + 1)) === issue.issue_number;
} else {
const url = linkedPrUrls[i].match(/https.+/)?.[0];
if (url) {
const linkedRepo = parseGitHubUrl(url);
isClosingPr = linkedRepo.issue_number === issue.issue_number && linkedRepo.repo === issue.repo && linkedRepo.owner === issue.owner;
}
}
}
return isGitHubLinkEvent(event) && event.source.issue.pull_request?.merged_at === null && isClosingPr;
});
}

export async function collectLinkedPulls(context: Context, issue: IssueParams) {
const issueLinkEvents = await getLinkedEvents(context, issue);
const onlyConnected = eliminateDisconnects(issueLinkEvents);
return onlyConnected.filter((event) => isGitHubLinkEvent(event) && event.source.issue.pull_request);
}

function eliminateDisconnects(issueLinkEvents: GitHubLinkEvent[]) {
// Track connections and disconnections
const connections = new Map<number, GitHubLinkEvent>(); // Use issue/pr number as key for easy access
const disconnections = new Map<number, GitHubLinkEvent>(); // Track disconnections

issueLinkEvents.forEach((issueEvent: GitHubLinkEvent) => {
const issueNumber = issueEvent.source.issue.number as number;

if (issueEvent.event === "connected" || issueEvent.event === "cross-referenced") {
// Only add to connections if there is no corresponding disconnected event
if (!disconnections.has(issueNumber)) {
connections.set(issueNumber, issueEvent);
}
} else if (issueEvent.event === "disconnected") {
disconnections.set(issueNumber, issueEvent);
// If a disconnected event is found, remove the corresponding connected event
if (connections.has(issueNumber)) {
connections.delete(issueNumber);
}
}
});

return Array.from(connections.values());
}

async function getLinkedEvents(context: Context, params: IssueParams): Promise<GitHubLinkEvent[]> {
const issueEvents = await getAllTimelineEvents(context, params);
return issueEvents.filter(isGitHubLinkEvent);
}

export async function getAllTimelineEvents({ octokit }: Context, issueParams: IssueParams): Promise<GitHubTimelineEvent[]> {
const options = octokit.issues.listEventsForTimeline.endpoint.merge(issueParams);
return await octokit.paginate(options);
}
28 changes: 22 additions & 6 deletions src/helpers/update-tasks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DateTime } from "luxon";
import { collectLinkedPullRequests } from "../handlers/collect-linked-pulls";
import { Context } from "../types/context";
import { Database } from "../types/database";
import { getGithubIssue } from "./get-env";
Expand Down Expand Up @@ -100,14 +101,29 @@ export async function updateTasks(context: Context) {
return true;
}

async function getAssigneesActivityForIssue({ octokit }: Context, issue: Database["public"]["Tables"]["issues"]["Row"]) {
const { repo, owner, issue_number } = parseGitHubUrl(issue.url);
return octokit.paginate(octokit.rest.issues.listEvents, {
owner,
repo,
issue_number,
/**
* Retrieves all the activity for users that are assigned to the issue. Also takes into account linked pull requests.
*/
async function getAssigneesActivityForIssue(context: Context, issue: Database["public"]["Tables"]["issues"]["Row"]) {
const gitHubUrl = parseGitHubUrl(issue.url);
const issueEvents = await context.octokit.paginate(context.octokit.rest.issues.listEvents, {
owner: gitHubUrl.owner,
repo: gitHubUrl.repo,
issue_number: gitHubUrl.issue_number,
per_page: 100,
});
const linkedPullRequests = await collectLinkedPullRequests(context, gitHubUrl);
for (const linkedPullRequest of linkedPullRequests) {
const { owner, repo, issue_number } = parseGitHubUrl(linkedPullRequest.source.issue.html_url);
const events = await context.octokit.paginate(context.octokit.rest.issues.listEvents, {
owner,
repo,
issue_number,
per_page: 100,
});
issueEvents.push(...events);
}
return issueEvents;
}

async function remindAssignees(context: Context, issue: Database["public"]["Tables"]["issues"]["Row"]) {
Expand Down
24 changes: 24 additions & 0 deletions src/types/github-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { RestEndpointMethodTypes } from "@octokit/rest";

export type GitHubIssue = RestEndpointMethodTypes["issues"]["get"]["response"]["data"];
export type GitHubPullRequest = RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
export type GitHubTimelineEvent = RestEndpointMethodTypes["issues"]["listEventsForTimeline"]["response"]["data"][0];
export type GitHubRepository = RestEndpointMethodTypes["repos"]["get"]["response"]["data"];

type LinkPullRequestDetail = {
url: string;
html_url: string;
diff_url: string;
patch_url: string;
merged_at: string;
};

type SourceIssueWithPullRequest = GitHubIssue | ((GitHubPullRequest & { pull_request: LinkPullRequestDetail }) & { repository: GitHubRepository });

export type GitHubLinkEvent = RestEndpointMethodTypes["issues"]["listEventsForTimeline"]["response"]["data"][0] & {
event: "connected" | "disconnected" | "cross-referenced";
source: { issue: SourceIssueWithPullRequest };
};
export function isGitHubLinkEvent(event: GitHubTimelineEvent): event is GitHubLinkEvent {
return "source" in event;
}
4 changes: 4 additions & 0 deletions tests/__mocks__/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { http, HttpResponse } from "msw";
import { db } from "./db";
import issueEventsGet from "./routes/get-events.json";
import issuesLabelsGet from "./routes/get-labels.json";
import issueTimeline from "./routes/get-timeline.json";

/**
* Intercepts the routes and returns a custom payload
Expand Down Expand Up @@ -29,4 +30,7 @@ export const handlers = [
http.get("https://api.github.com/repos/:owner/:repo/issues/:id/labels", () => {
return HttpResponse.json(issuesLabelsGet);
}),
http.get("https://api.github.com/repos/:owner/:repo/issues/:id/timeline", () => {
return HttpResponse.json(issueTimeline);
}),
];
Loading

0 comments on commit 812ec5f

Please sign in to comment.