Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a5dbb10
feat(messaging): add key, owner, source and hash columns to templates
Silthus Oct 16, 2026
a17b303
feat(messaging): upload an email template by key from code
Silthus Oct 16, 2026
65d441a
refactor(messaging): serialize upload errors and return dataclasses
Silthus Oct 16, 2026
bfe29cc
feat(messaging): refuse edits to code-managed email templates
Silthus Oct 16, 2026
0221cb6
feat(messaging): send an email template named by key or id, with a co…
Silthus Oct 16, 2026
e5234aa
Merge remote-tracking branch 'origin/spec/code-managed-templates' int…
Silthus Oct 16, 2026
50c1909
feat(messaging): adopt an email template by giving it a key
Silthus Oct 16, 2026
fcfe17e
Merge remote-tracking branch 'origin/spec/code-managed-templates' int…
Silthus Oct 16, 2026
0062c76
feat(workflows): show code-managed email templates, their source and key
Silthus Oct 16, 2026
4b119fc
fix(messaging): drop an unused type ignore on the adoption key field
Silthus Oct 17, 2026
7a11708
Merge remote-tracking branch 'origin/spec/code-managed-templates' int…
Silthus Oct 17, 2026
daf70f9
feat(messaging): let an automatic upload refuse a superseded hash
Silthus Oct 17, 2026
33fbbe2
feat(workflows): show code-managed email templates read-only
Silthus Oct 17, 2026
7845707
Merge remote-tracking branch 'origin/spec/code-managed-templates' int…
Silthus Oct 17, 2026
e52feb3
Merge remote-tracking branch 'upstream/master' into ship/code-managed…
Silthus Oct 17, 2026
e5f5ef2
fix(messaging): close the review findings in the template API
Silthus Oct 17, 2026
0c4ce8e
fix(workflows): close the review findings in the template library
Silthus Oct 17, 2026
07d7e0e
fix(messaging): close the second review round's findings
Silthus Oct 17, 2026
a863ed0
fix(workflows): keep unsaved edits when an upload claims a template back
Silthus Oct 17, 2026
d746c35
fix(workflows): lock a refused template before its reload
Silthus Oct 17, 2026
4e14309
feat(messaging): return the change prompt on code-managed templates
Silthus Oct 17, 2026
0ba8f79
chore(messaging): regenerate types for the change prompt fields
Silthus Oct 17, 2026
62c695a
feat(workflows): copy a prompt to edit a code-managed template with y…
Silthus Oct 17, 2026
8a50f6e
feat(workflows): copy a code-managed template as its HTML file
Silthus Oct 17, 2026
0692164
chore(workflows): merge spec/code-managed-templates into spec/templat…
Silthus Oct 17, 2026
4a8f7e1
fix(messaging): keep source values that could instruct the agent out …
Silthus Oct 17, 2026
36868e9
fix(messaging): scan liquid in one pass and return the prompt's varia…
Silthus Oct 17, 2026
37b8633
fix(workflows): keep every preheader out and Liquid intact in the cop…
Silthus Oct 17, 2026
aa43010
fix(workflows): count the server's variables, track every duplicate, …
Silthus Oct 17, 2026
5ff207c
fix(workflows): paint the full prompt in Chromium
Silthus Oct 17, 2026
01cbef1
fix(messaging): keep route-group and bracket paths in the change prompt
Silthus Oct 17, 2026
fa25e69
fix(workflows): return focus, keep Copy prompt in view, and paint the…
Silthus Oct 17, 2026
158cccc
fix(messaging): restore the preferences templates a formatter rewrote
Silthus Oct 17, 2026
3ae1cce
fix(messaging): keep shell syntax in a source path out of the change …
Silthus Oct 17, 2026
004b1bd
fix(workflows): close the agent prompt on a second click of its button
Silthus Oct 17, 2026
0960e3d
fix(workflows): let Escape close the agent prompt while its button ha…
Silthus Oct 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions frontend/src/lib/components/Git/gitMetadataParser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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'
)
})
})
})
77 changes: 68 additions & 9 deletions frontend/src/lib/components/Git/gitMetadataParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -86,15 +118,31 @@ 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:
return undefined
}
}

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

Expand Down Expand Up @@ -136,23 +184,34 @@ 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]
// GitLab nests projects in subgroups, so everything after the owner names the project.
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 {
// github.com/user/repo
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':
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 102 additions & 0 deletions products/messaging/backend/facade/contracts.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from uuid import UUID

from posthog.dataclasses import frozen
from posthog.enums import LabeledStrEnum


class UnlayerError(Exception):
Expand Down Expand Up @@ -73,3 +75,103 @@ class DeliveryUnavailable(Exception):
@frozen
class QueuedEmail:
message_id: str


TEMPLATE_KEY_PATTERN = r"^[a-zA-Z0-9_-]+$"
TEMPLATE_KEY_MAX_LENGTH = 400


class TemplateManagedBy(LabeledStrEnum):
GUI = "gui", "GUI"
CODE = "code", "Code"


class TemplateUploadResult(LabeledStrEnum):
CREATED = "created", "Created"
UPDATED = "updated", "Updated"
UNCHANGED = "unchanged", "Unchanged"
SUPERSEDED = "superseded", "Superseded"


class ContentHashState(LabeledStrEnum):
"""How the content hash a send carries compares with the template's published content."""

NONE = "none", "None"
CURRENT = "current", "Current"
SUPERSEDED = "superseded", "Superseded"
OUTDATED = "outdated", "Outdated"


@frozen
class TemplateReference:
"""A template a send names by exactly one of `key` or `id`."""

key: str | None
id: UUID | None
hash: str | None


@frozen
class TemplateWriteIntent:
"""A write to a template: the API action that carries it and its whole request body."""

action: str
payload: Mapping[str, Any]


class TemplateWriteRefused(Exception):
"""The template refuses this write. `code` is stable for API clients; `detail`, `why` and `fix` are written for
the caller."""

def __init__(self, *, code: str, detail: str, why: str, fix: str) -> None:
super().__init__(detail)
self.code = code
self.detail = detail
self.why = why
self.fix = fix


class TemplateManagedByCode(TemplateWriteRefused):
"""The template is managed by code, so it refuses every write but an upload, a send or a release."""

def __init__(self, *, detail: str, why: str, fix: str) -> None:
super().__init__(code="immutable", detail=detail, why=why, fix=fix)


class TemplateKeyRefused(TemplateWriteRefused):
"""The template can't take this key."""


@frozen
class TemplateSource:
"""Where the uploaded file lives. A part that is None leaves the stored part as it is."""

repository: str | None
path: str | None
ref: str | None


@frozen
class TemplateChangePrompt:
"""What a person hands their coding agent to change a template managed by code: the start of the sentence that
asks for the change, the "About this file" block that follows it after a blank line, and the variables that block
names."""

starter: str
context: str
variables: tuple[str, ...]


@frozen
class TemplateUpload:
key: str
name: str
subject: str
html: str
text: str
preheader: str
# The editor's design for the html, so the template opens in the visual editor.
design: dict[str, Any]
source: TemplateSource | None
# An automatic upload sets it, so it never publishes content that newer content already replaced.
if_not_superseded: bool
Loading