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
25 changes: 12 additions & 13 deletions apps/api/src/app/api/github/webhook/dispatchMatchingTriggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { dbWs } from "@superset/db/client";
import {
automations,
automationTriggers,
type GithubTriggerConfig,
userIdentities,
} from "@superset/db/schema";
import {
githubEventNames,
githubTriggerMatches,
type MatchableEvent,
triggerMatches,
} from "@superset/shared/automation-matching";
import { Client } from "@upstash/qstash";
import { and, eq } from "drizzle-orm";
Expand Down Expand Up @@ -128,17 +127,17 @@ export async function dispatchMatchingTriggers(params: {
params.ref,
);

const matched = candidates.filter((candidate) => {
const config = candidate.config as GithubTriggerConfig;
if (config.kind !== "github") return false;
return githubTriggerMatches(
config as never,
event,
// Resolved per candidate: two automations can watch the same event on
// behalf of different owners.
{ names, ownerIds: githubIdsByUser.get(candidate.ownerUserId) ?? [] },
).matches;
});
const matched = candidates.filter(
(candidate) =>
triggerMatches(candidate.config, event, {
// Resolved per candidate: two automations can watch the same event
// on behalf of different owners.
github: {
names,
ownerIds: githubIdsByUser.get(candidate.ownerUserId) ?? [],
},
}).matches,
);

if (matched.length === 0) {
return { matched: 0, considered: candidates.length };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { formatDistanceStrict } from "date-fns";
import { useMemo } from "react";
import { useRecentProjects } from "renderer/hooks/host-projects/useRecentProjects";
import type { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { cloudTrpc } from "renderer/lib/cloud-trpc";
import { DevicePicker } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker";
import { ProjectPicker } from "../../../components/ProjectPicker";
import { useProviderOptions } from "../../../components/providers/useProviderOptions";
import { RelayOfflineNotice } from "../../../components/RelayOfflineNotice";
import { TriggersEditor } from "../../../components/TriggersEditor";
import { WorkspacePicker } from "../../../components/WorkspacePicker";
Expand Down Expand Up @@ -43,25 +43,7 @@ export function TriggersCard({
onSaveTriggers,
}: TriggersCardProps) {
const recentProjects = useRecentProjects();
// repoId is GitHub's numeric id, which is what the matcher compares against —
// a full name would stop matching the moment someone renames the repo.
const reposQuery = cloudTrpc.integration.github.listRepositories.useQuery(
{ organizationId: automation.organizationId },
{ enabled: Boolean(automation.organizationId) },
);
const peopleQuery = cloudTrpc.integration.github.listLinkedPeople.useQuery(
{ organizationId: automation.organizationId },
{ enabled: Boolean(automation.organizationId) },
);
const people = useMemo(() => peopleQuery.data ?? [], [peopleQuery.data]);
const repositories = useMemo(
() =>
(reposQuery.data ?? []).map((repo) => ({
id: repo.repoId,
label: repo.fullName,
})),
[reposQuery.data],
);
const options = useProviderOptions(automation.organizationId);
const selectedProject = recentProjects.find(
(p) => p.id === automation.v2ProjectId,
);
Expand Down Expand Up @@ -145,8 +127,7 @@ export function TriggersCard({
config: t.config as DraftTrigger["config"],
}))}
onChange={onSaveTriggers}
repositories={repositories}
people={people}
options={options}
renderNextRun={renderNextRun}
readOnly={readOnly}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
import { Input } from "@superset/ui/input";
import { cn } from "@superset/ui/utils";
import { type ReactNode, useMemo, useRef, useState } from "react";
import { LuClock } from "react-icons/lu";
import {
DAY_OPTIONS,
formatTimeInputValue,
Expand Down Expand Up @@ -94,8 +93,6 @@ export function ScheduleSentence({
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[13px]">
<LuClock className="mr-0.5 size-4 shrink-0 text-muted-foreground" />

<SelectChip
value={state.kind}
disabled={disabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,15 @@ import type {
} from "@superset/shared/automation-triggers";
import { Button } from "@superset/ui/button";
import type { ReactNode } from "react";
import { FaGithub } from "react-icons/fa";
import { LuTrash2, LuWebhook } from "react-icons/lu";
import { ScheduleSentence } from "../ScheduleSentence";
import { LuTrash2 } from "react-icons/lu";
import { type ProviderOptions, providerFor } from "../providers";
import { CHIP_INVALID } from "./chipStyles";
import { ActorChip } from "./components/ActorChip";
import { ScopeChip } from "./components/ScopeChip";
import { TextFilterChip } from "./components/TextFilterChip";
import type { ScopeOption } from "./scopeOption";
import { GITHUB_SENTENCES, type SentencePart } from "./sentence";

interface TriggerSentenceProps {
trigger: DraftTrigger;
onChange: (next: DraftTrigger) => void;
onRemove: () => void;
repositories: ScopeOption[];
people: ScopeOption[];
options: ProviderOptions;
/** This row's problems, already filtered to it by the editor. */
problems?: TriggerProblem[];
/** Trailing "Next run ..." text for a schedule row. */
Expand All @@ -30,143 +23,40 @@ interface TriggerSentenceProps {
/**
* One trigger, rendered as a sentence.
*
* The grammar lives in `sentence.ts` so each event describes its own slots;
* this only walks the parts and renders the matching control.
* This knows nothing about any provider. It finds the one that owns the config
* and hands it the row's state; the provider decides what words and chips the
* sentence is made of. Row chrome — the leading icon and the remove button —
* lives here so every provider's row looks the same.
*/
export function TriggerSentence({
trigger,
onChange,
onRemove,
repositories,
people,
options,
problems,
nextRun,
disabled,
}: TriggerSentenceProps) {
const config = trigger.config;
const provider = providerFor(config);
const Icon = provider.icon;

// A banner naming the row is not enough when a sentence has three chips that
// could each be the empty one.
const invalid = new Set((problems ?? []).map((p) => p.field));
const mark = (field: string) =>
invalid.has(field) ? CHIP_INVALID : undefined;
const set = (patch: Record<string, unknown>) =>
onChange({ ...trigger, config: { ...config, ...patch } as never });

const renderPart = (part: SentencePart, index: number) => {
if ("text" in part) {
return (
<span key={index} className="text-[13px] text-muted-foreground">
{part.text}
</span>
);
}
// The slot list is derived from this event, so the fields it names are
const c = config as unknown as Record<string, never>;
switch (part.slot) {
case "repositories":
return (
<ScopeChip
key={index}
scope={c.repositories}
onChange={(v) => set({ repositories: v })}
className={mark("repositories")}
options={repositories}
emptyLabel="Select repos"
anyLabel="Any repo"
disabled={disabled}
/>
);
case "branches":
return (
<ScopeChip
key={index}
scope={c.branches}
// Clearing an optional filter means "any", not "none": the chip
// says "Any branch" either way, and null would make that a lie.
onChange={(v) => set({ branches: v ?? { mode: "any" } })}
options={[]}
emptyLabel="Any branch"
anyLabel="Any branch"
disabled={disabled}
/>
);
case "labels":
return (
<ScopeChip
key={index}
scope={c.labels}
// Clearing an optional filter means "any", not "none": the chip
// says "Any label" either way, and null would make that a lie.
onChange={(v) => set({ labels: v ?? { mode: "any" } })}
options={[]}
emptyLabel="Any label"
anyLabel="Any label"
disabled={disabled}
/>
);
case "actor":
return (
<ActorChip
key={index}
actor={c.actor}
onChange={(v) => set({ actor: v })}
className={mark("actor")}
people={people}
disabled={disabled}
/>
);
case "subjectAuthor":
return (
<ActorChip
key={index}
actor={c.subjectAuthor}
onChange={(v) => set({ subjectAuthor: v })}
className={mark("subjectAuthor")}
people={people}
disabled={disabled}
/>
);
case "commentFilter":
return (
<TextFilterChip
key={index}
value={c.commentFilter}
onChange={(v) => set({ commentFilter: v })}
emptyLabel="Any comment"
placeholder="Contains this text..."
disabled={disabled}
/>
);
}
};

return (
<div className="group flex min-h-10 flex-wrap items-center gap-1.5 rounded-[8px] px-2 py-1.5 hover:bg-foreground/[0.03]">
{config.kind === "github" && (
<FaGithub className="size-4 shrink-0 text-muted-foreground" />
)}
{config.kind === "webhook" && (
<LuWebhook className="size-4 shrink-0 text-muted-foreground" />
)}

{config.kind === "github" &&
GITHUB_SENTENCES[config.event].map(renderPart)}

{config.kind === "schedule" && (
<ScheduleSentence
rrule={config.rrule}
onRruleChange={(rrule) => set({ rrule })}
timezone={config.timezone}
nextRun={nextRun}
disabled={disabled}
/>
)}
<Icon className="size-4 shrink-0 text-muted-foreground" />

{config.kind === "webhook" && (
<span className="text-muted-foreground text-sm">
Triggered by an incoming webhook
</span>
)}
{provider.renderSentence(config, {
set: (patch) =>
onChange({ ...trigger, config: { ...config, ...patch } as never }),
mark: (field) => (invalid.has(field) ? CHIP_INVALID : undefined),
options,
disabled,
nextRun,
})}

<Button
type="button"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export type { ScopeOption } from "./scopeOption";
export { createGithubConfig, GITHUB_MENU } from "./sentence";
export { TriggerSentence } from "./TriggerSentence";
Loading
Loading