Skip to content

Commit

Permalink
Merge pull request #15 from ubq-testing/chore/testing
Browse files Browse the repository at this point in the history
Chore/testing
  • Loading branch information
ubiquity-os[bot] authored Aug 29, 2024
2 parents bb2c570 + 9f1af45 commit 7ab97aa
Show file tree
Hide file tree
Showing 30 changed files with 1,486 additions and 384 deletions.
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ cypress/screenshots
.dev.vars
/tests/http/http-client.private.env.json
.wrangler
test-dashboard.md
t.ts
test-dashboard.md
6 changes: 6 additions & 0 deletions graphql.config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
schema:
- https://api.github.com/graphql:
headers:
Authorization: Bearer ${GITHUB_TOKEN}
documents: src/handlers/collect-linked-pulls.ts
projects: {}
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"main": "src/worker.ts",
"author": "Ubiquity DAO",
"license": "MIT",
"type": "module",
"engines": {
"node": ">=20.10.0"
},
Expand All @@ -31,10 +32,11 @@
"dependencies": {
"@actions/core": "1.10.1",
"@actions/github": "6.0.0",
"@octokit/graphql-schema": "^15.25.0",
"@octokit/rest": "20.1.1",
"@octokit/webhooks": "13.2.7",
"@sinclair/typebox": "0.32.31",
"@ubiquity-dao/ubiquibot-logger": "^1.3.0",
"@ubiquity-dao/ubiquibot-logger": "^1.3.1",
"dotenv": "16.4.5",
"luxon": "3.4.4",
"ms": "2.1.3",
Expand All @@ -46,6 +48,7 @@
"@cspell/dict-node": "5.0.1",
"@cspell/dict-software-terms": "3.4.0",
"@cspell/dict-typescript": "3.1.5",
"@jest/globals": "^29.7.0",
"@mswjs/data": "0.16.1",
"@types/jest": "29.5.12",
"@types/luxon": "3.4.2",
Expand Down Expand Up @@ -86,4 +89,4 @@
]
},
"packageManager": "[email protected]"
}
}
71 changes: 59 additions & 12 deletions src/handlers/collect-linked-pulls.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,66 @@
import { parseIssueUrl } from "../helpers/github-url";
import { Context } from "../types/context";
import { IssuesSearch } from "../types/github-types";
import { PullRequest, User, validate } from "@octokit/graphql-schema";

export type IssueParams = ReturnType<typeof parseIssueUrl>;
type closedByPullRequestsReferences = {
node: Pick<PullRequest, "url" | "title" | "number" | "state" | "body"> & Pick<User, "login" | "id">;
}

function additionalBooleanFilters(issueNumber: number) {
return `linked:${issueNumber} in:body "closes #${issueNumber}" OR "closes #${issueNumber}" OR "fixes #${issueNumber}" OR "fix #${issueNumber}" OR "resolves #${issueNumber}"`;
type IssueWithClosedByPRs = {
repository: {
issue: {
closedByPullRequestsReferences: {
edges: closedByPullRequestsReferences[];
};
};
};
}

export async function collectLinkedPullRequests(context: Context, issue: IssueParams) {
return await context.octokit.paginate(
context.octokit.rest.search.issuesAndPullRequests,
{
q: `repo:${issue.owner}/${issue.repo} is:pr is:open ${additionalBooleanFilters(issue.issue_number)}`,
per_page: 100,
const query = /* GraphQL */ `
query collectLinkedPullRequests($owner: String!, $repo: String!, $issue_number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issue_number) {
closedByPullRequestsReferences(first: 100, includeClosedPrs: true) {
edges {
node {
url
title
body
state
number
author {
login
... on User {
id: databaseId
}
}
}
}
}
}
) as IssuesSearch[];
}
}`

const queryErrors = validate(query);

/**
* > 1 because the schema package is slightly out of date and does not include the
* `closedByPullRequestsReferences` object in the schema as it is a recent addition to the GitHub API.
*/
if (queryErrors.length > 1) {
throw new Error(`Invalid query: ${queryErrors.join(", ")}`);
}

export async function collectLinkedPullRequests(context: Context, issue: {
owner: string;
repo: string;
issue_number: number;
}) {
const { owner, repo, issue_number } = issue;
const result = await context.octokit.graphql<IssueWithClosedByPRs>(query, {
owner,
repo,
issue_number,
});

return result.repository.issue.closedByPullRequestsReferences.edges.map((edge) => edge.node);
}
47 changes: 47 additions & 0 deletions src/handlers/watch-user-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { getWatchedRepos } from "../helpers/get-watched-repos";
import { updateTaskReminder } from "../helpers/task-update";
import { Context } from "../types/context";
import { ListForOrg, ListIssueForRepo } from "../types/github-types";

export async function watchUserActivity(context: Context) {
const { logger } = context;

const repos = await getWatchedRepos(context);

if (!repos?.length) {
logger.info("No watched repos have been found, no work to do.");
return false;
}

for (const repo of repos) {
await updateReminders(context, repo);
}

return true;
}

async function updateReminders(context: Context, repo: ListForOrg["data"][0]) {
const { logger, octokit, payload } = context;
const owner = payload.repository.owner?.login;
if (!owner) {
throw new Error("No owner found in the payload");
}
const issues = (await octokit.paginate(octokit.rest.issues.listForRepo, {
owner,
repo: repo.name,
per_page: 100,
state: "open",
})) as ListIssueForRepo[];

for (const issue of issues) {
// I think we can safely ignore the following
if (issue.draft || issue.pull_request || issue.locked || issue.state !== "open") {
continue;
}

if (issue.assignees?.length || issue.assignee) {
logger.debug(`Checking assigned issue: ${issue.html_url}`);
await updateTaskReminder(context, repo, issue);
}
}
}
32 changes: 32 additions & 0 deletions src/helpers/get-assignee-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { DateTime } from "luxon";
import { collectLinkedPullRequests } from "../handlers/collect-linked-pulls";
import { Context } from "../types/context";
import { parseIssueUrl } from "./github-url";
import { GitHubListEvents, ListIssueForRepo } from "../types/github-types";

/**
* Retrieves all the activity for users that are assigned to the issue. Also takes into account linked pull requests.
*/
export async function getAssigneesActivityForIssue(context: Context, issue: ListIssueForRepo, assigneeIds: number[]) {
const gitHubUrl = parseIssueUrl(issue.html_url);
const issueEvents: GitHubListEvents[] = 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 } = parseIssueUrl(linkedPullRequest.url || "");
const events = await context.octokit.paginate(context.octokit.rest.issues.listEvents, {
owner,
repo,
issue_number,
per_page: 100,
});
issueEvents.push(...events);
}

return issueEvents.filter((o) => o.actor && o.actor.id && assigneeIds.includes(o.actor.id))
.sort((a, b) => DateTime.fromISO(b.created_at).toMillis() - DateTime.fromISO(a.created_at).toMillis());
}
22 changes: 15 additions & 7 deletions src/helpers/get-watched-repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,35 @@ import { Context } from "../types/context";
import { ListForOrg } from "../types/github-types";

export async function getWatchedRepos(context: Context) {
const { config: { watch: { optOut } } } = context;
const {
config: {
watch: { optOut },
},
} = context;
const repoNames = new Set<string>();
const orgRepos = await getReposForOrg(context, context.payload.repository.owner.login);
const owner = context.payload.repository.owner?.login;
if (!owner) {
throw new Error("No owner found in the payload");
}
const orgRepos = await getReposForOrg(context, owner);
orgRepos.forEach((repo) => repoNames.add(repo.name.toLowerCase()));

for (const repo of optOut) {
repoNames.forEach((name) => name.includes(repo) ? repoNames.delete(name) : null);
repoNames.forEach((name) => (name.includes(repo) ? repoNames.delete(name) : null));
}

return Array.from(repoNames).map((name) => orgRepos.find((repo) => repo.name.toLowerCase() === name))
return Array.from(repoNames)
.map((name) => orgRepos.find((repo) => repo.name.toLowerCase() === name))
.filter((repo) => repo !== undefined) as ListForOrg["data"];
}


export async function getReposForOrg(context: Context, orgOrRepo: string) {
const { octokit } = context;
try {
return await octokit.paginate(octokit.rest.repos.listForOrg, {
return (await octokit.paginate(octokit.rest.repos.listForOrg, {
org: orgOrRepo,
per_page: 100,
}) as ListForOrg["data"];
})) as ListForOrg["data"];
} catch (er) {
throw new Error(`Error getting repositories for org ${orgOrRepo}: ` + JSON.stringify(er));
}
Expand Down
71 changes: 71 additions & 0 deletions src/helpers/remind-and-remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Context } from "../types/context";
import { parseIssueUrl } from "./github-url";
import { ListIssueForRepo } from "../types/github-types";
import { createStructuredMetadata } from "./structured-metadata";

export async function unassignUserFromIssue(context: Context, issue: ListIssueForRepo) {
const { logger, config } = context;

if (config.disqualification <= 0) {
logger.info("The unassign threshold is <= 0, won't unassign users.");
} else {
logger.info(`Passed the deadline on ${issue.html_url} and no activity is detected, removing assignees.`);
await removeAllAssignees(context, issue);
}
}

export async function remindAssigneesForIssue(context: Context, issue: ListIssueForRepo) {
const { logger, config } = context;
if (config.warning <= 0) {
logger.info("The reminder threshold is <= 0, won't send any reminder.");
} else {
logger.info(`Passed the reminder threshold on ${issue.html_url}, sending a reminder.`);
await remindAssignees(context, issue);
}
}

async function remindAssignees(context: Context, issue: ListIssueForRepo) {
const { octokit, logger } = context;
const { repo, owner, issue_number } = parseIssueUrl(issue.html_url);

if (!issue?.assignees?.length) {
logger.error(`Missing Assignees from ${issue.html_url}`);
return false;
}
const logins = issue.assignees
.map((o) => o?.login)
.filter((o) => !!o)
.join(", @");

const logMessage = logger.info(`@${logins}, this task has been idle for a while. Please provide an update.\n\n`, {
taskAssignees: issue.assignees.map((o) => o?.id),
});

const metadata = createStructuredMetadata("Followup", logMessage);

await octokit.rest.issues.createComment({
owner,
repo,
issue_number,
body: [logMessage.logMessage.raw, metadata].join("\n"),
});
return true;
}

async function removeAllAssignees(context: Context, issue: ListIssueForRepo) {
const { octokit, logger } = context;
const { repo, owner, issue_number } = parseIssueUrl(issue.html_url);

if (!issue?.assignees?.length) {
logger.error(`Missing Assignees from ${issue.html_url}`);
return false;
}
const logins = issue.assignees.map((o) => o?.login).filter((o) => !!o) as string[];
await octokit.rest.issues.removeAssignees({
owner,
repo,
issue_number,
assignees: logins,
});
return true;
}
28 changes: 28 additions & 0 deletions src/helpers/structured-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { LogReturn } from "@ubiquity-dao/ubiquibot-logger";

export function createStructuredMetadata(className: string, logReturn: LogReturn | null) {
let logMessage, metadata;
if (logReturn) {
logMessage = logReturn.logMessage;
metadata = logReturn.metadata;
}

const jsonPretty = JSON.stringify(metadata, null, 2);
const stackLine = new Error().stack?.split("\n")[2] ?? "";
const caller = stackLine.match(/at (\S+)/)?.[1] ?? "";
const ubiquityMetadataHeader = `<!-- Ubiquity - ${className} - ${caller} - ${metadata?.revision}`;

let metadataSerialized: string;
const metadataSerializedVisible = ["```json", jsonPretty, "```"].join("\n");
const metadataSerializedHidden = [ubiquityMetadataHeader, jsonPretty, "-->"].join("\n");

if (logMessage?.type === "fatal") {
// if the log message is fatal, then we want to show the metadata
metadataSerialized = [metadataSerializedVisible, metadataSerializedHidden].join("\n");
} else {
// otherwise we want to hide it
metadataSerialized = metadataSerializedHidden;
}

return metadataSerialized;
}
Loading

0 comments on commit 7ab97aa

Please sign in to comment.