diff --git a/.github/workflows/dashboard-pr-reminder.yml b/.github/workflows/dashboard-pr-reminder.yml index 876b0d6c4abde..76d464d0f4e85 100644 --- a/.github/workflows/dashboard-pr-reminder.yml +++ b/.github/workflows/dashboard-pr-reminder.yml @@ -18,29 +18,26 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + sparse-checkout: | + scripts - - name: Find Dashboard PRs older than 24 hours - id: find-prs - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + name: Install pnpm with: - script: | - const findStalePRs = require('./scripts/actions/find-stale-dashboard-prs.js'); - return await findStalePRs({ github, context, core }); + run_install: false - - name: Send Slack notification - if: fromJSON(steps.find-prs.outputs.count) > 0 - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DASHBOARD_WEBHOOK_URL }} - STALE_PRS_JSON: ${{ steps.find-prs.outputs.stale_prs }} + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - script: | - const sendSlackNotification = require('./scripts/actions/send-slack-pr-notification.js'); - const stalePRs = JSON.parse(process.env.STALE_PRS_JSON); - const webhookUrl = process.env.SLACK_WEBHOOK_URL; - await sendSlackNotification(stalePRs, webhookUrl); + node-version-file: '.nvmrc' + cache: 'pnpm' - - name: No stale PRs found - if: fromJSON(steps.find-prs.outputs.count) == 0 - run: | - echo "✓ No Dashboard PRs older than 24 hours found" + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Find stale Dashboard PRs and notify Slack + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DASHBOARD_WEBHOOK_URL }} + run: pnpm tsx scripts/actions/find-stale-dashboard-prs.ts | pnpm tsx scripts/actions/send-slack-pr-notification.ts diff --git a/apps/design-system/package.json b/apps/design-system/package.json index aa0c8da105770..4694e992bfd59 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -31,7 +31,7 @@ "next-contentlayer2": "0.4.6", "next-themes": "^0.3.0", "react": "catalog:", - "react-data-grid": "7.0.0-beta.41", + "react-data-grid": "7.0.0-beta.47", "react-day-picker": "^9.11.1", "react-dom": "catalog:", "react-hook-form": "^7.45.0", diff --git a/apps/design-system/registry/default/example/data-grid-demo.tsx b/apps/design-system/registry/default/example/data-grid-demo.tsx index 461d63e540654..66af73297fa7c 100644 --- a/apps/design-system/registry/default/example/data-grid-demo.tsx +++ b/apps/design-system/registry/default/example/data-grid-demo.tsx @@ -33,7 +33,7 @@ export default function DataGridDemo() { headerCellClass: 'border-default border-r border-b', renderCell: ({ row }) => { // eslint-disable-next-line react-hooks/rules-of-hooks - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useRowSelection() return (
@@ -43,7 +43,6 @@ export default function DataGridDemo() { e.stopPropagation() onRowSelectionChange({ row, - type: 'ROW', checked: !isRowSelected, isShiftClick: e.shiftKey, }) diff --git a/apps/studio/components/grid/components/grid/AddColumn.tsx b/apps/studio/components/grid/components/grid/AddColumn.tsx index 5cf1478412e5b..69c0a4dbef8fc 100644 --- a/apps/studio/components/grid/components/grid/AddColumn.tsx +++ b/apps/studio/components/grid/components/grid/AddColumn.tsx @@ -1,10 +1,10 @@ import { Plus } from 'lucide-react' import type { CalculatedColumn } from 'react-data-grid' - -import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button } from 'ui' + import { ADD_COLUMN_KEY } from '../../constants' import { DefaultFormatter } from '../formatter/DefaultFormatter' +import { useTableEditorStateSnapshot } from '@/state/table-editor' export const AddColumn: CalculatedColumn = { key: ADD_COLUMN_KEY, @@ -15,7 +15,6 @@ export const AddColumn: CalculatedColumn = { resizable: false, sortable: false, frozen: false, - isLastFrozenColumn: false, renderHeaderCell() { return }, diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 2079e3692ea28..e15bca7e4743f 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -203,7 +203,7 @@ export const Grid = memo( // Compute rowClass function to style pending add/delete rows const computedRowClass = useMemo(() => { return (row: SupaRow) => { - const classes: string[] = [] + const classes: string[] = ['[&>.rdg-cell]:flex', '[&>.rdg-cell]:items-center'] // Call the original rowClass if provided if (rowClass) { diff --git a/apps/studio/components/grid/components/grid/GridError.tsx b/apps/studio/components/grid/components/grid/GridError.tsx index a44ca11a8f675..fa8def1d3710a 100644 --- a/apps/studio/components/grid/components/grid/GridError.tsx +++ b/apps/studio/components/grid/components/grid/GridError.tsx @@ -1,14 +1,18 @@ import { useParams } from 'common' import { useTableFilter } from 'components/grid/hooks/useTableFilter' +import { useTableFilterNew } from 'components/grid/hooks/useTableFilterNew' import { useTableSort } from 'components/grid/hooks/useTableSort' import AlertError from 'components/ui/AlertError' import { InlineLink } from 'components/ui/InlineLink' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useCallback } from 'react' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Button } from 'ui' import { Admonition } from 'ui-patterns' +import { isFilterRelatedError } from './GridError.utils' +import { useIsTableFilterBarEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { HighCostError } from '@/components/ui/HighQueryCost' import { COST_THRESHOLD_ERROR } from '@/data/sql/execute-sql-query' import { useTableEditorStateSnapshot } from '@/state/table-editor' @@ -18,12 +22,22 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { const { id: _id } = useParams() const tableId = _id ? Number(_id) : undefined - const { filters } = useTableFilter() + const newFilterBarEnabled = useIsTableFilterBarEnabled() + const { filters: oldFilters, clearFilters: clearOldFilters } = useTableFilter() + const { filters: newFilters, clearFilters: clearNewFilters } = useTableFilterNew() const { sorts } = useTableSort() const snap = useTableEditorTableStateSnapshot() const tableEditorSnap = useTableEditorStateSnapshot() + const removeAllFilters = useCallback(() => { + if (newFilterBarEnabled) { + clearNewFilters() + } else { + clearOldFilters() + } + }, [clearOldFilters, clearNewFilters, newFilterBarEnabled]) + if (!error) return null const tableEntityType = snap.originalTable?.entity_type @@ -32,8 +46,9 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { const isForeignTableMissingVaultKeyError = isForeignTable && error?.message?.includes('query vault failed') - const isInvalidSyntaxError = - filters.length > 0 && error?.message?.includes('invalid input syntax') + const hasActiveFilters = oldFilters.length > 0 || newFilters.length > 0 + + const hasFilterRelatedError = hasActiveFilters && isFilterRelatedError(error?.message) const isInvalidOrderingOperatorError = sorts.length > 0 && error?.message?.includes('identify an ordering operator') @@ -55,8 +70,8 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { ) } else if (isForeignTableMissingVaultKeyError) { return - } else if (isInvalidSyntaxError) { - return + } else if (hasFilterRelatedError) { + return } else if (isInvalidOrderingOperatorError) { return } @@ -94,27 +109,18 @@ const ForeignTableMissingVaultKeyError = () => { ) } -const InvalidSyntaxError = ({ error }: { error: ResponseError }) => { - const { onApplyFilters } = useTableFilter() - +const FilterError = ({ removeAllFilters }: { removeAllFilters: () => void }) => { return ( -

- Unable to retrieve results as the provided value in your filter(s) doesn't match it's column - data type. +

+ One or more of your filters may have a value or operator that doesn't match the column's + data type. Try updating or removing the filter.

-

- Verify that your filter values are correct before applying the filters again. -

-

- Error: {error.message} -

- -
diff --git a/apps/studio/components/grid/components/grid/GridError.utils.test.ts b/apps/studio/components/grid/components/grid/GridError.utils.test.ts new file mode 100644 index 0000000000000..5a1095dd78267 --- /dev/null +++ b/apps/studio/components/grid/components/grid/GridError.utils.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'vitest' + +import { isFilterRelatedError } from './GridError.utils' + +describe('isFilterRelatedError', () => { + test('returns false for null or undefined', () => { + expect(isFilterRelatedError(null)).toBe(false) + expect(isFilterRelatedError(undefined)).toBe(false) + }) + + test('returns false for empty string', () => { + expect(isFilterRelatedError('')).toBe(false) + }) + + test('returns false for unrelated error messages', () => { + expect(isFilterRelatedError('connection refused')).toBe(false) + expect(isFilterRelatedError('permission denied for table users')).toBe(false) + expect(isFilterRelatedError('relation "users" does not exist')).toBe(false) + expect(isFilterRelatedError('Query cost exceeds threshold')).toBe(false) + }) + + test('detects invalid input syntax errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22P02: invalid input syntax for type inet: "192.168.3"' + ) + ).toBe(true) + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22P02: invalid input syntax for type integer: "abc"' + ) + ).toBe(true) + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22P02: invalid input syntax for type uuid: "not-a-uuid"' + ) + ).toBe(true) + }) + + test('detects operator does not exist errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 42883: operator does not exist: text > integer' + ) + ).toBe(true) + }) + + test('detects collation errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: could not determine which collation to use for string comparison' + ) + ).toBe(true) + }) + + test('detects invalid enum value errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22P02: invalid input value for enum status: "badvalue"' + ) + ).toBe(true) + }) + + test('detects malformed array literal errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22P02: malformed array literal: "not-an-array"' + ) + ).toBe(true) + }) + + test('detects invalid byte sequence errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22021: invalid byte sequence for encoding "UTF8"' + ) + ).toBe(true) + }) + + test('detects syntax errors from invalid IS operator values', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 42601: syntax error at or near "sdfsdf"' + ) + ).toBe(true) + }) +}) diff --git a/apps/studio/components/grid/components/grid/GridError.utils.ts b/apps/studio/components/grid/components/grid/GridError.utils.ts new file mode 100644 index 0000000000000..cc1331c74c9f2 --- /dev/null +++ b/apps/studio/components/grid/components/grid/GridError.utils.ts @@ -0,0 +1,14 @@ +const FILTER_ERROR_PATTERNS = [ + 'invalid input syntax', + 'operator does not exist', + 'could not determine which collation', + 'invalid input value for enum', + 'malformed array literal', + 'invalid byte sequence', + 'syntax error', +] + +export function isFilterRelatedError(errorMessage: string | undefined | null): boolean { + if (!errorMessage) return false + return FILTER_ERROR_PATTERNS.some((pattern) => errorMessage.includes(pattern)) +} diff --git a/apps/studio/components/grid/components/grid/SelectColumn.tsx b/apps/studio/components/grid/components/grid/SelectColumn.tsx index 0e07057b9217f..8596cc65772d6 100644 --- a/apps/studio/components/grid/components/grid/SelectColumn.tsx +++ b/apps/studio/components/grid/components/grid/SelectColumn.tsx @@ -5,14 +5,15 @@ import { RenderCellProps, RenderGroupCellProps, RenderHeaderCellProps, + useHeaderRowSelection, useRowSelection, } from 'react-data-grid' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useTableEditorStateSnapshot } from 'state/table-editor' -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { SELECT_COLUMN_KEY } from '../../constants' import type { SupaRow } from '../../types' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useTableEditorStateSnapshot } from '@/state/table-editor' +import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' export const SelectColumn: CalculatedColumn = { key: SELECT_COLUMN_KEY, @@ -23,25 +24,24 @@ export const SelectColumn: CalculatedColumn = { resizable: false, sortable: false, frozen: true, - isLastFrozenColumn: false, renderHeaderCell: (props: RenderHeaderCellProps) => { // [Joshen] formatter is actually a valid React component, so we can use hooks here // eslint-disable-next-line react-hooks/rules-of-hooks - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useHeaderRowSelection() return ( onRowSelectionChange({ type: 'HEADER', checked })} + onChange={(checked) => onRowSelectionChange({ checked })} /> ) }, renderCell: (props: RenderCellProps) => { // [Alaister] formatter is actually a valid React component, so we can use hooks here // eslint-disable-next-line react-hooks/rules-of-hooks - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useRowSelection() return ( = { value={isRowSelected} row={props.row} onChange={(checked, isShiftClick) => { - onRowSelectionChange({ type: 'ROW', row: props.row, checked, isShiftClick }) + onRowSelectionChange({ row: props.row, checked, isShiftClick }) }} // Stop propagation to prevent row selection onClick={stopPropagation} @@ -59,7 +59,7 @@ export const SelectColumn: CalculatedColumn = { renderGroupCell: (props: RenderGroupCellProps) => { // [Alaister] groupFormatter is actually a valid React component, so we can use hooks here // eslint-disable-next-line react-hooks/rules-of-hooks - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useRowSelection() return ( = { value={isRowSelected} onChange={(checked) => { onRowSelectionChange({ - type: 'ROW', row: props.row, checked, isShiftClick: false, diff --git a/apps/studio/components/grid/utils/gridColumns.tsx b/apps/studio/components/grid/utils/gridColumns.tsx index 064f055ec3b88..54d50cdc72bf1 100644 --- a/apps/studio/components/grid/utils/gridColumns.tsx +++ b/apps/studio/components/grid/utils/gridColumns.tsx @@ -1,4 +1,3 @@ -import { COLUMN_MIN_WIDTH } from 'components/grid/constants' import { CalculatedColumn, RenderCellProps } from 'react-data-grid' import { DefaultValue } from '../components/common/DefaultValue' @@ -39,6 +38,7 @@ import { isTextColumn, isTimeColumn, } from './types' +import { COLUMN_MIN_WIDTH } from '@/components/grid/constants' export const ESTIMATED_CHARACTER_PIXEL_WIDTH = 9 @@ -73,7 +73,6 @@ export function getGridColumns( width: columnWidth, minWidth: COLUMN_MIN_WIDTH, frozen: false, - isLastFrozenColumn: false, renderHeaderCell: (props) => ( { // This is actually a valid React component, so we can use hooks here // eslint-disable-next-line react-hooks/rules-of-hooks - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useRowSelection() const value = row?.[col.id] const user = users?.find((u) => u.id === row.id) @@ -329,7 +329,6 @@ export const formatUserColumns = ({ e.stopPropagation() onRowSelectionChange({ row, - type: 'ROW', checked: !isRowSelected, isShiftClick: e.shiftKey, }) diff --git a/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx b/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx index 13a085c76ffca..67d4b04fb272d 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx @@ -1,6 +1,6 @@ import { ChevronDown, SortAsc, SortDesc } from 'lucide-react' import { useEffect, useRef, useState } from 'react' -import { useRowSelection } from 'react-data-grid' +import { useHeaderRowSelection } from 'react-data-grid' import { Button, DropdownMenu, @@ -18,7 +18,7 @@ export const SelectHeaderCell = ({ allRowsSelected: boolean }) => { const inputRef = useRef(null) - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useHeaderRowSelection() const isIndeterminate = selectedUsers.size > 0 && !allRowsSelected @@ -36,7 +36,7 @@ export const SelectHeaderCell = ({ className="sb-grid-select-cell__header__input" disabled={false} checked={isRowSelected} - onChange={(e) => onRowSelectionChange({ type: 'HEADER', checked: e.target.checked })} + onChange={(e) => onRowSelectionChange({ checked: e.target.checked })} />
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx new file mode 100644 index 0000000000000..04456c2dc279f --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx @@ -0,0 +1,139 @@ +import { useParams } from 'common' +import { LOGS_TABLES } from 'components/interfaces/Settings/Logs/Logs.constants' +import useLogsPreview from 'hooks/analytics/useLogsPreview' +import { Clock, ExternalLink, RefreshCw } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { Button, cn } from 'ui' +import { Admonition, TimestampInfo } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { parseEdgeFunctionEventMessage } from './EdgeFunctionRecentInvocations.utils' + +interface EdgeFunctionRecentInvocationsProps { + functionId: string + functionSlug: string +} + +export const EdgeFunctionRecentInvocations = ({ + functionId, + functionSlug, +}: EdgeFunctionRecentInvocationsProps) => { + const { ref } = useParams() + const router = useRouter() + + const { logData, isLoading, isSuccess, refresh } = useLogsPreview({ + projectRef: ref as string, + table: LOGS_TABLES.fn_edge, + filterOverride: { function_id: functionId }, + limit: 10, + }) + + return ( +
+
+
+

Recent Invocations

+

+ Latest invocation requests for this function +

+
+ +
+ + {isLoading && !isSuccess ? ( + + ) : logData.length === 0 ? ( + + ) : ( +
+ {logData.map((log) => { + const statusCode = String(log.status_code ?? '') + const method = String(log.method ?? '') + const executionTime = log.execution_time_ms + const is2xx = statusCode.startsWith('2') + const is4xx = statusCode.startsWith('4') + const is5xx = statusCode.startsWith('5') + const logUrl = `/project/${ref}/functions/${functionSlug}/invocations?log=${log.id}` + + return ( +
router.push(logUrl)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + router.push(logUrl) + } + }} + className="group flex items-center font-mono px-3 py-2 gap-3 bg-surface-100 cursor-pointer hover:bg-surface-200 transition-colors" + > + + + +
+ {statusCode ? ( +
+ {statusCode} +
+ ) : ( + - + )} +
+ {method || '-'} + {executionTime !== undefined && ( + + + {Number(executionTime).toFixed(0)}ms + + )} + + {parseEdgeFunctionEventMessage( + String(log.event_message ?? ''), + method, + statusCode + )} + + +
+ ) + })} + + View all invocations + +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts new file mode 100644 index 0000000000000..e5809e2517b85 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { parseEdgeFunctionEventMessage } from './EdgeFunctionRecentInvocations.utils' + +describe('parseEdgeFunctionEventMessage', () => { + it('should strip method and status code when they match the structured fields', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe( + 'https://example.supabase.red/functions/v1/hello-world' + ) + }) + + it('should strip method and status code for different HTTP methods', () => { + const message = 'GET | 404 | https://example.supabase.red/functions/v1/not-found' + expect(parseEdgeFunctionEventMessage(message, 'GET', '404')).toBe( + 'https://example.supabase.red/functions/v1/not-found' + ) + }) + + it('should strip method and status code for 500 errors', () => { + const message = 'POST | 500 | https://example.supabase.red/functions/v1/broken' + expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe( + 'https://example.supabase.red/functions/v1/broken' + ) + }) + + it('should preserve the rest of the message when it contains pipe separators', () => { + const message = 'POST | 200 | some | complex | message' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe('some | complex | message') + }) + + it('should return original message when method does not match', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'GET', '200')).toBe(message) + }) + + it('should return original message when status code does not match', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe(message) + }) + + it('should return original message when method is undefined', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, undefined, '200')).toBe(message) + }) + + it('should return original message when status code is undefined', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', undefined)).toBe(message) + }) + + it('should return original message when both method and status code are undefined', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, undefined, undefined)).toBe(message) + }) + + it('should return original message when format has fewer than 3 parts', () => { + const message = 'some simple message' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe(message) + }) + + it('should return original message when format has only 2 pipe-separated parts', () => { + const message = 'POST | 200' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe(message) + }) + + it('should return empty string for empty input', () => { + expect(parseEdgeFunctionEventMessage('', 'POST', '200')).toBe('') + }) + + it('should handle whitespace around parts correctly', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe( + 'https://example.supabase.red/functions/v1/hello-world' + ) + }) + + it('should return original message when the message does not follow the expected format', () => { + const message = 'Error: function crashed unexpectedly' + expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe(message) + }) + + it('should handle messages with extra whitespace in the URL portion', () => { + const message = 'DELETE | 204 | ' + expect(parseEdgeFunctionEventMessage(message, 'DELETE', '204')).toBe('') + }) +}) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts new file mode 100644 index 0000000000000..37145dc578707 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts @@ -0,0 +1,37 @@ +/** + * Parses an edge function invocation event_message to extract the meaningful + * content, stripping out the method and status code that are already displayed + * as structured fields. + * + * The event_message typically follows the format: + * "{METHOD} | {STATUS_CODE} | {URL_OR_DETAIL}" + * + * e.g. "POST | 200 | https://example.supabase.red/functions/v1/hello-world" + * + * When the structured method and status_code fields match what's in the message, + * we strip them out to avoid duplication. If parsing fails or the message doesn't + * match the expected format, we return the original message as-is. + */ +export function parseEdgeFunctionEventMessage( + eventMessage: string, + method?: string, + statusCode?: string +): string { + if (!eventMessage) return eventMessage + + const parts = eventMessage.split(' | ') + + if (parts.length < 3) return eventMessage + + const messageMethod = parts[0].trim() + const messageStatus = parts[1].trim() + + const methodMatches = method !== undefined && messageMethod === method + const statusMatches = statusCode !== undefined && messageStatus === statusCode + + if (methodMatches && statusMatches) { + return parts.slice(2).join(' | ').trim() + } + + return eventMessage +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts index dfe96116478f2..158a9c18c1ba2 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts @@ -41,6 +41,16 @@ describe('parseCronJobCommand', () => { }) }) + it('should return a sql function command when the function name contains an underscore', () => { + const command = 'SELECT random_schema.function_1()' + expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ + type: 'sql_function', + schema: 'random_schema', + functionName: 'function_1', + snippet: command, + }) + }) + it('should return a sql snippet command when the command is SELECT public.test_fn(1, 2)', () => { const command = 'SELECT public.test_fn(1, 2)' expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ @@ -49,6 +59,30 @@ describe('parseCronJobCommand', () => { }) }) + it('should return a sql snippet command when the command is using a SQL function from the search path', () => { + const command = 'SELECT test_cron_function()' + expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ + type: 'sql_snippet', + snippet: command, + }) + }) + + it('should return a sql snippet command when the command is SELECT .()', () => { + const command = 'SELECT .()' + expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ + type: 'sql_snippet', + snippet: command, + }) + }) + + it('should return a sql snippet command when the command is SELECT schema.()', () => { + const command = 'SELECT schema.()' + expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ + type: 'sql_snippet', + snippet: command, + }) + }) + it('should return a edge function config when the command posts to its own supabase.co project', () => { const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_', headers:=jsonb_build_object('Authorization', 'Bearer something'), body:='', timeout_milliseconds:=5000 );` expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx index 2f726f8050aa5..75ef9acf94edc 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx @@ -127,7 +127,7 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string) } } - const regexDBFunction = /select\s+[a-zA-Z-_]*\.?[a-zA-Z-_]*\s*\(\)/g + const regexDBFunction = /select\s+[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\s*\(\)/g if (command.toLocaleLowerCase().match(regexDBFunction)) { const [schemaName, functionName] = command .replace('SELECT ', '') diff --git a/apps/studio/components/ui/APIDocsButton.tsx b/apps/studio/components/ui/APIDocsButton.tsx index dc1d3fc072ee9..2f3291b58b6cd 100644 --- a/apps/studio/components/ui/APIDocsButton.tsx +++ b/apps/studio/components/ui/APIDocsButton.tsx @@ -1,8 +1,9 @@ -import { BookOpenText } from 'lucide-react' import { useParams } from 'common' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { BookOpenText } from 'lucide-react' import { useAppStateSnapshot } from 'state/app-state' + import { ButtonTooltip } from './ButtonTooltip' interface APIDocsButtonProps { @@ -36,7 +37,7 @@ export const APIDocsButton = ({ section, source }: APIDocsButtonProps) => { }) }} icon={} - className="h-7 w-7" + className="w-7" tooltip={{ content: { side: 'bottom', diff --git a/apps/studio/package.json b/apps/studio/package.json index c0c422e830d66..e95f81597a0db 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -114,7 +114,7 @@ "react": "catalog:", "react-beautiful-dnd": "^13.1.0", "react-contexify": "^5.0.0", - "react-data-grid": "7.0.0-beta.41", + "react-data-grid": "7.0.0-beta.47", "react-day-picker": "^9.11.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index 7c7b05294bdb6..b2708a941938a 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { IS_PLATFORM, useParams } from 'common' -import { useFlag } from 'common' +import { IS_PLATFORM, useFlag, useParams } from 'common' +import { EdgeFunctionRecentInvocations } from 'components/interfaces/Functions/EdgeFunctionRecentInvocations' import ReportWidget from 'components/interfaces/Reports/ReportWidget' import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' @@ -22,9 +22,9 @@ import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import type { ChartIntervals, NextPageWithLayout } from 'types' import { + Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, - Alert_Shadcn_, Button, WarningIcon, } from 'ui' @@ -138,6 +138,14 @@ const PageLayout: NextPageWithLayout = () => { + {IS_PLATFORM && id && ( +
+ +
+ )}
{CHART_INTERVALS.map((item, i) => { diff --git a/e2e/studio/features/filter-bar.spec.ts b/e2e/studio/features/filter-bar.spec.ts index 09fc74545ae6f..86597c9e758c3 100644 --- a/e2e/studio/features/filter-bar.spec.ts +++ b/e2e/studio/features/filter-bar.spec.ts @@ -13,6 +13,7 @@ import { } from '../utils/filter-bar-helpers.js' import { test } from '../utils/test.js' import { toUrl } from '../utils/to-url.js' +import { createApiResponseWaiter } from '../utils/wait-for-response.js' const tableNamePrefix = 'pw_filter_bar' @@ -811,4 +812,50 @@ test.describe('Filter Bar', () => { } }) }) + + test.describe('Filter Error Feedback', () => { + test('invalid filter value shows friendly error and remove button clears filters', async ({ + page, + ref, + }) => { + const tableName = `${tableNamePrefix}_err_fb` + + await createTable(tableName, 'name', [{ name: 'Alice' }, { name: 'Bob' }]) + + try { + await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`)) + await navigateToTable(page, ref, tableName) + + // Apply a filter using the 'is' operator with an invalid value + // 'is' only accepts null/not null/true/false, so 'badvalue' triggers a syntax error + await selectColumnFilter(page, 'name') + await selectOperatorByClick(page, 'name', 'is') + + const valueInput = page.getByTestId('filter-value-name') + const rowsWaiter = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-rows-') + await valueInput.fill('badvalue') + await page.keyboard.press('Enter') + await rowsWaiter + + // Should show the friendly filter error, not the scary general error + await expect(page.getByText('No results found — check your filter values')).toBeVisible({ + timeout: 10000, + }) + await expect(page.getByText("doesn't match the column's data type")).toBeVisible() + + const removeButton = page.getByRole('button', { name: 'Remove filters' }) + await expect(removeButton).toBeVisible() + + // Clicking "Remove filters" should clear filters and restore data + await removeButton.click() + + // Filter pill should be gone and data should be visible again + await expect(page.getByTestId('filter-condition-name')).not.toBeVisible({ timeout: 10000 }) + await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible({ timeout: 10000 }) + await expect(page.getByRole('gridcell', { name: 'Bob' })).toBeVisible() + } finally { + await dropTable(tableName) + } + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 748b1f7feec2a..26d2bb2a87119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,8 +171,8 @@ importers: specifier: 'catalog:' version: 18.3.1 react-data-grid: - specifier: 7.0.0-beta.41 - version: 7.0.0-beta.41(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 7.0.0-beta.47 + version: 7.0.0-beta.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-day-picker: specifier: ^9.11.1 version: 9.11.1(react@18.3.1) @@ -1002,8 +1002,8 @@ importers: specifier: ^5.0.0 version: 5.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-data-grid: - specifier: 7.0.0-beta.41 - version: 7.0.0-beta.41(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 7.0.0-beta.47 + version: 7.0.0-beta.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-day-picker: specifier: ^9.11.1 version: 9.11.1(react@18.3.1) @@ -15800,11 +15800,11 @@ packages: react: '>= 15' react-dom: '>= 15' - react-data-grid@7.0.0-beta.41: - resolution: {integrity: sha512-WmTP/PV+vtVjIaGVLgyG6WAhqvuPBM8I54bsR7oJZl6w43+mIasZM9rEBWjQ52XHJEy41/tjcMBIMNiWqoEbrQ==} + react-data-grid@7.0.0-beta.47: + resolution: {integrity: sha512-28kjsmwQGD/9RXYC50zn5Zv/SQMhBBoSvG5seq0fM8XXi9TZ0zr9Z5T3YJqLwcEtoNzTOq3y0njkmdujGkIwQQ==} peerDependencies: - react: ^18.0 - react-dom: ^18.0 + react: ^18.0 || ^19.0 + react-dom: ^18.0 || ^19.0 react-day-picker@9.11.1: resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==} @@ -34816,7 +34816,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-data-grid@7.0.0-beta.41(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-data-grid@7.0.0-beta.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: clsx: 2.1.1 react: 18.3.1 diff --git a/scripts/actions/find-stale-dashboard-prs.js b/scripts/actions/find-stale-dashboard-prs.js deleted file mode 100644 index e772025275ac8..0000000000000 --- a/scripts/actions/find-stale-dashboard-prs.js +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Finds stale Dashboard PRs (older than 24 hours) and fetches their status - * including review status and mergeable state. - * - * @param {Object} github - GitHub API client from actions/github-script - * @param {Object} context - GitHub Actions context - * @param {Object} core - GitHub Actions core utilities - * @returns {Array} Array of stale PRs with status information - */ -module.exports = async ({ github, context, core }) => { - const TWENTY_FOUR_HOURS_AGO = new Date(Date.now() - 24 * 60 * 60 * 1000) - const DASHBOARD_PATH = 'apps/studio/' - - console.log(`Looking for PRs older than: ${TWENTY_FOUR_HOURS_AGO.toISOString()}`) - - const stalePRs = [] - let page = 1 - let hasMore = true - - // Fetch PRs page by page, newest first - while (hasMore && page <= 10) { - // Limit to 10 pages (1000 PRs) as safety measure - console.log(`Fetching page ${page}...`) - - const { data: prs } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - sort: 'created', - direction: 'desc', - per_page: 100, - page: page, - }) - - if (prs.length === 0) { - hasMore = false - break - } - - // Check each PR - for (const pr of prs) { - // Skip PRs from forks - only check internal PRs - if (pr.head.repo && pr.head.repo.full_name !== context.repo.owner + '/' + context.repo.repo) { - console.log(`PR #${pr.number} is from a fork, skipping...`) - continue - } - - // Skip dependabot PRs - if (pr.user.login === 'dependabot[bot]' || pr.user.login === 'dependabot') { - console.log(`PR #${pr.number} is from dependabot, skipping...`) - continue - } - - // Skip draft PRs - if (pr.draft) { - console.log(`PR #${pr.number} is a draft, skipping...`) - continue - } - - const createdAt = new Date(pr.created_at) - - // If this PR is newer than 24 hours, skip it - if (createdAt > TWENTY_FOUR_HOURS_AGO) { - console.log(`PR #${pr.number} is too new, skipping...`) - continue - } - - console.log(`Checking PR #${pr.number}: ${pr.title}`) - - // Fetch files changed in this PR - const { data: files } = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - per_page: 100, - }) - - // Check if any file is under apps/studio/ - const touchesDashboard = files.some((file) => file.filename.startsWith(DASHBOARD_PATH)) - - if (touchesDashboard) { - const hoursOld = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60)) - const daysOld = Math.floor(hoursOld / 24) - - // Fetch review status - let reviewStatus = 'no-reviews' - let reviewEmoji = ':eyes:' - try { - const { data: reviews } = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - per_page: 100, - }) - - if (reviews.length === 0) { - reviewStatus = 'no-reviews' - reviewEmoji = ':eyes:' - } else { - // Get the most recent review from each reviewer - const latestReviews = {} - reviews.forEach((review) => { - if ( - !latestReviews[review.user.login] || - new Date(review.submitted_at) > - new Date(latestReviews[review.user.login].submitted_at) - ) { - latestReviews[review.user.login] = review - } - }) - - // Check for most critical state (Changes Requested > Approved > Commented) - const states = Object.values(latestReviews).map((r) => r.state) - if (states.includes('CHANGES_REQUESTED')) { - reviewStatus = 'changes-requested' - reviewEmoji = ':warning:' - } else if (states.includes('APPROVED')) { - reviewStatus = 'approved' - reviewEmoji = ':heavy_check_mark:' - } else { - reviewStatus = 'commented' - reviewEmoji = ':speech_balloon:' - } - } - } catch (error) { - console.log( - `Warning: Could not fetch review status for PR #${pr.number}: ${error.message}` - ) - } - - // Get mergeable state - let mergeableStatus = 'unknown' - let mergeableEmoji = ':grey_question:' - - // Fetch full PR details to get mergeable state (it's not always in the list response) - try { - const { data: fullPR } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }) - - const mergeableState = fullPR.mergeable_state - - switch (mergeableState) { - case 'clean': - mergeableStatus = 'ready' - mergeableEmoji = ':rocket:' - break - case 'dirty': - mergeableStatus = 'conflicts' - mergeableEmoji = ':collision:' - break - case 'blocked': - mergeableStatus = 'blocked' - mergeableEmoji = ':no_entry:' - break - case 'unstable': - mergeableStatus = 'unstable' - mergeableEmoji = ':warning:' - break - case 'behind': - mergeableStatus = 'behind' - mergeableEmoji = ':arrow_down:' - break - case 'draft': - mergeableStatus = 'draft' - mergeableEmoji = ':pencil2:' - break - default: - mergeableStatus = mergeableState || 'unknown' - mergeableEmoji = ':grey_question:' - } - } catch (error) { - console.log( - `Warning: Could not fetch mergeable state for PR #${pr.number}: ${error.message}` - ) - } - - // Skip PRs that have already been actioned (reviewed) - if (reviewStatus !== 'no-reviews') { - console.log(`PR #${pr.number} has already been reviewed (${reviewStatus}), skipping...`) - continue - } - - // Skip PRs with merge conflicts - if (mergeableStatus === 'conflicts') { - console.log(`PR #${pr.number} has merge conflicts, skipping...`) - continue - } - - stalePRs.push({ - number: pr.number, - title: pr.title, - url: pr.html_url, - author: pr.user.login, - createdAt: pr.created_at, - hoursOld: hoursOld, - daysOld: daysOld, - fileCount: files.filter((f) => f.filename.startsWith(DASHBOARD_PATH)).length, - reviewStatus: reviewStatus, - reviewEmoji: reviewEmoji, - mergeableStatus: mergeableStatus, - mergeableEmoji: mergeableEmoji, - }) - - console.log( - `✓ Found stale Dashboard PR #${pr.number} (Review: ${reviewStatus}, Mergeable: ${mergeableStatus})` - ) - } - } - - page++ - } - - console.log(`Found ${stalePRs.length} stale Dashboard PRs`) - - // Sort by age (newest first) - stalePRs.sort((a, b) => a.hoursOld - b.hoursOld) - - // Store results for next step - core.setOutput('stale_prs', JSON.stringify(stalePRs)) - core.setOutput('count', stalePRs.length) - - return stalePRs -} diff --git a/scripts/actions/find-stale-dashboard-prs.ts b/scripts/actions/find-stale-dashboard-prs.ts new file mode 100644 index 0000000000000..c73f52e0aa4aa --- /dev/null +++ b/scripts/actions/find-stale-dashboard-prs.ts @@ -0,0 +1,266 @@ +const TWENTY_FOUR_HOURS_AGO = new Date(Date.now() - 24 * 60 * 60 * 1000) +const DASHBOARD_PATH = 'apps/studio/' +const REPO_OWNER = 'supabase' +const REPO_NAME = 'supabase' + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN + +class RateLimitError extends Error { + constructor(resetAt: string) { + super(`GitHub API rate limit exceeded. Resets at ${resetAt}`) + } +} + +async function githubApi(path: string) { + const headers: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + } + if (GITHUB_TOKEN) { + headers.Authorization = `Bearer ${GITHUB_TOKEN}` + } + + const response = await fetch(`https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}${path}`, { + headers, + }) + + if ( + response.status === 429 || + (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') + ) { + const resetEpoch = response.headers.get('x-ratelimit-reset') + const resetAt = resetEpoch ? new Date(Number(resetEpoch) * 1000).toISOString() : 'unknown' + throw new RateLimitError(resetAt) + } + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n${errorText}`) + } + + return response.json() +} + +interface StalePR { + number: number + title: string + url: string + author: string + createdAt: string + hoursOld: number + daysOld: number + fileCount: number + reviewStatus: string + reviewEmoji: string + mergeableStatus: string + mergeableEmoji: string +} + +async function findStalePRs(): Promise { + console.error(`Looking for PRs older than: ${TWENTY_FOUR_HOURS_AGO.toISOString()}`) + + const stalePRs: StalePR[] = [] + let page = 1 + let hasMore = true + + outer: while (hasMore && page <= 10) { + console.error(`Fetching page ${page}...`) + + let prs: any[] + try { + prs = await githubApi( + `/pulls?state=open&sort=created&direction=desc&per_page=100&page=${page}` + ) + } catch (error: any) { + if (error instanceof RateLimitError) { + console.error(`Rate limited while listing PRs. ${error.message}`) + break + } + throw error + } + + if (prs.length === 0) { + hasMore = false + break + } + + for (const pr of prs) { + // Skip PRs from forks + if (pr.head.repo && pr.head.repo.full_name !== `${REPO_OWNER}/${REPO_NAME}`) { + console.error(`PR #${pr.number} is from a fork, skipping...`) + continue + } + + // Skip dependabot PRs + if (pr.user.login === 'dependabot[bot]' || pr.user.login === 'dependabot') { + console.error(`PR #${pr.number} is from dependabot, skipping...`) + continue + } + + // Skip draft PRs + if (pr.draft) { + console.error(`PR #${pr.number} is a draft, skipping...`) + continue + } + + const createdAt = new Date(pr.created_at) + + if (createdAt > TWENTY_FOUR_HOURS_AGO) { + console.error(`PR #${pr.number} is too new, skipping...`) + continue + } + + console.error(`Checking PR #${pr.number}: ${pr.title}`) + + let files: any[] + try { + files = await githubApi(`/pulls/${pr.number}/files?per_page=100`) + } catch (error: any) { + if (error instanceof RateLimitError) { + console.error(`Rate limited while fetching files. ${error.message}`) + break outer + } + throw error + } + + const touchesDashboard = files.some((file: any) => file.filename.startsWith(DASHBOARD_PATH)) + + if (!touchesDashboard) continue + + const hoursOld = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60)) + const daysOld = Math.floor(hoursOld / 24) + + // Fetch review status + let reviewStatus = 'no-reviews' + let reviewEmoji = ':eyes:' + try { + const reviews = await githubApi(`/pulls/${pr.number}/reviews?per_page=100`) + + if (reviews.length > 0) { + const latestReviews: Record = {} + reviews.forEach((review: any) => { + if ( + !latestReviews[review.user.login] || + new Date(review.submitted_at) > + new Date(latestReviews[review.user.login].submitted_at) + ) { + latestReviews[review.user.login] = review + } + }) + + const states = Object.values(latestReviews).map((r) => r.state) + if (states.includes('CHANGES_REQUESTED')) { + reviewStatus = 'changes-requested' + reviewEmoji = ':warning:' + } else if (states.includes('APPROVED')) { + reviewStatus = 'approved' + reviewEmoji = ':heavy_check_mark:' + } + } + } catch (error: any) { + if (error instanceof RateLimitError) { + console.error(`Rate limited while fetching reviews. ${error.message}`) + break outer + } + console.error( + `Warning: Could not fetch review status for PR #${pr.number}: ${error.message}` + ) + } + + // Get mergeable state + let mergeableStatus = 'unknown' + let mergeableEmoji = ':grey_question:' + try { + const fullPR = await githubApi(`/pulls/${pr.number}`) + const mergeableState = fullPR.mergeable_state + + switch (mergeableState) { + case 'clean': + mergeableStatus = 'ready' + mergeableEmoji = ':rocket:' + break + case 'dirty': + mergeableStatus = 'conflicts' + mergeableEmoji = ':collision:' + break + case 'blocked': + mergeableStatus = 'blocked' + mergeableEmoji = ':no_entry:' + break + case 'unstable': + mergeableStatus = 'unstable' + mergeableEmoji = ':warning:' + break + case 'behind': + mergeableStatus = 'behind' + mergeableEmoji = ':arrow_down:' + break + case 'draft': + mergeableStatus = 'draft' + mergeableEmoji = ':pencil2:' + break + default: + mergeableStatus = mergeableState || 'unknown' + mergeableEmoji = ':grey_question:' + } + } catch (error: any) { + if (error instanceof RateLimitError) { + console.error(`Rate limited while fetching mergeable state. ${error.message}`) + break outer + } + console.error( + `Warning: Could not fetch mergeable state for PR #${pr.number}: ${error.message}` + ) + } + + // Skip PRs that have already been reviewed + if (reviewStatus !== 'no-reviews') { + console.error(`PR #${pr.number} has already been reviewed (${reviewStatus}), skipping...`) + continue + } + + // Skip PRs with merge conflicts + if (mergeableStatus === 'conflicts') { + console.error(`PR #${pr.number} has merge conflicts, skipping...`) + continue + } + + stalePRs.push({ + number: pr.number, + title: pr.title, + url: pr.html_url, + author: pr.user.login, + createdAt: pr.created_at, + hoursOld, + daysOld, + fileCount: files.filter((f: any) => f.filename.startsWith(DASHBOARD_PATH)).length, + reviewStatus, + reviewEmoji, + mergeableStatus, + mergeableEmoji, + }) + + console.error( + `Found stale Dashboard PR #${pr.number} (Review: ${reviewStatus}, Mergeable: ${mergeableStatus})` + ) + } + + page++ + } + + console.error(`Found ${stalePRs.length} stale Dashboard PRs`) + + stalePRs.sort((a, b) => a.hoursOld - b.hoursOld) + + return stalePRs +} + +findStalePRs() + .then((stalePRs) => { + // Output JSON to stdout for piping to the next script + console.log(JSON.stringify(stalePRs)) + }) + .catch((error) => { + console.error('Error:', error.message) + process.exit(1) + }) diff --git a/scripts/actions/send-slack-pr-notification.js b/scripts/actions/send-slack-pr-notification.ts similarity index 65% rename from scripts/actions/send-slack-pr-notification.js rename to scripts/actions/send-slack-pr-notification.ts index c9e56b0412377..7396c8a7af749 100644 --- a/scripts/actions/send-slack-pr-notification.js +++ b/scripts/actions/send-slack-pr-notification.ts @@ -1,31 +1,42 @@ -/** - * Sends a Slack notification with stale Dashboard PRs - * - * @param {Array} stalePRs - Array of stale PRs from find-stale-dashboard-prs.js - * @param {string} webhookUrl - Slack webhook URL - */ -module.exports = async (stalePRs, webhookUrl) => { +const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL + +if (!SLACK_WEBHOOK_URL) { + console.error('SLACK_WEBHOOK_URL environment variable is required') + process.exit(1) +} + +interface StalePR { + number: number + title: string + url: string + author: string + createdAt: string + hoursOld: number + daysOld: number + fileCount: number + reviewStatus: string + reviewEmoji: string + mergeableStatus: string + mergeableEmoji: string +} + +function escapeSlack(text: string) { + return text.replace(/&/g, '&').replace(//g, '>') +} + +async function sendSlackNotification(stalePRs: StalePR[]) { const count = stalePRs.length - // Build PR blocks with proper escaping for Slack mrkdwn const prBlocks = stalePRs.map((pr) => { - // Format age display const remainingHours = pr.hoursOld % 24 const ageText = pr.daysOld > 0 ? `${pr.daysOld}d ${remainingHours}h` : `${pr.hoursOld}h` - // Escape special characters for Slack mrkdwn (escape &, <, >) - const escapeSlack = (text) => { - return text.replace(/&/g, '&').replace(//g, '>') - } - - // Truncate title if too long (max 3000 chars for entire text field) const maxTitleLength = 200 const safeTitle = pr.title.length > maxTitleLength ? escapeSlack(pr.title.substring(0, maxTitleLength) + '...') : escapeSlack(pr.title) - // Format status text const reviewStatusText = pr.reviewStatus === 'approved' ? 'Approved' @@ -59,13 +70,10 @@ module.exports = async (stalePRs, webhookUrl) => { } }) - // Slack has a 50 block limit, we use 3 for header/intro/divider - // So we can show max 47 PRs const MAX_PRS_TO_SHOW = 47 const prBlocksToShow = prBlocks.slice(0, MAX_PRS_TO_SHOW) const hasMorePRs = prBlocks.length > MAX_PRS_TO_SHOW - // Build complete Slack message const slackMessage = { text: 'Dashboard PRs needing attention', blocks: [ @@ -90,12 +98,9 @@ module.exports = async (stalePRs, webhookUrl) => { ], } - // Send to Slack - const response = await fetch(webhookUrl, { + const response = await fetch(SLACK_WEBHOOK_URL!, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(slackMessage), }) @@ -106,5 +111,30 @@ module.exports = async (stalePRs, webhookUrl) => { ) } - console.log('✓ Slack notification sent successfully!') + console.error('Slack notification sent successfully!') } + +// Read JSON from stdin +async function readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk) + } + return Buffer.concat(chunks).toString('utf-8') +} + +readStdin() + .then(async (input) => { + const stalePRs: StalePR[] = JSON.parse(input) + + if (stalePRs.length === 0) { + console.error('No stale PRs to notify about') + return + } + + await sendSlackNotification(stalePRs) + }) + .catch((error) => { + console.error('Error:', error.message) + process.exit(1) + })