From 63643dd73cd67c601cf2720ff9e97203806718c4 Mon Sep 17 00:00:00 2001 From: whilefoo Date: Sun, 10 Mar 2024 22:19:34 +0100 Subject: [PATCH] feat: supabase, typeguards --- .cspell.json | 2 +- package.json | 4 +- pnpm-lock.yaml | 109 +++- src/adapters/index.ts | 19 + .../supabase/helpers/tables/access.ts | 75 +++ src/adapters/supabase/helpers/tables/label.ts | 74 +++ .../supabase/helpers/tables/locations.ts | 53 ++ src/adapters/supabase/helpers/tables/super.ts | 13 + src/adapters/supabase/helpers/tables/user.ts | 44 ++ src/adapters/supabase/types/database.ts | 589 ++++++++++++++++++ src/adapters/supabase/types/github.ts | 11 + src/handlers/comment.ts | 58 ++ src/handlers/label-change.ts | 17 +- src/handlers/pricing-label.ts | 19 +- src/index.ts | 8 + src/shared/label.ts | 15 +- src/shared/permissions.ts | 13 +- src/types/context.ts | 4 +- src/types/env.ts | 10 + src/types/github.ts | 6 + src/types/typeguards.ts | 27 +- 21 files changed, 1132 insertions(+), 38 deletions(-) create mode 100644 src/adapters/index.ts create mode 100644 src/adapters/supabase/helpers/tables/access.ts create mode 100644 src/adapters/supabase/helpers/tables/label.ts create mode 100644 src/adapters/supabase/helpers/tables/locations.ts create mode 100644 src/adapters/supabase/helpers/tables/super.ts create mode 100644 src/adapters/supabase/helpers/tables/user.ts create mode 100644 src/adapters/supabase/types/database.ts create mode 100644 src/adapters/supabase/types/github.ts create mode 100644 src/handlers/comment.ts create mode 100644 src/types/env.ts diff --git a/.cspell.json b/.cspell.json index dfd6093..25eebdc 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,7 +4,7 @@ "ignorePaths": ["**/*.json", "**/*.css", "node_modules", "**/*.log"], "useGitignore": true, "language": "en", - "words": ["dataurl", "devpool", "outdir", "servedir"], + "words": ["dataurl", "devpool", "outdir", "servedir", "supabase", "typebox"], "dictionaries": ["typescript", "node", "software-terms"], "import": ["@cspell/dict-typescript/cspell-ext.json", "@cspell/dict-node/cspell-ext.json", "@cspell/dict-software-terms"], "ignoreRegExpList": ["[0-9a-fA-F]{6}"] diff --git a/package.json b/package.json index 101c5ff..c09e27f 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,10 @@ "@actions/github": "^6.0.0", "@octokit/rest": "^20.0.2", "@octokit/webhooks": "^13.1.0", + "@sinclair/typebox": "^0.32.15", + "@supabase/supabase-js": "^2.39.7", "@types/ms": "^0.7.34", - "dotenv": "^16.4.4", + "dotenv": "^16.4.5", "ms": "^2.1.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e977af..7a6aa96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,11 +17,17 @@ dependencies: '@octokit/webhooks': specifier: ^13.1.0 version: 13.1.0 + '@sinclair/typebox': + specifier: ^0.32.15 + version: 0.32.15 + '@supabase/supabase-js': + specifier: ^2.39.7 + version: 2.39.7 '@types/ms': specifier: ^0.7.34 version: 0.7.34 dotenv: - specifier: ^16.4.4 + specifier: ^16.4.5 version: 16.4.5 ms: specifier: ^2.1.3 @@ -2256,6 +2262,10 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true + /@sinclair/typebox@0.32.15: + resolution: {integrity: sha512-5Lrwo7VOiWEBJBhHmqNmf3TPB9ll8gcEshvYJyAIJyCZ2PF48MFOtiDHJNj8+FsNcqImaQYmxVkKBCBlyAa/wg==} + dev: false + /@sinonjs/commons@3.0.1: resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} dependencies: @@ -2278,6 +2288,63 @@ packages: p-map: 4.0.0 dev: true + /@supabase/functions-js@2.1.5: + resolution: {integrity: sha512-BNzC5XhCzzCaggJ8s53DP+WeHHGT/NfTsx2wUSSGKR2/ikLFQTBCDzMvGz/PxYMqRko/LwncQtKXGOYp1PkPaw==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: false + + /@supabase/gotrue-js@2.62.2: + resolution: {integrity: sha512-AP6e6W9rQXFTEJ7sTTNYQrNf0LCcnt1hUW+RIgUK+Uh3jbWvcIST7wAlYyNZiMlS9+PYyymWQ+Ykz/rOYSO0+A==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: false + + /@supabase/node-fetch@2.6.15: + resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==} + engines: {node: 4.x || >=6.0.0} + dependencies: + whatwg-url: 5.0.0 + dev: false + + /@supabase/postgrest-js@1.9.2: + resolution: {integrity: sha512-I6yHo8CC9cxhOo6DouDMy9uOfW7hjdsnCxZiaJuIVZm1dBGTFiQPgfMa9zXCamEWzNyWRjZvupAUuX+tqcl5Sw==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: false + + /@supabase/realtime-js@2.9.3: + resolution: {integrity: sha512-lAp50s2n3FhGJFq+wTSXLNIDPw5Y0Wxrgt44eM5nLSA3jZNUUP3Oq2Ccd1CbZdVntPCWLZvJaU//pAd2NE+QnQ==} + dependencies: + '@supabase/node-fetch': 2.6.15 + '@types/phoenix': 1.6.4 + '@types/ws': 8.5.10 + ws: 8.16.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + + /@supabase/storage-js@2.5.5: + resolution: {integrity: sha512-OpLoDRjFwClwc2cjTJZG8XviTiQH4Ik8sCiMK5v7et0MDu2QlXjCAW3ljxJB5+z/KazdMOTnySi+hysxWUPu3w==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: false + + /@supabase/supabase-js@2.39.7: + resolution: {integrity: sha512-1vxsX10Uhc2b+Dv9pRjBjHfqmw2N2h1PyTg9LEfICR3x2xwE24By1MGCjDZuzDKH5OeHCsf4it6K8KRluAAEXA==} + dependencies: + '@supabase/functions-js': 2.1.5 + '@supabase/gotrue-js': 2.62.2 + '@supabase/node-fetch': 2.6.15 + '@supabase/postgrest-js': 1.9.2 + '@supabase/realtime-js': 2.9.3 + '@supabase/storage-js': 2.5.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + /@tsconfig/node10@1.0.9: resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} dev: true @@ -2390,12 +2457,15 @@ packages: resolution: {integrity: sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==} dependencies: undici-types: 5.26.5 - dev: true /@types/normalize-package-data@2.4.4: resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} dev: true + /@types/phoenix@1.6.4: + resolution: {integrity: sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==} + dev: false + /@types/picomatch@2.3.3: resolution: {integrity: sha512-Yll76ZHikRFCyz/pffKGjrCwe/le2CDwOP5F210KQo27kpRE46U2rDnzikNlVn6/ezH3Mhn46bJMTfeVTtcYMg==} dev: true @@ -2436,6 +2506,12 @@ packages: dev: true optional: true + /@types/ws@8.5.10: + resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} + dependencies: + '@types/node': 20.11.24 + dev: false + /@types/yargs-parser@21.0.3: resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} dev: true @@ -7451,6 +7527,10 @@ packages: url-parse: 1.5.10 dev: true + /tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + dev: false + /trim-newlines@3.0.1: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} @@ -7682,7 +7762,6 @@ packages: /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - dev: true /undici@5.28.3: resolution: {integrity: sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==} @@ -7838,6 +7917,17 @@ packages: dev: true optional: true + /webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + dev: false + + /whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + dev: false + /which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} dependencies: @@ -7938,6 +8028,19 @@ packages: signal-exit: 3.0.7 dev: true + /ws@8.16.0: + resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /xdg-basedir@5.1.0: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..c1355be --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,19 @@ +import { SupabaseClient } from "@supabase/supabase-js"; +import { Context } from "../types/context"; +import { Access } from "./supabase/helpers/tables/access"; +import { User } from "./supabase/helpers/tables/user"; +import { Label } from "./supabase/helpers/tables/label"; +import { Locations } from "./supabase/helpers/tables/locations"; +import { Super } from "./supabase/helpers/tables/super"; + +export function createAdapters(supabaseClient: SupabaseClient, context: Context) { + return { + supabase: { + access: new Access(supabaseClient, context), + user: new User(supabaseClient, context), + label: new Label(supabaseClient, context), + locations: new Locations(supabaseClient, context), + super: new Super(supabaseClient, context), + }, + }; +} diff --git a/src/adapters/supabase/helpers/tables/access.ts b/src/adapters/supabase/helpers/tables/access.ts new file mode 100644 index 0000000..b33f5c5 --- /dev/null +++ b/src/adapters/supabase/helpers/tables/access.ts @@ -0,0 +1,75 @@ +import { SupabaseClient } from "@supabase/supabase-js"; +import { Database } from "../../types/database"; +import { GitHubNode } from "../../types/github"; +import { Super } from "./super"; +import { UserRow } from "./user"; +import { Context } from "../../../../types/context"; +import { Comment } from "../../../../types/github"; + +type AccessRow = Database["public"]["Tables"]["access"]["Row"]; +type AccessInsert = Database["public"]["Tables"]["access"]["Insert"]; +type UserWithAccess = UserRow & { access: AccessRow[] }; + +type AccessData = { + user_id: number; + multiplier: number; + multiplier_reason: string; + node_id: string; + node_type: string; + node_url: string; +}; + +export class Access extends Super { + constructor(supabase: SupabaseClient, context: Context) { + super(supabase, context); + } + + private async _getUserWithAccess(id: number): Promise { + const { data, error } = await this.supabase.from("users").select("*, access(*)").filter("id", "eq", id).single(); + + if (error) { + this.context.logger.fatal(error.message, error); + throw new Error(error.message); + } + return data; + } + + public async getAccess(id: number): Promise { + const userWithAccess = await this._getUserWithAccess(id); + if (userWithAccess.access.length === 0) { + this.context.logger.debug("No access found for user", { id }); + return null; + } + return userWithAccess.access[0]; + } + + public async setAccess(labels: string[], node: GitHubNode, userId?: number): Promise { + const { data, error } = await this.supabase.from("access").upsert({ + labels: labels, + ...node, + user_id: userId, + } as AccessInsert); + if (error) throw new Error(error.message); + return data; + } + + async upsertMultiplier(userId: number, multiplier: number, reason: string, comment: Comment) { + try { + const accessData: AccessData = { + user_id: userId, + multiplier: multiplier, + multiplier_reason: reason, + node_id: comment.node_id, + node_type: "IssueComment", + node_url: comment.html_url, + }; + + const { data, error } = await this.supabase.from("access").upsert(accessData, { onConflict: "location_id" }); + + if (error) throw new Error(error.message); + if (!data) throw new Error("Multiplier not upserted"); + } catch (error) { + console.error("An error occurred while upserting multiplier:", error); + } + } +} diff --git a/src/adapters/supabase/helpers/tables/label.ts b/src/adapters/supabase/helpers/tables/label.ts new file mode 100644 index 0000000..ba5fafc --- /dev/null +++ b/src/adapters/supabase/helpers/tables/label.ts @@ -0,0 +1,74 @@ +import { SupabaseClient } from "@supabase/supabase-js"; + +import { Database } from "../../types/database"; +import { Super } from "./super"; +import { Context } from "../../../../types/context"; +import { WebhookEvent } from "../../../../types/github"; + +type LabelRow = Database["public"]["Tables"]["labels"]["Row"]; + +export class Label extends Super { + constructor(supabase: SupabaseClient, context: Context) { + super(supabase, context); + } + + async saveLabelChange({ + previousLabel, + currentLabel, + authorized, + repository, + }: { + previousLabel: string; + currentLabel: string; + authorized: boolean; + repository: WebhookEvent<"issues">["payload"]["repository"]; + }): Promise { + const { data, error } = await this.supabase.from("labels").insert({ + label_from: previousLabel, + label_to: currentLabel, + authorized: authorized, + node_id: repository.node_id, + node_type: "Repository", + node_url: repository.html_url, + }); + + if (error) throw new Error(error.message); + return data; + } + + async getLabelChanges(repositoryNodeId: string) { + const locationId = await this._getRepositoryLocationId(repositoryNodeId); + if (!locationId) { + return null; + } + return await this._getUnauthorizedLabelChanges(locationId); + } + + async approveLabelChange(id: number): Promise { + const { data, error } = await this.supabase.from("labels").update({ authorized: true }).eq("id", id); + if (error) throw new Error(error.message); + return data; + } + + private async _getUnauthorizedLabelChanges(locationId: number): Promise { + // Get label changes that are not authorized in the repository + const { data, error } = await this.supabase.from("labels").select("*").eq("location_id", locationId).eq("authorized", false); + + if (error) throw new Error(error.message); + + return data; + } + + private async _getRepositoryLocationId(nodeId: string) { + // Get the location_id for the repository from the locations table + const { data: locationData, error: locationError } = await this.supabase.from("locations").select("id").eq("node_id", nodeId).maybeSingle(); + + if (locationError) throw new Error(locationError.message); + if (!locationData) { + this.context.logger.error("Repository location ID not found in database."); + return null; + } + + return locationData.id; + } +} diff --git a/src/adapters/supabase/helpers/tables/locations.ts b/src/adapters/supabase/helpers/tables/locations.ts new file mode 100644 index 0000000..a238ffc --- /dev/null +++ b/src/adapters/supabase/helpers/tables/locations.ts @@ -0,0 +1,53 @@ +import { SupabaseClient } from "@supabase/supabase-js"; +import { Super } from "./super"; +import { Context } from "../../../../types/context"; + +// currently trying to save all of the location metadata of the event. +// seems that focusing on the IssueComments will provide the most value + +// type LocationsRow = Database["public"]["Tables"]["logs"]["Row"]; +export class Locations extends Super { + locationResponse: LocationResponse | undefined; + + user_id: string | undefined; + comment_id: string | undefined; + issue_id: string | undefined; + repository_id: string | undefined; + node_id: string | undefined; + node_type: string | undefined; + + constructor(supabase: SupabaseClient, context: Context) { + super(supabase, context); + } + + public async getLocationsFromRepo(repositoryId: number) { + const { data: locationData, error } = await this.supabase.from("locations").select("id").eq("repository_id", repositoryId); + + if (error) throw this.context.logger.fatal("Error getting location data", new Error(error.message)); + return locationData; + } +} + +interface LocationResponse { + data: { + node: { + id: "IC_kwDOH92Z-c5oA5cs"; + author: { + login: "molecula451"; + id: "MDQ6VXNlcjQxNTUyNjYz"; + }; + issue: { + id: "I_kwDOH92Z-c5yRpyq"; + number: 846; + repository: { + id: "R_kgDOH92Z-Q"; + name: "ubiquibot"; + owner: { + id: "MDEyOk9yZ2FuaXphdGlvbjc2NDEyNzE3"; + login: "ubiquity"; + }; + }; + }; + }; + }; +} diff --git a/src/adapters/supabase/helpers/tables/super.ts b/src/adapters/supabase/helpers/tables/super.ts new file mode 100644 index 0000000..662fb69 --- /dev/null +++ b/src/adapters/supabase/helpers/tables/super.ts @@ -0,0 +1,13 @@ +import { SupabaseClient } from "@supabase/supabase-js"; +import { Context } from "../../../../types/context"; +import { Database } from "../../types/database"; + +export class Super { + protected supabase: SupabaseClient; + protected context: Context; + + constructor(supabase: SupabaseClient, context: Context) { + this.supabase = supabase; + this.context = context; + } +} diff --git a/src/adapters/supabase/helpers/tables/user.ts b/src/adapters/supabase/helpers/tables/user.ts new file mode 100644 index 0000000..9aea208 --- /dev/null +++ b/src/adapters/supabase/helpers/tables/user.ts @@ -0,0 +1,44 @@ +import { SupabaseClient } from "@supabase/supabase-js"; +import { Database } from "../../types/database"; +import { Super } from "./super"; +import { Context } from "../../../../types/context"; + +export type UserRow = Database["public"]["Tables"]["users"]["Row"]; +export class User extends Super { + constructor(supabase: SupabaseClient, context: Context) { + super(supabase, context); + } + + public async getUserId(context: Context, username: string): Promise { + const { data } = await context.octokit.rest.users.getByUsername({ username }); + return data.id; + } + + public async getMultiplier(userId: number, repositoryId: number) { + const locationData = await this.context.adapters.supabase.locations.getLocationsFromRepo(repositoryId); + if (locationData && locationData.length > 0) { + const accessData = await this._getAccessData(locationData, userId); + if (accessData) { + return { + value: accessData.multiplier || null, + reason: accessData.multiplier_reason || null, + }; + } + } + return null; + } + + private async _getAccessData(locationData: { id: number }[], userId: number) { + const locationIdsInCurrentRepository = locationData.map((location) => location.id); + + const { data: accessData, error: accessError } = await this.supabase + .from("access") + .select("multiplier, multiplier_reason") + .in("location_id", locationIdsInCurrentRepository) + .eq("user_id", userId) + .order("id", { ascending: false }) // get the latest one + .maybeSingle(); + if (accessError) throw this.context.logger.fatal("Error getting access data", accessError); + return accessData; + } +} diff --git a/src/adapters/supabase/types/database.ts b/src/adapters/supabase/types/database.ts new file mode 100644 index 0000000..f0476b1 --- /dev/null +++ b/src/adapters/supabase/types/database.ts @@ -0,0 +1,589 @@ +type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export interface Database { + public: { + Tables: { + access: { + Row: { + created: string; + id: number; + labels: Json | null; + location_id: number | null; + multiplier: number; + multiplier_reason: string | null; + updated: string | null; + user_id: number; + }; + Insert: { + created?: string; + id?: number; + labels?: Json | null; + location_id?: number | null; + multiplier?: number; + multiplier_reason?: string | null; + updated?: string | null; + user_id: number; + }; + Update: { + created?: string; + id?: number; + labels?: Json | null; + location_id?: number | null; + multiplier?: number; + multiplier_reason?: string | null; + updated?: string | null; + user_id?: number; + }; + Relationships: [ + { + foreignKeyName: "access_user_id_fkey"; + columns: ["user_id"]; + referencedRelation: "users"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "fk_access_location"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + ]; + }; + credits: { + Row: { + amount: number; + created: string; + id: number; + location_id: number | null; + permit_id: number | null; + updated: string | null; + }; + Insert: { + amount: number; + created?: string; + id?: number; + location_id?: number | null; + permit_id?: number | null; + updated?: string | null; + }; + Update: { + amount?: number; + created?: string; + id?: number; + location_id?: number | null; + permit_id?: number | null; + updated?: string | null; + }; + Relationships: [ + { + foreignKeyName: "credits_location_id_fkey"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "credits_permit_id_fkey"; + columns: ["permit_id"]; + referencedRelation: "permits"; + referencedColumns: ["id"]; + }, + ]; + }; + debits: { + Row: { + amount: number; + created: string; + id: number; + location_id: number | null; + token_id: number | null; + updated: string | null; + }; + Insert: { + amount: number; + created?: string; + id?: number; + location_id?: number | null; + token_id?: number | null; + updated?: string | null; + }; + Update: { + amount?: number; + created?: string; + id?: number; + location_id?: number | null; + token_id?: number | null; + updated?: string | null; + }; + Relationships: [ + { + foreignKeyName: "debits_token_id_fkey"; + columns: ["token_id"]; + referencedRelation: "tokens"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "fk_debits_location"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + ]; + }; + labels: { + Row: { + authorized: boolean | null; + created: string; + id: number; + label_from: string | null; + label_to: string | null; + location_id: number | null; + updated: string | null; + }; + Insert: { + authorized?: boolean | null; + created?: string; + id?: number; + label_from?: string | null; + label_to?: string | null; + location_id?: number | null; + updated?: string | null; + }; + Update: { + authorized?: boolean | null; + created?: string; + id?: number; + label_from?: string | null; + label_to?: string | null; + location_id?: number | null; + updated?: string | null; + }; + Relationships: [ + { + foreignKeyName: "labels_location_id_fkey"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + ]; + }; + locations: { + Row: { + comment_id: number | null; + created: string; + id: number; + issue_id: number | null; + node_id: string | null; + node_type: string | null; + node_url: string | null; + organization_id: number | null; + repository_id: number | null; + updated: string | null; + user_id: number | null; + }; + Insert: { + comment_id?: number | null; + created?: string; + id?: number; + issue_id?: number | null; + node_id?: string | null; + node_type?: string | null; + node_url?: string | null; + organization_id?: number | null; + repository_id?: number | null; + updated?: string | null; + user_id?: number | null; + }; + Update: { + comment_id?: number | null; + created?: string; + id?: number; + issue_id?: number | null; + node_id?: string | null; + node_type?: string | null; + node_url?: string | null; + organization_id?: number | null; + repository_id?: number | null; + updated?: string | null; + user_id?: number | null; + }; + Relationships: []; + }; + logs: { + Row: { + created: string; + id: number; + level: string | null; + location_id: number | null; + log: string; + metadata: Json | null; + updated: string | null; + }; + Insert: { + created?: string; + id?: number; + level?: string | null; + location_id?: number | null; + log: string; + metadata?: Json | null; + updated?: string | null; + }; + Update: { + created?: string; + id?: number; + level?: string | null; + location_id?: number | null; + log?: string; + metadata?: Json | null; + updated?: string | null; + }; + Relationships: [ + { + foreignKeyName: "fk_logs_location"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + ]; + }; + partners: { + Row: { + created: string; + id: number; + location_id: number | null; + updated: string | null; + wallet_id: number | null; + }; + Insert: { + created?: string; + id?: number; + location_id?: number | null; + updated?: string | null; + wallet_id?: number | null; + }; + Update: { + created?: string; + id?: number; + location_id?: number | null; + updated?: string | null; + wallet_id?: number | null; + }; + Relationships: [ + { + foreignKeyName: "fk_partners_location"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "partners_wallet_id_fkey"; + columns: ["wallet_id"]; + referencedRelation: "wallets"; + referencedColumns: ["id"]; + }, + ]; + }; + permits: { + Row: { + amount: string; + beneficiary_id: number; + created: string; + deadline: string; + id: number; + location_id: number | null; + nonce: string; + partner_id: number | null; + signature: string; + token_id: number | null; + transaction: string | null; + updated: string | null; + }; + Insert: { + amount: string; + beneficiary_id: number; + created?: string; + deadline: string; + id?: number; + location_id?: number | null; + nonce: string; + partner_id?: number | null; + signature: string; + token_id?: number | null; + transaction?: string | null; + updated?: string | null; + }; + Update: { + amount?: string; + beneficiary_id?: number; + created?: string; + deadline?: string; + id?: number; + location_id?: number | null; + nonce?: string; + partner_id?: number | null; + signature?: string; + token_id?: number | null; + transaction?: string | null; + updated?: string | null; + }; + Relationships: [ + { + foreignKeyName: "fk_permits_location"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "permits_beneficiary_id_fkey"; + columns: ["beneficiary_id"]; + referencedRelation: "users"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "permits_partner_id_fkey"; + columns: ["partner_id"]; + referencedRelation: "partners"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "permits_token_fkey"; + columns: ["token_id"]; + referencedRelation: "tokens"; + referencedColumns: ["id"]; + }, + ]; + }; + settlements: { + Row: { + created: string; + credit_id: number | null; + debit_id: number | null; + id: number; + location_id: number | null; + updated: string | null; + user_id: number; + }; + Insert: { + created?: string; + credit_id?: number | null; + debit_id?: number | null; + id?: number; + location_id?: number | null; + updated?: string | null; + user_id: number; + }; + Update: { + created?: string; + credit_id?: number | null; + debit_id?: number | null; + id?: number; + location_id?: number | null; + updated?: string | null; + user_id?: number; + }; + Relationships: [ + { + foreignKeyName: "fk_settlements_location"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "settlements_credit_id_fkey"; + columns: ["credit_id"]; + referencedRelation: "credits"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "settlements_debit_id_fkey"; + columns: ["debit_id"]; + referencedRelation: "debits"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "settlements_user_id_fkey"; + columns: ["user_id"]; + referencedRelation: "users"; + referencedColumns: ["id"]; + }, + ]; + }; + tokens: { + Row: { + address: string; + created: string; + id: number; + location_id: number | null; + network: number; + updated: string | null; + }; + Insert: { + address: string; + created?: string; + id?: number; + location_id?: number | null; + network?: number; + updated?: string | null; + }; + Update: { + address?: string; + created?: string; + id?: number; + location_id?: number | null; + network?: number; + updated?: string | null; + }; + Relationships: [ + { + foreignKeyName: "tokens_location_id_fkey"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + ]; + }; + users: { + Row: { + created: string; + id: number; + location_id: number | null; + updated: string | null; + wallet_id: number | null; + }; + Insert: { + created?: string; + id?: number; + location_id?: number | null; + updated?: string | null; + wallet_id?: number | null; + }; + Update: { + created?: string; + id?: number; + location_id?: number | null; + updated?: string | null; + wallet_id?: number | null; + }; + Relationships: [ + { + foreignKeyName: "users_location_id_fkey"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "users_wallet_id_fkey"; + columns: ["wallet_id"]; + referencedRelation: "wallets"; + referencedColumns: ["id"]; + }, + ]; + }; + wallets: { + Row: { + address: string | null; + created: string; + id: number; + location_id: number | null; + updated: string | null; + }; + Insert: { + address?: string | null; + created?: string; + id?: number; + location_id?: number | null; + updated?: string | null; + }; + Update: { + address?: string | null; + created?: string; + id?: number; + location_id?: number | null; + updated?: string | null; + }; + Relationships: [ + { + foreignKeyName: "wallets_location_id_fkey"; + columns: ["location_id"]; + referencedRelation: "locations"; + referencedColumns: ["id"]; + }, + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + github_node_type: + | "App" + | "Bot" + | "CheckRun" + | "CheckSuite" + | "ClosedEvent" + | "CodeOfConduct" + | "Commit" + | "CommitComment" + | "CommitContributionsByRepository" + | "ContributingGuidelines" + | "ConvertToDraftEvent" + | "CreatedCommitContribution" + | "CreatedIssueContribution" + | "CreatedPullRequestContribution" + | "CreatedPullRequestReviewContribution" + | "CreatedRepositoryContribution" + | "CrossReferencedEvent" + | "Discussion" + | "DiscussionComment" + | "Enterprise" + | "EnterpriseUserAccount" + | "FundingLink" + | "Gist" + | "Issue" + | "IssueComment" + | "JoinedGitHubContribution" + | "Label" + | "License" + | "Mannequin" + | "MarketplaceCategory" + | "MarketplaceListing" + | "MergeQueue" + | "MergedEvent" + | "MigrationSource" + | "Milestone" + | "Organization" + | "PackageFile" + | "Project" + | "ProjectCard" + | "ProjectColumn" + | "ProjectV2" + | "PullRequest" + | "PullRequestCommit" + | "PullRequestReview" + | "PullRequestReviewComment" + | "ReadyForReviewEvent" + | "Release" + | "ReleaseAsset" + | "Repository" + | "RepositoryContactLink" + | "RepositoryTopic" + | "RestrictedContribution" + | "ReviewDismissedEvent" + | "SecurityAdvisoryReference" + | "SocialAccount" + | "SponsorsListing" + | "Team" + | "TeamDiscussion" + | "TeamDiscussionComment" + | "User" + | "Workflow" + | "WorkflowRun" + | "WorkflowRunFile"; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +} diff --git a/src/adapters/supabase/types/github.ts b/src/adapters/supabase/types/github.ts new file mode 100644 index 0000000..b0a0e62 --- /dev/null +++ b/src/adapters/supabase/types/github.ts @@ -0,0 +1,11 @@ +import { Database } from "./database"; + +export type GitHubNode = { + // will leave support for id and type until more research is completed to confirm that it can be removed + node_id?: string; + node_type?: GitHubNodeType; + // use HTML URL so that administrators can easily audit the location of the node + node_url: string; +}; + +type GitHubNodeType = Database["public"]["Enums"]["github_node_type"]; // Manually searched for every type that supports `url` diff --git a/src/handlers/comment.ts b/src/handlers/comment.ts new file mode 100644 index 0000000..46cc744 --- /dev/null +++ b/src/handlers/comment.ts @@ -0,0 +1,58 @@ +import { isUserAdminOrBillingManager } from "../shared/issue"; +import { Context } from "../types/context"; +import { isCommentEvent } from "../types/typeguards"; + +export async function setLabels(context: Context, body: string) { + const logger = context.logger; + if (!isCommentEvent(context)) { + return logger.debug("Not an comment event"); + } + + const payload = context.payload; + const sender = payload.sender.login; + + const sufficientPrivileges = await isUserAdminOrBillingManager(context, sender); + if (!sufficientPrivileges) return logger.info(`You are not an admin and do not have the required permissions to access this function.`); // if sender is not admin, return + + if (!payload.issue) return context.logger.info(`Skipping '/labels' because of no issue instance`); + + if (body.startsWith("/labels")) { + const { username, labels } = parseComment(body); + const { access, user } = context.adapters.supabase; + const url = payload.comment?.html_url as string; + if (!url) throw new Error("Comment url is undefined"); + + const nodeInfo = { + node_id: payload.comment?.node_id, + node_type: "IssueComment" as const, + node_url: url, + }; + + const userId = await user.getUserId(context, username); + await access.setAccess(labels, nodeInfo, userId); + if (!labels.length) { + return context.logger.info("Successfully cleared access", { username }); + } + return context.logger.info("Successfully set access", { username, labels }); + } else { + throw logger.fatal(`Invalid syntax for allow \n usage: '/labels set-(access type) @user true|false' \n ex-1 /labels set-multiplier @user false`); + } +} + +function parseComment(comment: string): { username: string; labels: string[] } { + // Extract the @username using a regular expression + const usernameMatch = comment.match(/@(\w+)/); + if (!usernameMatch) throw new Error("Username not found in comment"); + const username = usernameMatch[1]; + + // Split the comment into words and filter out the command and the username + const labels = comment.split(/\s+/).filter((word) => word !== "/labels" && !word.startsWith("@")); + // if (!labels.length) throw new Error("No labels found in comment"); + + // no labels means clear access + + return { + username: username, + labels: labels, + }; +} diff --git a/src/handlers/label-change.ts b/src/handlers/label-change.ts index e2eca1c..686998d 100644 --- a/src/handlers/label-change.ts +++ b/src/handlers/label-change.ts @@ -1,14 +1,15 @@ import { isUserAdminOrBillingManager } from "../shared/issue"; import { Context } from "../types/context"; +import { isLabelEditedEvent } from "../types/typeguards"; export async function watchLabelChange(context: Context) { const logger = context.logger; - - const payload = context.payload; - if (!("changes" in payload) || !payload.changes) { - context.logger.debug("Not an issue event"); + if (!isLabelEditedEvent(context)) { + logger.debug("Not a label event"); return; } + + const payload = context.payload; const { label, changes, sender } = payload; const previousLabel = changes?.name?.from; @@ -25,9 +26,7 @@ export async function watchLabelChange(context: Context) { // check if user is authorized to make the change const hasAccess = await hasLabelEditPermission(context, currentLabel, triggerUser); - const { supabase } = Runtime.getState().adapters; - - await supabase.label.saveLabelChange({ + await context.adapters.supabase.label.saveLabelChange({ previousLabel, currentLabel, authorized: hasAccess, @@ -46,8 +45,8 @@ async function hasLabelEditPermission(context: Context, label: string, caller: s if (sufficientPrivileges) { // check permission - const { access, user } = Runtime.getState().adapters.supabase; - const userId = await user.getUserId(context.event, caller); + const { access, user } = context.adapters.supabase; + const userId = await user.getUserId(context, caller); const accessible = await access.getAccess(userId); if (accessible) return true; logger.info("No access to edit label", { caller, label }); diff --git a/src/handlers/pricing-label.ts b/src/handlers/pricing-label.ts index c410f52..3954577 100644 --- a/src/handlers/pricing-label.ts +++ b/src/handlers/pricing-label.ts @@ -6,19 +6,19 @@ import { labelAccessPermissionsCheck } from "../shared/permissions"; import { setPrice } from "../shared/pricing"; import { handleParentIssue, isParentIssue, sortLabelsByValue } from "./handle-parent-issue"; import { AssistivePricingSettings } from "../types/plugin-input"; +import { isIssueLabelEvent } from "../types/typeguards"; export async function onLabelChangeSetPricing(context: Context): Promise { - const config = context.config; - const logger = context.logger; - const payload = context.payload; - if (!("issue" in payload) || !payload.issue) { + if (!isIssueLabelEvent(context)) { context.logger.debug("Not an issue event"); return; } + const config = context.config; + const logger = context.logger; + const payload = context.payload; const labels = payload.issue.labels; if (!labels) throw logger.error(`No labels to calculate price`); - const labelNames = labels.map((i) => i.name); if (payload.issue.body && isParentIssue(payload.issue.body)) { await handleParentIssue(context, labels); @@ -58,7 +58,14 @@ export async function onLabelChangeSetPricing(context: Context): Promise { return; } - const recognizedLabels = getRecognizedLabels(labels, config); + await setPriceLabel(context, labels, config); +} + +async function setPriceLabel(context: Context, issueLabels: Label[], config: AssistivePricingSettings) { + const logger = context.logger; + const labelNames = issueLabels.map((i) => i.name); + + const recognizedLabels = getRecognizedLabels(issueLabels, config); if (!recognizedLabels.time.length || !recognizedLabels.priority.length) { logger.error("No recognized labels to calculate price"); diff --git a/src/index.ts b/src/index.ts index d436efe..194e42d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,18 @@ import * as core from "@actions/core"; import * as github from "@actions/github"; +import { createClient } from "@supabase/supabase-js"; import { Octokit } from "@octokit/rest"; import { PluginInputs } from "./types/plugin-input"; import { Context } from "./types/context"; import { syncPriceLabelsToConfig } from "./handlers/sync-labels-to-config"; import { onLabelChangeSetPricing } from "./handlers/pricing-label"; import { watchLabelChange } from "./handlers/label-change"; +import { Value } from "@sinclair/typebox/value"; +import { envSchema } from "./types/env"; +import { createAdapters } from "./adapters"; async function run() { + const env = Value.Decode(envSchema, process.env); const webhookPayload = github.context.payload.inputs; const inputs: PluginInputs = { stateId: webhookPayload.stateId, @@ -18,6 +23,7 @@ async function run() { ref: webhookPayload.ref, }; const octokit = new Octokit({ auth: inputs.authToken }); + const supabaseClient = createClient(env.SUPABASE_URL, env.SUPABASE_KEY); const context: Context = { eventName: inputs.eventName, @@ -41,7 +47,9 @@ async function run() { console.error(message, ...optionalParams); }, }, + adapters: {} as ReturnType, }; + context.adapters = createAdapters(supabaseClient, context); const eventName = inputs.eventName; switch (eventName) { diff --git a/src/shared/label.ts b/src/shared/label.ts index 33b3882..e1bdc77 100644 --- a/src/shared/label.ts +++ b/src/shared/label.ts @@ -1,5 +1,6 @@ import { Context } from "../types/context"; import { Label, UserType } from "../types/github"; +import { isIssueLabelEvent } from "../types/typeguards"; import { addCommentToIssue, isUserAdminOrBillingManager } from "./issue"; // cspell:disable @@ -37,7 +38,6 @@ export async function createLabel(context: Context, name: string, labelType = "d export async function removeLabel(context: Context, name: string) { const payload = context.payload; if (!("issue" in payload) || !payload.issue) { - context.logger.debug("Not an issue event"); return; } @@ -56,7 +56,6 @@ export async function removeLabel(context: Context, name: string) { export async function clearAllPriceLabelsOnIssue(context: Context) { const payload = context.payload; if (!("issue" in payload) || !payload.issue) { - context.logger.debug("Not an issue event"); return; } @@ -81,7 +80,6 @@ export async function clearAllPriceLabelsOnIssue(context: Context) { export async function addLabelToIssue(context: Context, labelName: string) { const payload = context.payload; if (!("issue" in payload) || !payload.issue) { - context.logger.debug("Not an issue event"); return; } @@ -98,14 +96,13 @@ export async function addLabelToIssue(context: Context, labelName: string) { } export async function labelAccessPermissionsCheck(context: Context) { + if (!isIssueLabelEvent(context)) { + return; + } const { logger, payload } = context; const { publicAccessControl } = context.config; if (!publicAccessControl.setLabel) return true; - if (!("issue" in payload) || !payload.issue) { - context.logger.debug("Not an issue event"); - return; - } if (!payload.label?.name) return; if (payload.sender.type === UserType.Bot) return true; @@ -131,8 +128,8 @@ export async function labelAccessPermissionsCheck(context: Context) { } else { logger.info("Checking access for labels", { repo: repo.full_name, user: sender, labelType }); // check permission - const { access, user } = runtime.adapters.supabase; - const userId = await user.getUserId(context.event, sender); + const { access, user } = context.adapters.supabase; + const userId = await user.getUserId(context, sender); const accessible = await access.getAccess(userId); if (accessible) { return true; diff --git a/src/shared/permissions.ts b/src/shared/permissions.ts index b987c65..8c05507 100644 --- a/src/shared/permissions.ts +++ b/src/shared/permissions.ts @@ -1,17 +1,18 @@ import { Context } from "../types/context"; import { UserType } from "../types/github"; +import { isIssueLabelEvent } from "../types/typeguards"; import { addCommentToIssue, isUserAdminOrBillingManager } from "./issue"; import { addLabelToIssue, removeLabel } from "./label"; export async function labelAccessPermissionsCheck(context: Context) { + if (!isIssueLabelEvent(context)) { + context.logger.debug("Not an issue event"); + return; + } const { logger, payload } = context; const { publicAccessControl } = context.config; if (!publicAccessControl.setLabel) return true; - if (!("issue" in payload) || !payload.issue) { - context.logger.debug("Not an issue event"); - return; - } if (!payload.label?.name) return; if (payload.sender.type === UserType.Bot) return true; @@ -37,8 +38,8 @@ export async function labelAccessPermissionsCheck(context: Context) { } else { logger.info("Checking access for labels", { repo: repo.full_name, user: sender, labelType }); // check permission - const { access, user } = runtime.adapters.supabase; - const userId = await user.getUserId(context.event, sender); + const { access, user } = context.adapters.supabase; + const userId = await user.getUserId(context, sender); const accessible = await access.getAccess(userId); if (accessible) { return true; diff --git a/src/types/context.ts b/src/types/context.ts index 2314bbc..766c099 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -1,13 +1,15 @@ import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks"; import { Octokit } from "@octokit/rest"; import { AssistivePricingSettings } from "./plugin-input"; +import { createAdapters } from "../adapters"; -export type SupportedEvents = "issues.labeled" | "issues.unlabeled" | "label.edited"; +export type SupportedEvents = "issues.labeled" | "issues.unlabeled" | "label.edited" | "issue_comment.created"; export interface Context { eventName: T; payload: WebhookEvent["payload"]; octokit: InstanceType; + adapters: ReturnType; config: AssistivePricingSettings; logger: { fatal: (message: unknown, ...optionalParams: unknown[]) => void; diff --git a/src/types/env.ts b/src/types/env.ts new file mode 100644 index 0000000..f3f2a16 --- /dev/null +++ b/src/types/env.ts @@ -0,0 +1,10 @@ +import { Type as T } from "@sinclair/typebox"; +import { StaticDecode } from "@sinclair/typebox"; +import "dotenv/config"; + +export const envSchema = T.Object({ + SUPABASE_URL: T.String(), + SUPABASE_KEY: T.String(), +}); + +export type Env = StaticDecode; diff --git a/src/types/github.ts b/src/types/github.ts index 943d736..2a2b690 100644 --- a/src/types/github.ts +++ b/src/types/github.ts @@ -1,4 +1,5 @@ import { RestEndpointMethodTypes } from "@octokit/rest"; +import { EmitterWebhookEvent, EmitterWebhookEventName } from "@octokit/webhooks"; export type Label = RestEndpointMethodTypes["issues"]["listLabelsForRepo"]["response"]["data"][0]; @@ -7,3 +8,8 @@ export enum UserType { Bot = "Bot", Organization = "Organization", } + +export type Comment = RestEndpointMethodTypes["issues"]["listComments"]["response"]["data"][0]; +export type Repository = RestEndpointMethodTypes["repos"]["get"]["response"]["data"]; + +export type WebhookEvent = EmitterWebhookEvent; diff --git a/src/types/typeguards.ts b/src/types/typeguards.ts index 0a71135..b93e123 100644 --- a/src/types/typeguards.ts +++ b/src/types/typeguards.ts @@ -1,6 +1,29 @@ -import { RestEndpointMethodTypes } from "@octokit/rest"; import { Context } from "./context"; -export function isIssueEvent(context: Context): context is Context & { issue: RestEndpointMethodTypes["issues"]["list"]["response"]["data"][0] } { +export function isIssueEvent(context: Context): context is Context & { payload: { issue: Context<"issues">["payload"]["issue"] } } { return context.eventName.startsWith("issues."); } + +export function isCommentEvent( + context: Context +): context is Context & { payload: { issue: Context<"issue_comment">["payload"]["issue"]; comment: Context<"issue_comment">["payload"]["comment"] } } { + return context.eventName.startsWith("issue_comment."); +} + +export function isIssueLabelEvent(context: Context): context is Context & { + payload: { + issue: Context<"issues.labeled" | "issues.unlabeled">["payload"]["issue"]; + label: Context<"issues.labeled" | "issues.unlabeled">["payload"]["label"]; + }; +} { + return context.eventName === "issues.labeled" || context.eventName === "issues.unlabeled"; +} + +export function isLabelEditedEvent(context: Context): context is Context & { + payload: { + label: Context<"label.edited">["payload"]["label"]; + changes: Context<"label.edited">["payload"]["changes"]; + }; +} { + return context.eventName === "label.edited"; +}