Skip to content
Merged
33 changes: 21 additions & 12 deletions apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {
applyPullRequestDraftFilterChange,
parsePullRequestQuery,
} from "@t3tools/client-runtime/pull-request-query";
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
import { useNavigation } from "@react-navigation/native";
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ProjectId } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import {
Keyboard,
Expand Down Expand Up @@ -30,10 +33,10 @@ import { usePullRequestListActions } from "./usePullRequestListActions";
import {
filterPullRequestList,
pullRequestListFacets,
pullRequestListInput,
pullRequestListRowsAreEqual,
scopedPullRequestListKey as pullRequestKey,
type EnvironmentPullRequestListEntry,
PR_LIST_PAGE_SIZE,
withoutScratchProjectScope,
} from "./pullRequestList";
import { useDebouncedValue } from "../../state/queries";
Expand Down Expand Up @@ -79,6 +82,8 @@ export function PullRequestsScreen(
);
const debouncedQuery = useDebouncedValue(tab.query.trim(), 180);
const query = tab.searchMode === "resting" ? "" : debouncedQuery;
const typedQuery = tab.searchMode === "resting" ? "" : tab.query;
const typedDraft = parsePullRequestQuery(typedQuery).filters.draft;
const listControls = usePullRequestListControls();
const selectedEnvironmentId = listControls.environmentId;
const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
Expand Down Expand Up @@ -121,14 +126,7 @@ export function PullRequestsScreen(
.sort((left, right) => left.environmentId.localeCompare(right.environmentId))
.map((environment) => ({
environmentId: environment.environmentId,
input: {
state: controls.state,
involvement: controls.involvement,
filters: controls.filters,
limit: PR_LIST_PAGE_SIZE,
...(controls.projectId ? { projectId: ProjectId.make(controls.projectId) } : {}),
...(query ? { query } : {}),
},
input: pullRequestListInput(controls, query),
})),
[environments, environmentId, configs, controls, query],
);
Expand Down Expand Up @@ -281,9 +279,20 @@ export function PullRequestsScreen(
}))}
environmentId={environmentId}
onEnvironmentChange={listControls.setEnvironmentId}
value={controls}
value={
typedDraft
? { ...controls, filters: { ...controls.filters, draft: typedDraft } }
: controls
}
onChange={(next) => {
listControls.setControls(environmentId, next);
const update = applyPullRequestDraftFilterChange(
typedQuery,
controls.filters,
next.filters,
);
listControls.setControls(environmentId, { ...next, filters: update.filters });
if (update.query !== typedQuery)
workspace.dispatch({ type: "query", tab: "pullRequests", query: update.query });
void listRef.current?.scrollToOffset({
offset: Platform.OS === "ios" ? -insets.top : 0,
animated: false,
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/features/pull-requests/pullRequestList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
mergePullRequestListEntries,
mergeEnvironmentPullRequestPages,
nextPullRequestListInput,
PR_LIST_PAGE_SIZE,
pullRequestListFacets,
pullRequestListHead,
pullRequestListInput,
pullRequestListReviewStatus,
pullRequestLabelColor,
scopedPullRequestListKey,
Expand Down Expand Up @@ -314,6 +316,29 @@ describe("mobile PR scan and filters", () => {
),
).toEqual([1]);
});
it("reads a typed draft qualifier as a filter and searches only the text left over", () => {
expect(pullRequestListInput(DEFAULT_PR_LIST_CONTROLS, "draft:false fix")).toEqual({
state: "open",
involvement: "all",
filters: { draft: "hide" },
limit: PR_LIST_PAGE_SIZE,
query: "fix",
});
const rows = [
entry(1, { title: "Fix parser" }),
entry(2, { title: "Fix drafts", isDraft: true }),
entry(3),
];
expect(
filterPullRequestList(
rows,
DEFAULT_PR_LIST_CONTROLS,
{},
"draft:false fix",
new Set(["github.com"]),
).map((row) => row.number),
).toEqual([1]);
});
it("does not label unsupported review summaries as reviewed or match text locally on host-search results", () => {
expect(
filterPullRequestList(
Expand Down
32 changes: 25 additions & 7 deletions apps/mobile/src/features/pull-requests/pullRequestList.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type {
EnvironmentId,
PullRequestListEntry,
PullRequestListInput,
PullRequestListResult,
import { parsePullRequestQuery } from "@t3tools/client-runtime/pull-request-query";
import {
ProjectId,
type EnvironmentId,
type PullRequestListEntry,
type PullRequestListInput,
type PullRequestListResult,
} from "@t3tools/contracts";
import type { PullRequestListControls } from "../../persistence/mobile-preferences";

Expand Down Expand Up @@ -138,6 +140,21 @@ export function pullRequestListHead(
};
}

export function pullRequestListInput(
controls: PullRequestListControls,
query: string,
): PullRequestListInput {
const parsed = parsePullRequestQuery(query);
return {
state: controls.state,
involvement: controls.involvement,
filters: { ...controls.filters, ...parsed.filters },
limit: PR_LIST_PAGE_SIZE,
...(controls.projectId ? { projectId: ProjectId.make(controls.projectId) } : {}),
...(parsed.text ? { query: parsed.text } : {}),
};
}

export function nextPullRequestListInput(
pages: PullRequestListPages,
input: PullRequestListInput,
Expand Down Expand Up @@ -186,8 +203,9 @@ export function filterPullRequestList<
connectedEnvironmentIds?: ReadonlySet<string>,
) {
const normalize = (value: string) => value.toLowerCase();
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const { filters } = controls;
const parsed = parsePullRequestQuery(query);
const words = parsed.text.toLowerCase().split(/\s+/).filter(Boolean);
const filters = { ...controls.filters, ...parsed.filters };
const filtered = entries.filter((entry) => {
if (controls.state !== "all" && entry.state !== controls.state) return false;
if (controls.projectId && entry.projectId !== controls.projectId) return false;
Expand Down
131 changes: 0 additions & 131 deletions apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
groupPullRequestsByInvolvement,
matchesPullRequestFilters,
matchesPullRequestQuery,
parsePullRequestQuery,
pullRequestStatsBatches,
pullRequestStatsKeysToRequest,
pullRequestStatsRefreshBatches,
Expand Down Expand Up @@ -557,84 +556,6 @@ describe("narrowing rows by the filters a host may not have applied", () => {
});
});

describe("reading qualifiers out of a typed query", () => {
it("keeps quoted values whole and separates negation", () => {
expect(parsePullRequestQuery('label:"needs design" -label:wip Fix the parser')).toEqual({
text: "Fix the parser",
filters: { labels: [["needs design"]], excludedLabels: ["wip"] },
});
});

it("reads a comma-separated list as one group either name satisfies", () => {
expect(parsePullRequestQuery("label:size:S,size:XS").filters.labels).toEqual([
["size:S", "size:XS"],
]);
});

it("keeps the spaces in a quoted name of a list, and drops an empty part", () => {
expect(parsePullRequestQuery('label:"needs design","wip"').filters.labels).toEqual([
["needs design", "wip"],
]);
expect(parsePullRequestQuery("label:a,,b").filters.labels).toEqual([["a", "b"]]);
});

it("excludes every name of a negated list", () => {
expect(parsePullRequestQuery("-label:a,b").filters).toEqual({ excludedLabels: ["a", "b"] });
});

it("makes each label qualifier its own group, so the groups are an AND", () => {
expect(parsePullRequestQuery("label:bug label:a,b").filters.labels).toEqual([
["bug"],
["a", "b"],
]);
});

it("reads the scalar qualifiers in GitHub's own spelling", () => {
expect(
parsePullRequestQuery("author:octocat DRAFT:false review:changes_requested status:failure")
.filters,
).toEqual({
author: "octocat",
draft: "hide",
review: "changes-requested",
checks: "failing",
});
});

it("leaves an unknown value and a stray colon as text, and reads an unknown key as a label", () => {
const parsed = parsePullRequestQuery("milestone:v2 draft:maybe status: parser");
expect(parsed.filters).toEqual({ labels: [["milestone:v2"]] });
expect(parsed.text).toBe("draft:maybe status: parser");
});

it("reads a quoted name holding a comma as the one label it is", () => {
expect(parsePullRequestQuery('label:"needs,triage"').filters.labels).toEqual([
["needs,triage"],
]);
expect(parsePullRequestQuery('-label:"needs,triage"').filters.excludedLabels).toEqual([
"needs,triage",
]);
});

it("cuts a typed query back to what the contract carries", () => {
const many = Array.from({ length: 14 }, (_, index) => `l${index}`).join(",");
const long = "x".repeat(240);
const parsed = parsePullRequestQuery(`label:${many} label:${long} author:${long}`);
expect(parsed.filters.labels?.[0]).toHaveLength(10);
expect(parsed.filters.labels?.[1]).toEqual(["x".repeat(200)]);
expect(parsed.filters.author).toBe("x".repeat(200));
});

it("cuts a namespaced label after its key is prefixed on", () => {
const parsed = parsePullRequestQuery(`size:${"x".repeat(240)}`);
expect(parsed.filters.labels).toEqual([[`size:${"x".repeat(240)}`.slice(0, 200)]]);
});

it("answers an empty query with an empty everything", () => {
expect(parsePullRequestQuery(" ")).toEqual({ text: "", filters: {} });
});
});

describe("resolveProjectScope", () => {
const projects = [{ id: "p1" }, { id: "p2" }];

Expand Down Expand Up @@ -1508,58 +1429,6 @@ describe("the server a saved selection names", () => {
});
});

describe("colon-namespaced labels typed as a search", () => {
it("reads an unknown key as the label it almost always is", () => {
expect(parsePullRequestQuery("size:XXL").filters.labels).toEqual([["size:XXL"]]);
expect(parsePullRequestQuery("vouch:trusted").filters.labels).toEqual([["vouch:trusted"]]);
expect(parsePullRequestQuery("size:XXL").text).toBe("");
});

it("reads the key as the namespace of every bare name in its list", () => {
expect(parsePullRequestQuery("size:S,XS").filters.labels).toEqual([["size:S", "size:XS"]]);
});

it("leaves a name that already carries its own namespace alone", () => {
expect(parsePullRequestQuery("size:S,size:XS").filters.labels).toEqual([["size:S", "size:XS"]]);
});

it("excludes one the same way", () => {
expect(parsePullRequestQuery("-size:XXL").filters.excludedLabels).toEqual(["size:XXL"]);
expect(parsePullRequestQuery("-size:S,XS").filters.excludedLabels).toEqual([
"size:S",
"size:XS",
]);
});

it("keeps a quoted token as text, which is the way back to a literal search", () => {
const parsed = parsePullRequestQuery('"size:XXL"');
expect(parsed.filters.labels).toBeUndefined();
expect(parsed.text).toBe('"size:XXL"');
});

it("reads a hyphenated namespace as a label too", () => {
expect(parsePullRequestQuery("priority-1:high").filters.labels).toEqual([["priority-1:high"]]);
expect(parsePullRequestQuery("priority-1:high").text).toBe("");
});

it("leaves a pasted link alone rather than naming a label after its scheme", () => {
const parsed = parsePullRequestQuery("https://github.com/pingdotgg/t3code/pull/1");
expect(parsed.filters.labels).toBeUndefined();
expect(parsed.text).toBe("https://github.com/pingdotgg/t3code/pull/1");
});

it("mixes with the keys it does know, and with plain words", () => {
const parsed = parsePullRequestQuery("area:web draft:true wizard label:bug");
expect(parsed.filters).toEqual({ labels: [["area:web"], ["bug"]], draft: "only" });
expect(parsed.text).toBe("wizard");
});

it("shares the ceiling with the labels typed as labels", () => {
const many = Array.from({ length: 12 }, (_, index) => `area:${index}`).join(" ");
expect(parsePullRequestQuery(many).filters.labels).toHaveLength(10);
});
});

describe("the priority groups against a paginated feed", () => {
const authoredRow = (number: number, updatedAt: string) =>
entry({ number, updatedAt, author: { login: "Bilal", name: null, avatarUrl: null } });
Expand Down
Loading
Loading