Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 8 additions & 3 deletions app/commands/escalate/escalationResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "#~/effects/discordSdk";
import { logEffect } from "#~/effects/observability";
import {
getMostSevereResolution,
humanReadableResolutions,
resolutions,
type Resolution,
Expand Down Expand Up @@ -78,13 +79,17 @@ export const processEscalationEffect = (
if (tally.totalVotes === 0) {
resolution = resolutions.track;
} else if (tally.isTied) {
resolution = getMostSevereResolution(tally.tiedResolutions);
yield* logEffect(
"warn",
"EscalationResolver",
"Auto-resolve defaulting to track due to tie",
{ tiedResolutions: tally.tiedResolutions, votingStrategy },
"Auto-resolve tie broken by severity",
{
tiedResolutions: tally.tiedResolutions,
selectedResolution: resolution,
votingStrategy,
},
);
resolution = resolutions.track;
} else if (tally.leader) {
resolution = tally.leader;
} else {
Expand Down
31 changes: 31 additions & 0 deletions app/commands/escalate/voting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,37 @@ describe("tallyVotes", () => {
expect(result.tiedResolutions).toHaveLength(3);
});

it("provides tied resolutions with different severities for tiebreaker", () => {
const tally = tallyVotes([
{ vote: resolutions.timeout, voter_id: "user1" },
{ vote: resolutions.restrict, voter_id: "user2" },
{ vote: resolutions.timeout, voter_id: "user3" },
{ vote: resolutions.restrict, voter_id: "user4" },
]);

expect(tally.isTied).toBe(true);
expect(tally.tiedResolutions).toHaveLength(2);
expect(tally.tiedResolutions).toContain(resolutions.timeout);
expect(tally.tiedResolutions).toContain(resolutions.restrict);
});

it("provides all tied resolutions in three-way tie for tiebreaker", () => {
const tally = tallyVotes([
{ vote: resolutions.track, voter_id: "user1" },
{ vote: resolutions.kick, voter_id: "user2" },
{ vote: resolutions.ban, voter_id: "user3" },
{ vote: resolutions.track, voter_id: "user4" },
{ vote: resolutions.kick, voter_id: "user5" },
{ vote: resolutions.ban, voter_id: "user6" },
]);

expect(tally.isTied).toBe(true);
expect(tally.tiedResolutions).toHaveLength(3);
expect(tally.tiedResolutions).toContain(resolutions.track);
expect(tally.tiedResolutions).toContain(resolutions.kick);
expect(tally.tiedResolutions).toContain(resolutions.ban);
});

it("breaks tie when one option gets more votes", () => {
const votes = [
{ vote: resolutions.ban, voter_id: "user1" },
Expand Down
64 changes: 64 additions & 0 deletions app/helpers/modResponse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";

import { getMostSevereResolution, resolutions } from "./modResponse.js";

describe("getMostSevereResolution", () => {
it("returns most severe when given multiple resolutions", () => {
expect(getMostSevereResolution([resolutions.track, resolutions.ban])).toBe(
resolutions.ban,
);

expect(
getMostSevereResolution([resolutions.timeout, resolutions.restrict]),
).toBe(resolutions.restrict);

expect(
getMostSevereResolution([
resolutions.track,
resolutions.kick,
resolutions.timeout,
]),
).toBe(resolutions.kick);
});

it("returns the resolution when given a single resolution", () => {
expect(getMostSevereResolution([resolutions.track])).toBe(
resolutions.track,
);
expect(getMostSevereResolution([resolutions.ban])).toBe(resolutions.ban);
});

it("handles all resolutions being tied", () => {
expect(
getMostSevereResolution([
resolutions.track,
resolutions.timeout,
resolutions.restrict,
resolutions.kick,
resolutions.ban,
]),
).toBe(resolutions.ban);
});

it("returns track for empty array", () => {
expect(getMostSevereResolution([])).toBe(resolutions.track);
});

it("correctly orders all severity levels", () => {
expect(getMostSevereResolution([resolutions.ban, resolutions.track])).toBe(
resolutions.ban,
);
expect(
getMostSevereResolution([resolutions.track, resolutions.timeout]),
).toBe(resolutions.timeout);
expect(
getMostSevereResolution([resolutions.timeout, resolutions.restrict]),
).toBe(resolutions.restrict);
expect(
getMostSevereResolution([resolutions.restrict, resolutions.kick]),
).toBe(resolutions.kick);
expect(getMostSevereResolution([resolutions.kick, resolutions.ban])).toBe(
resolutions.ban,
);
});
});
30 changes: 30 additions & 0 deletions app/helpers/modResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@ export const humanReadableResolutions = {
} as const;
export type Resolution = (typeof resolutions)[keyof typeof resolutions];

const severityOrder: Resolution[] = [
resolutions.track,
resolutions.timeout,
resolutions.restrict,
resolutions.kick,
resolutions.ban,
];

export function getMostSevereResolution(
resolutionList: Resolution[],
): Resolution {
// Defensive: return track if empty (shouldn't happen in practice)
if (resolutionList.length === 0) {
return resolutions.track;
}

let mostSevere = resolutionList[0];
let highestIndex = severityOrder.indexOf(mostSevere);

for (const resolution of resolutionList) {
const index = severityOrder.indexOf(resolution);
if (index > highestIndex) {
highestIndex = index;
mostSevere = resolution;
}
}

return mostSevere;
}

export const votingStrategies = {
simple: "simple",
majority: "majority",
Expand Down