Skip to content

Commit

Permalink
Merge pull request #9 from ubiquibot/development
Browse files Browse the repository at this point in the history
Merge development into main
  • Loading branch information
gentlementlegen authored Jul 9, 2024
2 parents ac6aa04 + 812ec5f commit 34520f1
Show file tree
Hide file tree
Showing 16 changed files with 1,367 additions and 370 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "20.10.0"
- uses: actions/checkout@v4
- uses: cloudflare/wrangler-action@v3
with:
Expand Down
16 changes: 6 additions & 10 deletions .github/workflows/jest-testing.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
name: Run Jest testing suite
on:
workflow_dispatch:
workflow_run:
workflows: ["Knip"]
types:
- completed
pull_request:

env:
NODE_ENV: "test"
Expand All @@ -24,10 +21,9 @@ jobs:
with:
fetch-depth: 0

- name: Jest With Coverage Comment
# Ensures this step is run even on previous step failure (e.g. test failed)
- name: Jest With Coverage
run: yarn install --immutable --immutable-cache --check-cache && yarn test

- name: Add Jest Report to Summary
if: always()
uses: ArtiomTr/jest-coverage-report-action@v2
with:
package-manager: yarn
prnumber: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number }}
run: echo "$(cat test-dashboard.md)" >> $GITHUB_STEP_SUMMARY
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# @ubiquibot/user-activity-watcher

Watches user activity on issues, sends reminders on deadlines, and eventually unassigns inactive user to ensure that
tasks don't stall, and applies malus XP.
tasks don't stall, and subtracts XP.

## Setup
```shell
Expand Down Expand Up @@ -52,6 +52,6 @@ yarn test
- plugin: ubiquibot/user-activity-watcher
type: github
with:
unassignUserThreshold: 7
sendRemindersThreshold: 3.5
disqualification: "7 days"
warning: "3.5 days"
```
2 changes: 1 addition & 1 deletion jest.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"coveragePathIgnorePatterns": ["node_modules", "mocks"],
"collectCoverage": true,
"coverageReporters": ["json", "lcov", "text", "clover", "json-summary"],
"reporters": ["default", "jest-junit"],
"reporters": ["default", "jest-junit", "jest-md-dashboard"],
"coverageDirectory": "coverage",
"setupFiles": ["dotenv/config"]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"husky": "9.0.11",
"jest": "29.7.0",
"jest-junit": "16.0.0",
"jest-md-dashboard": "0.8.0",
"knip": "5.17.3",
"lint-staged": "15.2.5",
"msw": "2.3.1",
Expand Down
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);
}
36 changes: 26 additions & 10 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 All @@ -11,7 +12,7 @@ async function unassignUserFromIssue(context: Context, issue: Database["public"]
config,
} = context;

if (config.unassignUserThreshold <= 0) {
if (config.disqualification <= 0) {
logger.info("The unassign threshold is <= 0, won't unassign users.");
} else {
logger.info(`Passed the deadline on ${issue.url} and no activity is detected, removing assignees.`);
Expand All @@ -30,7 +31,7 @@ async function remindAssigneesForIssue(context: Context, issue: Database["public
const now = DateTime.now();
const deadline = DateTime.fromISO(issue.deadline);

if (config.sendRemindersThreshold <= 0) {
if (config.warning <= 0) {
logger.info("The reminder threshold is <= 0, won't send any reminder.");
} else {
const lastReminder = issue.last_reminder;
Expand Down Expand Up @@ -59,8 +60,8 @@ async function updateReminders(context: Context, issue: Database["public"]["Tabl
payload.issue?.assignees?.find((assignee) => assignee?.login === o.actor.login) && DateTime.fromISO(o.created_at) >= DateTime.fromISO(issue.last_check)
);
const deadline = DateTime.fromISO(issue.deadline);
const deadlineWithThreshold = deadline.plus({ day: config.unassignUserThreshold });
const reminderWithThreshold = deadline.plus({ day: config.sendRemindersThreshold });
const deadlineWithThreshold = deadline.plus({ milliseconds: config.disqualification });
const reminderWithThreshold = deadline.plus({ milliseconds: config.warning });

if (activity?.length) {
const lastCheck = DateTime.fromISO(issue.last_check);
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
7 changes: 5 additions & 2 deletions src/parser/payload.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import * as github from "@actions/github";
import { Value } from "@sinclair/typebox/value";
import { config } from "dotenv";
import { PluginInputs } from "../types/plugin-inputs";
import { PluginInputs, userActivityWatcherSettingsSchema } from "../types/plugin-inputs";

config();

const webhookPayload = github.context.payload.inputs;
const settings = Value.Decode(userActivityWatcherSettingsSchema, Value.Default(userActivityWatcherSettingsSchema, JSON.parse(webhookPayload.settings)));

const program: PluginInputs = {
stateId: webhookPayload.stateId,
eventName: webhookPayload.eventName,
authToken: webhookPayload.authToken,
ref: webhookPayload.ref,
eventPayload: JSON.parse(webhookPayload.eventPayload),
settings: JSON.parse(webhookPayload.settings),
settings,
};

export default program;
Loading

0 comments on commit 34520f1

Please sign in to comment.