From 3d3a661ebf57da497282979b4001998c075dac1f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:33:25 +0000 Subject: [PATCH 001/319] feat(workflows): link a file and ref on any known git host Co-Authored-By: Claude Opus 5.5 --- .../components/Git/gitMetadataParser.spec.ts | 52 +++++++++++++ .../lib/components/Git/gitMetadataParser.ts | 75 ++++++++++++++++--- 2 files changed, 118 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/Git/gitMetadataParser.spec.ts b/frontend/src/lib/components/Git/gitMetadataParser.spec.ts index 0ff93905ac42..5f66711f3073 100644 --- a/frontend/src/lib/components/Git/gitMetadataParser.spec.ts +++ b/frontend/src/lib/components/Git/gitMetadataParser.spec.ts @@ -18,6 +18,11 @@ describe('GitMetadataParser', () => { remote_url: 'git@gitlab.com:posthog-bot-group/posthog-bot-project.git', expected: 'https://gitlab.com/posthog-bot-group/posthog-bot-project/-/commit/commit-sha', }, + { + description: 'should create commit link for bitbucket URL', + remote_url: 'https://bitbucket.org/user/repo.git', + expected: 'https://bitbucket.org/user/repo/commits/commit-sha', + }, { description: 'should create commit link for gitlab URL', remote_url: 'invalid_url', @@ -51,6 +56,21 @@ describe('GitMetadataParser', () => { remote_url: 'git@gitlab.com:posthog-bot-group/posthog-bot-project.git', expected: 'https://gitlab.com/posthog-bot-group/posthog-bot-project/-/tree/branch-name', }, + { + description: 'should create branch link for bitbucket URL', + remote_url: 'https://bitbucket.org/user/repo.git', + expected: 'https://bitbucket.org/user/repo/src/branch-name', + }, + { + description: 'should create branch link for a gitlab project in a subgroup', + remote_url: 'gitlab.com/group/subgroup/project', + expected: 'https://gitlab.com/group/subgroup/project/-/tree/branch-name', + }, + { + description: 'should create branch link for a gitlab remote with a trailing slash', + remote_url: 'https://gitlab.com/group/project/', + expected: 'https://gitlab.com/group/project/-/tree/branch-name', + }, ])('$description', ({ remote_url, expected }) => { const result = GitMetadataParser.getBranchLink(remote_url, 'branch-name') expect(result).toBe(expected) @@ -80,4 +100,36 @@ describe('GitMetadataParser', () => { expect(GitMetadataParser.getGitHubRepositorySlug(remote_url)).toBe(expected) }) }) + + describe('getFileLink', () => { + it.each([ + { + description: 'a GitHub remote without a scheme', + remote_url: 'github.com/example/app', + expected: 'https://github.com/example/app/blob/main/emails/welcome.html', + }, + { + description: 'a GitLab SSH remote', + remote_url: 'git@gitlab.com:example/app.git', + expected: 'https://gitlab.com/example/app/-/blob/main/emails/welcome.html', + }, + { + description: 'a Bitbucket HTTPS remote', + remote_url: 'https://bitbucket.org/example/app', + expected: 'https://bitbucket.org/example/app/src/main/emails/welcome.html', + }, + { description: 'an unknown host', remote_url: 'git.example.com/example/app', expected: undefined }, + ])('links the file for $description', ({ remote_url, expected }) => { + expect(GitMetadataParser.getFileLink(remote_url, 'main', './emails/welcome.html')).toBe(expected) + }) + + it('encodes each part of the ref and the path', () => { + expect(GitMetadataParser.getFileLink('github.com/example/app', 'feat/a b', 'emails/a#b.html')).toBe( + 'https://github.com/example/app/blob/feat/a%20b/emails/a%23b.html' + ) + expect(GitMetadataParser.getRefLink('github.com/example/app', 'fix#12')).toBe( + 'https://github.com/example/app/tree/fix%2312' + ) + }) + }) }) diff --git a/frontend/src/lib/components/Git/gitMetadataParser.ts b/frontend/src/lib/components/Git/gitMetadataParser.ts index 6c6f198438c9..5c1382fb92dc 100644 --- a/frontend/src/lib/components/Git/gitMetadataParser.ts +++ b/frontend/src/lib/components/Git/gitMetadataParser.ts @@ -9,6 +9,11 @@ export type ParsedRemoteUrl = { } const GITHUB_SLUG_PATTERN = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/ +const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i + +function encodePathSegments(value: string): string { + return value.split('/').map(encodeURIComponent).join('/') +} export class GitMetadataParser { static getCommitLink(remote_url?: string, commit_id?: string): string | undefined { @@ -33,6 +38,28 @@ export class GitMetadataParser { return this.buildBranchLink(parsedRemoteUrl, branch) } + static getRefLink(remote_url?: string, ref?: string): string | undefined { + if (!ref) { + return undefined + } + return this.isCommitSha(ref) ? this.getCommitLink(remote_url, ref) : this.getBranchLink(remote_url, ref) + } + + static getFileLink(remote_url?: string, ref?: string, path?: string): string | undefined { + if (!remote_url || !ref || !path) { + return undefined + } + const parsedRemoteUrl = this.parseRemoteUrl(remote_url) + if (!parsedRemoteUrl) { + return undefined + } + return this.buildFileLink(parsedRemoteUrl, ref, path) + } + + static isCommitSha(ref: string): boolean { + return COMMIT_SHA_PATTERN.test(ref) + } + static getRepoLink(remote_url?: string): string | undefined { if (!remote_url) { return undefined @@ -57,7 +84,11 @@ export class GitMetadataParser { } static parseRemoteUrl(remoteUrl: string): ParsedRemoteUrl | undefined { - return this.parseSshRemoteUrl(remoteUrl) || this.parseHttpsRemoteUrl(remoteUrl) + return ( + this.parseSshRemoteUrl(remoteUrl) || + this.parseHttpsRemoteUrl(remoteUrl) || + this.parseSchemelessRemoteUrl(remoteUrl) + ) } private static buildRemoteLink(parsedUrl: ParsedRemoteUrl): string | undefined { @@ -74,10 +105,11 @@ export class GitMetadataParser { private static buildBranchLink(parsedUrl: ParsedRemoteUrl, branch: string): string | undefined { switch (parsedUrl.provider) { case 'github': - case 'bitbucket': - return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/tree/${branch}` + return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/tree/${encodePathSegments(branch)}` case 'gitlab': - return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/-/tree/${branch}` + return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/-/tree/${encodePathSegments(branch)}` + case 'bitbucket': + return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/src/${encodePathSegments(branch)}` default: return undefined } @@ -86,8 +118,9 @@ export class GitMetadataParser { private static buildCommitLink(parsedUrl: ParsedRemoteUrl, commitSha: string): string | undefined { switch (parsedUrl.provider) { case 'github': - case 'bitbucket': return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/commit/${commitSha}` + case 'bitbucket': + return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/commits/${commitSha}` case 'gitlab': return `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}/-/commit/${commitSha}` default: @@ -95,6 +128,21 @@ export class GitMetadataParser { } } + private static buildFileLink(parsedUrl: ParsedRemoteUrl, ref: string, path: string): string | undefined { + const base = `${parsedUrl.providerUrl}/${parsedUrl.owner}/${parsedUrl.repository}` + const refAndPath = `${encodePathSegments(ref)}/${encodePathSegments(path.replace(/^\.?\//, ''))}` + switch (parsedUrl.provider) { + case 'github': + return `${base}/blob/${refAndPath}` + case 'gitlab': + return `${base}/-/blob/${refAndPath}` + case 'bitbucket': + return `${base}/src/${refAndPath}` + default: + return undefined + } + } + private static parseSshRemoteUrl(remoteUrl: string): ParsedRemoteUrl | undefined { // git@github.com:user/repo.git @@ -136,23 +184,32 @@ export class GitMetadataParser { } const domain = withoutProtocol.slice(0, firstSlashIdx) - const pathParts = withoutProtocol.slice(firstSlashIdx + 1).split('/') + const pathParts = withoutProtocol + .slice(firstSlashIdx + 1) + .split('/') + .filter((part) => part !== '') if (pathParts.length < 2) { return undefined } + const [provider, providerUrl] = this.parseDomain(domain) const owner = pathParts[0] - let repository = pathParts[1] + let repository = provider === 'gitlab' ? pathParts.slice(1).join('/') : pathParts[1] if (repository.endsWith('.git')) { repository = repository.slice(0, -4) } - const [provider, providerUrl] = this.parseDomain(domain) - return { provider, owner, repository, providerUrl } } + private static parseSchemelessRemoteUrl(remoteUrl: string): ParsedRemoteUrl | undefined { + if (remoteUrl.includes('://') || remoteUrl.includes('@')) { + return undefined + } + return this.parseHttpsRemoteUrl(`https://${remoteUrl}`) + } + private static parseDomain(domain: string): [GitProvider, string | undefined] { switch (domain) { case 'github.com': From ca3de9c476b9ef812b5796534df1b2c3c394240b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:33:42 +0000 Subject: [PATCH 002/319] feat(workflows): show code-managed email templates read-only A template uploaded from code opens as a read-only preview with its key and source. Edits refused by an upload wait in a banner that offers a duplicate, and the menu can stop managing the template in code. Co-Authored-By: Claude Opus 5.5 --- frontend/src/lib/constants.tsx | 1 + .../CodeManagedSource.stories.tsx | 55 +++ .../TemplateLibrary/CodeManagedSource.tsx | 42 +++ .../TemplateLibrary/CodeManagedTag.tsx | 25 ++ .../TemplateLibrary/EmailHtmlFrame.test.tsx | 47 +++ .../TemplateLibrary/EmailHtmlFrame.tsx | 33 ++ .../TemplateLibrary/MessageTemplate.tsx | 315 +++++++++------- .../TemplateLibrary/MessageTemplateCard.tsx | 10 +- .../MessageTemplateMenu.test.tsx | 91 +++++ .../TemplateLibrary/MessageTemplateMenu.tsx | 82 +++++ .../TemplateLibrary/MessageTemplatesTable.tsx | 4 + .../QuillMessageTemplateActions.tsx | 29 +- .../ReadOnlyTemplatePreview.stories.tsx | 23 ++ .../ReadOnlyTemplatePreview.tsx | 33 ++ .../TemplateLibrary/RefusedEditsBanner.tsx | 34 ++ .../TransactionalEmailApiExample.tsx | 24 +- .../TemplateLibrary/codeManagedTemplate.ts | 50 +++ .../messageTemplateLogic.test.ts | 345 ++++++++++++++++++ .../TemplateLibrary/messageTemplateLogic.ts | 232 ++++++++++-- .../TemplateLibrary/messageTemplatesLogic.ts | 7 +- .../TemplateLibrary/releaseTemplateDialog.ts | 17 + .../frontend/TemplateLibrary/templateCopy.ts | 9 + .../frontend/TemplateLibrary/types.ts | 5 + 23 files changed, 1336 insertions(+), 177 deletions(-) create mode 100644 products/workflows/frontend/TemplateLibrary/CodeManagedSource.stories.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/CodeManagedSource.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/CodeManagedTag.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.test.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/MessageTemplateMenu.test.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/MessageTemplateMenu.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/ReadOnlyTemplatePreview.stories.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/ReadOnlyTemplatePreview.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/RefusedEditsBanner.tsx create mode 100644 products/workflows/frontend/TemplateLibrary/codeManagedTemplate.ts create mode 100644 products/workflows/frontend/TemplateLibrary/releaseTemplateDialog.ts create mode 100644 products/workflows/frontend/TemplateLibrary/templateCopy.ts diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index f18891915b6a..9fe80d32fa06 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -623,6 +623,7 @@ export const FEATURE_FLAGS = { WORKFLOWS_DESTINATION_SUGGESTION: 'workflows-destination-suggestion', // owner: #team-workflows, offers workflows instead of a new email tool destination WORKFLOWS_EMAIL_DOMAIN_AGENT_SETUP: 'workflows-email-domain-agent-setup', // owner: @silthus #team-workflows WORKFLOWS_EMAIL_DOMAIN_WIZARD: 'workflows-email-domain-wizard', // owner: @silthus #team-workflows + WORKFLOWS_EMAIL_FROM_CODE: 'workflows-email-from-code', // owner: @silthus #team-workflows WORKFLOWS_EMAIL_SENDER_ROTATION: 'workflows-email-sender-rotation', // owner: @arthurdedeus #team-workflows WORKFLOWS_INTERNAL_EVENT_FILTERS: 'workflows-internal-event-filters', // owner: @haven #team-workflows WORKFLOWS_LINEAR_VIEW: 'workflows-linear-view', // owner: #team-workflows diff --git a/products/workflows/frontend/TemplateLibrary/CodeManagedSource.stories.tsx b/products/workflows/frontend/TemplateLibrary/CodeManagedSource.stories.tsx new file mode 100644 index 000000000000..9938c90b05eb --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/CodeManagedSource.stories.tsx @@ -0,0 +1,55 @@ +import { Meta, StoryFn } from '@storybook/react' + +import { CodeManagedSource } from './CodeManagedSource' +import { CodeManagedTag } from './CodeManagedTag' +import { templateSource } from './codeManagedTemplate' +import { NEW_TEMPLATE } from './constants' +import { MessageTemplate } from './types' + +const meta: Meta = { + title: 'Products/Workflows/Template library/Code managed source', + component: CodeManagedSource, +} +export default meta + +function template(overrides: Partial = {}): MessageTemplate { + return { + ...NEW_TEMPLATE, + id: 'template-1', + name: 'Welcome', + key: 'welcome', + managed_by: 'code', + source_repository: 'github.com/example/app', + source_path: 'emails/welcome.html', + source_ref: '3f2c1ab9d4e5f60718293a4b5c6d7e8f90a1b2c3', + ...overrides, + } +} + +const Template: StoryFn<{ template: MessageTemplate }> = ({ template }) => ( +
+
+

{template.name}

+ +
+ +
+) + +export const UploadedFromACommit = Template.bind({}) +UploadedFromACommit.args = { template: template() } + +export const LongPathOnGitLabBranch = Template.bind({}) +LongPathOnGitLabBranch.args = { + template: template({ + source_repository: 'gitlab.com/example/customer-lifecycle-emails', + source_path: 'emails/onboarding/workspace-owners/welcome-to-your-new-workspace.html', + source_ref: 'main', + }), +} + +export const UnknownHost = Template.bind({}) +UnknownHost.args = { template: template({ source_repository: 'git.example.com/team/app' }) } + +export const NoRecordedSource = Template.bind({}) +NoRecordedSource.args = { template: template({ source_repository: null, source_path: null, source_ref: null }) } diff --git a/products/workflows/frontend/TemplateLibrary/CodeManagedSource.tsx b/products/workflows/frontend/TemplateLibrary/CodeManagedSource.tsx new file mode 100644 index 000000000000..e1c26c23a594 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/CodeManagedSource.tsx @@ -0,0 +1,42 @@ +import { Link } from '@posthog/lemon-ui' + +import { cn } from 'lib/utils/css-classes' + +import { TemplateSource } from './codeManagedTemplate' + +function SourcePart({ label, url }: { label: string; url: string | undefined }): JSX.Element { + return url ? ( + + {label} + + ) : ( + {label} + ) +} + +export function CodeManagedSource({ + source, + className, +}: { + source: TemplateSource | null + className?: string +}): JSX.Element | null { + if (!source) { + return null + } + const { path, fileUrl, repository, repositoryUrl, ref, refUrl } = source + + return ( +

+ {path && } + {path && repository && in } + {repository && } + {ref && ( + <> + {path || repository ? ', last uploaded from ' : 'Last uploaded from '} + + + )} +

+ ) +} diff --git a/products/workflows/frontend/TemplateLibrary/CodeManagedTag.tsx b/products/workflows/frontend/TemplateLibrary/CodeManagedTag.tsx new file mode 100644 index 000000000000..0bf9585acc16 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/CodeManagedTag.tsx @@ -0,0 +1,25 @@ +import { IconCode } from '@posthog/icons' +import { LemonTag, LemonTagProps, Tooltip } from '@posthog/lemon-ui' + +import { isCodeManagedTemplate } from './codeManagedTemplate' +import { MessageTemplate } from './types' + +export function CodeManagedTag({ + template, + size, +}: { + template: Pick | null | undefined + size?: LemonTagProps['size'] +}): JSX.Element | null { + if (!isCodeManagedTemplate(template)) { + return null + } + + return ( + + } data-attr="message-template-managed-by-code"> + Managed by code + + + ) +} diff --git a/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.test.tsx b/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.test.tsx new file mode 100644 index 000000000000..a1be44c16426 --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.test.tsx @@ -0,0 +1,47 @@ +import { cleanup, render } from '@testing-library/react' + +import { EmailHtmlFrame } from './EmailHtmlFrame' + +describe('EmailHtmlFrame', () => { + afterEach(() => cleanup()) + + function renderedDocument(html: string): Document { + const { container } = render() + return new DOMParser().parseFromString( + container.querySelector('iframe')?.getAttribute('srcdoc') ?? '', + 'text/html' + ) + } + + it.each([ + ['a link without a target', '

Go

'], + ['a link that targets the frame itself', '

Go

'], + [ + 'a link in a full document', + 'HiGo', + ], + ])('opens %s in a new tab instead of the preview', (_case, html) => { + const link = renderedDocument(html).querySelector('a') + + expect(link?.getAttribute('target')).toEqual('_blank') + expect(link?.textContent).toEqual('Go') + }) + + it('keeps the doctype, so the email renders in the same mode', () => { + const html = + '

Hi

' + const { container } = render() + + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toMatch( + /^/ + ) + }) + + it('lets links open a new tab but never runs scripts', () => { + const { container } = render() + const sandbox = container.querySelector('iframe')?.getAttribute('sandbox')?.split(' ') + + expect(sandbox).toEqual(expect.arrayContaining(['allow-popups', 'allow-popups-to-escape-sandbox'])) + expect(sandbox).not.toContain('allow-scripts') + }) +}) diff --git a/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx b/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx new file mode 100644 index 000000000000..9361fb86293b --- /dev/null +++ b/products/workflows/frontend/TemplateLibrary/EmailHtmlFrame.tsx @@ -0,0 +1,33 @@ +import { useMemo } from 'react' + +import { cn } from 'lib/utils/css-classes' + +function doctypeOf(parsed: Document): string { + const doctype = parsed.doctype + if (!doctype) { + return '' + } + const publicId = doctype.publicId ? ` PUBLIC "${doctype.publicId}"` : '' + const systemId = doctype.systemId ? `${doctype.publicId ? '' : ' SYSTEM'} "${doctype.systemId}"` : '' + return `` +} + +function withLinksInNewTab(html: string): string { + const parsed = new DOMParser().parseFromString(html, 'text/html') + for (const link of parsed.querySelectorAll('a[href], area[href]')) { + link.setAttribute('target', '_blank') + } + return doctypeOf(parsed) + parsed.documentElement.outerHTML +} + +export function EmailHtmlFrame({ html, className }: { html: string; className?: string }): JSX.Element { + const previewDocument = useMemo(() => withLinksInNewTab(html), [html]) + return ( +